Skip to main content

Documentation

No results found.
Features Members

Popups & Announcement Bars

Dashboard-managed popups and announcement bars for the whole site: run a holiday-hours bar across every page, schedule a sale popup for next weekend, catch abandoning visitors with an exit-intent offer — without touching any page in the edi...

Dashboard-managed popups and announcement bars for the whole site: run a holiday-hours bar across every page, schedule a sale popup for next weekend, catch abandoning visitors with an exit-intent offer — without touching any page in the editor. Multiple items can run at once, each with its own trigger, frequency cap, schedule window, and page targeting, all managed from Dashboard → Popups & Bars.


The problem

Announcements used to live in two places, both wrong for campaigns:

  • Static header banner rows — 46 of the header design-library variants shipped a baked-in announcement strip. The text was editable, but the strip was permanent: no scheduling, no dismissal, no frequency capping, no "only on the shop pages," and turning it off meant switching header templates. Those banner header variants have been removed — this feature replaces them (the contact-banner header variants, which show social links and a phone number rather than announcements, remain).
  • Page-embedded popup rows (the free Popups design-library category, popup-rows.md) — great for a one-page promo edited like any other row, but per-page by design: a site-wide campaign would mean adding the row to every page, and there is no scheduling, exit-intent, or admin-controlled dismissal memory.

What you get

Two display types, any number of concurrent items — every item's look is a page-builder design:

  • Announcement bar — a slim strip pinned to the top or bottom of the page. Top bars stay visible while scrolling and push stock fixed/sticky headers down so nothing overlaps; bottom bars float above the content.
  • Popup — a centered modal panel over a dimmed backdrop with full dialog accessibility (focus trap, Escape, backdrop click, focus restore, reduced-motion respected). The panel surface itself belongs to the design, so photo and solid-brand layouts bleed edge to edge.

Designs — built with the page builder

Creating an item starts with a design gallery (live previews, per type): bars ship classic / gradient promo / flash sale; popups ship classic card / photo split / photo backdrop / flash sale / newsletter. Picking one materialises the design as this item's own editable fragment and drops you straight into the page editor — text, images, buttons, colors, spacing, everything is edited exactly like a page row (AI images, brand presets, class editing included). "Edit design" on the item and on the index reopens it any time. A/B variants clone the design (with its saved content) so each variant can diverge visually.

Generate with AI (v2). When an AI text provider is configured (Settings → API Keys), the create page's Design section offers a Generate with AI mode beside the gallery: describe the campaign, optionally pin a template (default "Let AI choose"), and the model picks the best-fitting design from the type's catalog and writes copy for its actual fields — saved as ordinary content overrides on the fresh fragment, so the editor opens on the AI copy and reset-to-default still returns the template text. Copywriting failure never blocks the create (the template text is kept, with a notice). Photo spots stay as placeholders — the editor's existing AI image generation covers those. The mode is hidden entirely when no provider is configured.

Preview on a real page (v2). While editing a design, a Preview on page dropdown beside the "Popup design" badge swaps the editor's neutral backdrop for any public page: the iframe renders that page's preview with the in-progress fragment forced visible over it — a bar in its top/bottom strip, a popup open over the dimmed overlay at its design's panel width. Only the item being edited shows; every other campaign item stays suppressed in editor previews.

Preview from the list. Every designed item gets an eye icon in the Popups & Bars list's actions column. It opens the item's saved design on an isolated canvas — a popup centered over the dimmed overlay at its panel width, a bar as its full-width strip (pinned to the bottom when that's where it lives), with the close button present or absent exactly as the item's setting says. Triggers, scheduling, and frequency capping are deliberately not simulated: this answers what does it look like, while the editor's "Preview on page" (below) answers how does it behave on a real page. Only the previewed item renders — the campaign runtime stays off, so no other site-wide item rides along.

Panel width per popup design (v2). A campaign popup template can declare @panelWidth max-w-2xl in its frontmatter (named max-w-* tokens only) to widen the shell's centered panel from the default max-w-lg. The directive is carried into the materialised fragment's @popup-fragment header, preserved on A/B clones, and applied by the public shell, the editor's neutral preview, and the preview-on-page shell. Photo Split ships with the wider max-w-2xl panel.

Analytics (v2)

Every item's edit page carries an Analytics card: a last-30-days funnel — impressions → conversions + conversion rate — charted day-by-day from the shared ab_daily_stats rollups (standalone items report under a per-item experiment key, so this isn't limited to A/B tests). For items in an A/B test the chart shows one conversions line per variant plus a per-variant 30-day totals table. The chart is server-rendered SVG following the Analytics dashboard's sparkline conventions — no JS chart dependency.

Some designs carry functional spots:

  • The flash sale designs include a countdown that ticks to the item's end date automatically.
  • The newsletter popup bakes in the signup form (email + honeypot posting to the existing newsletter/subscribe endpoint; shown while Marketing is enabled). Signups count as conversions.

Per item (behavior stays in the dashboard form):

Setting Options
Design Gallery pick at create; edited in the page editor; close button on/off
Trigger Immediately · after N seconds · at N% scroll depth · exit intent (desktop pointer leaving the viewport top)
Frequency Every page view · once per session · once every N days · until dismissed
Schedule Optional start and end datetimes — enforced in the visitor's browser, so campaigns start/stop on time even on cached pages; optional weekly recurrence (days of week + daily hours, visitor-local)
Targeting Newline path patterns with * wildcards (/shop, /shop/*); empty = every page; language prefixes are stripped (/es/shop matches /shop)
Audience New vs. returning visitors · logged-in members vs. guests · UTM source/medium/campaign · referrer contains · cart-not-empty (Ecommerce)
Promo code Picked from the shop's live discounts (active, unexpired, redemptions remaining) so a campaign can't advertise a dead offer — with an "Another code…" escape hatch for codes minted elsewhere. Appended client-side to the design's links as ?promo=CODE when the item binds, and auto-applied when the visitor reaches the cart (Ecommerce). With no live discounts the field falls back to plain text and points at Shop → Discounts. A code the shop later stops offering stays editable rather than silently vanishing from a running campaign.
Priority Sort order — bars stack in order; when several popups match a page, the lowest sort shows (one popup per page view)
A/B test Any item can spawn weighted variants (each with its own design copy); visitors are stickily assigned one and the dashboard tracks impressions/conversions per variant

Editing an item offers "Show again to visitors who already dismissed or saw it" — it bumps a per-item generation counter that invalidates everyone's stored dismissal/frequency memory, so a refreshed campaign re-surfaces without a typo-fix accidentally re-blasting anyone.

Caching architecture (why it's all client-side)

Same constraint as the cookie consent banner and the age gate: ResponseCache stores one HTML body per URL served to every visitor, so nothing in the markup may vary per visitor.

  • Server-side (per URL, cache-safe): which items render into a page — feature on, item enabled, path matches, and not definitively ended. Items scheduled for the future are rendered (hidden) with their start/end unix stamps in a data-wpcms-popup payload.
  • Client-side (per visitor): everything else. resources/js/popups.js — lazy-imported by the public bundle only when a [data-wpcms-popup-item] is present — evaluates the schedule window against the visitor's clock, applies frequency caps and dismissals from localStorage/sessionStorage (wpcms_popups, keyed {id}:{generation} → suppressed-until timestamp), and installs the trigger listeners.
  • Zero flash, zero layout shift: immediate-trigger bars are server-rendered visible; a tiny pre-paint script in the <head> (from popups::public.head) hides suppressed or out-of-window ones before first paint — the inverse of the age gate's fail-closed trick. Every other trigger renders hidden and is revealed by JS.
  • Cache invalidation: saving/toggling/deleting an item and toggling the feature clear the response cache; an hourly popups:sync-cache LazyCron sweep clears it when a schedule boundary passes, purging stale campaign bytes from stored bodies (correctness never depends on the sweep — the visitor's clock already enforces the window).
  • Disabled = byte-identical output. Every include point short-circuits on Features::enabled('popups'); no markup, no JS, no CSS ships.

Interplay with other overlays

  • Stacking order: sticky headers (z-50) < popups/bars (z-[55]) < cookie consent banner (z-[60]) < age verification gate (z-[80]).
  • While the age gate locks the page (html.age-gate-lock), all popup/bar markup is hidden by one CSS rule and the runtime defers entirely until the age-gate:passed event — trigger timers and frequency stamps never burn behind the overlay.
  • A bottom bar and the bottom cookie-consent banner can coexist; the consent banner deliberately stacks above.
  • Popups/bars never render inside the page editor or design-library preview iframes.

Architecture (for developers)

Piece File Role
Feature module app/Features/Popups/ Model, migrations, CRUD, runtime, design service, command
Model Models/PopupItem.php Columns (incl. design_slug), scopes (renderable, activeNow), clientPayload(), status()
Design service Support/PopupDesignService.php Materialises campaign templates into resources/views/popups/ fragments (the LayoutService::install twin); clone for variants; explicit delete; panelWidth() reads the @panelWidth-derived header attr
AI copywriter Support/PopupAiCopywriter.php The create flow's "Generate with AI": template pick from the catalog + copy fill (PageCopyExtractor slots → one AiTextService call → global overrides); fails soft
Design templates resources/design-library/rows/campaign-bars/ + campaign-popups/ The gallery (CampaignBars/CampaignPopups row categories, @requiresFeature popups; distinct from the free page-embedded Popups category)
Runtime selection Support/PopupsRuntime.php Per-URL item selection (memoized per request; design-less items filtered), bulk override warm, shouldRender() preview exclusions, editorPreviewShell() for the preview-on-page iframe, savedShell() for the list's preview canvas
Path matching app/Support/PathPatterns.php Shared wildcard matcher (also used by the age gate)
Public shells resources/views/public/ head (pre-paint + suppression CSS), bars-top, overlays, _bar, _popup — the shells own only the runtime contract (payload attrs, positioning, close button) and @include each item's fragment
Layout includes layouts/public.blade.php + partials/head.blade.php Top bars before the header include; bottom bars + popups at body end; head partial next to the age-gate head
Client runtime resources/js/popups.js Triggers, frequency, dismissals, a11y, delegated conversions, bind-time promo link rewriting, countdown, top-bar header offset (--wpcms-topbar-h)
Lazy boot resources/js/public.js Presence-gated import, re-runs on livewire:navigated
Cache sweep Console/SyncPopupsCacheCommand.php popups:sync-cache, hourly via LazyCron
Dashboard card registered in PopupsServiceProvider.php popups_active widget: active-now count + scheduled subline
Tests tests/Feature/PopupsPublicTest.php, PopupsDashboardTest.php, PopupDesignServiceTest.php, PopupFragmentEditorTest.php, PopupDesignMigrationTest.php, PopupAiCreateTest.php Inert-when-off, markup-when-on, schedule, targeting, CRUD, materialise/clone/delete, panel width, editor integration + preview-on-page, funnel card, AI create, legacy migration

Implementation notes:

  • Design fragments. Each item's design is a row-document blade under the gitignored resources/views/popups/ tree ({kind}-{template}-{rand6}.blade.php + .rowdoc.json sibling; row slug {template}:{rand6} on popup_items.design_slug). The page editor opens it via ?file=popups/… as a standalone document (feature-gated 404 when off; add-row picker scoped to the matching campaign category; preview iframe wraps it in layouts/popup-preview.blade.php mimicking the shell silhouette). Saved copy lives in content_overrides at the global tier keyed by the fragment slug; PopupsRuntime bulk-warms those slugs per render (PagePreload::loadOverridesForSlugs) — no page-sidecar involvement. Items without a materialised fragment never render.
  • List preview plumbing. dashboard/popups/{item}/preview (manager+, feature-gated) renders preview-page.blade.php — the public-bundle twin of layouts.popup-preview, reusing the same _preview-shell partial the preview-on-page flow uses, but fed by PopupsRuntime::savedShell() (the item's materialised fragment) instead of the editor's per-user in-progress copy. 404s when the item has no fragment. The route name is registered in design_library_preview(), which is what keeps the campaign runtime off the canvas: without it, partials.head boots popups::public.head and every site-wide item's pre-paint suppression block rides along.
  • Preview-on-page plumbing. The editor's "Preview on page" dropdown is the popup twin of the header/footer partial-preview flow: the iframe loads the backdrop page's preview token with ?popup_preview={fragment file slug}, while keystrokes rewrite a per-user pages/_editor-previews/partials/{uid}-popup-{fileSlug}.blade.php + matching preview sidecar (warmed by PagePreload::warmPartialPreviewSidecars, which globs header/footer/popup types). layouts.public force-includes _preview-shell.blade.php via PopupsRuntime::editorPreviewShell() — which only resolves on the design-library.preview route for the authed user's own preview file, so nothing ever leaks to the public render, and the blanket shouldRender() suppression keeps every OTHER campaign item out of editor previews.
  • Top-bar header offset. All top bars render in one sticky top-0 z-[55] container before the header include. When visible, popups.js measures the container into --wpcms-topbar-h and adds html.wpcms-topbar; a rule in the head partial shifts stock fixed/sticky header elements (header.fixed, header .sticky, …) down by that amount so overlay headers (e.g. the transparent E1) don't overlap the bar. Removed when the last top bar is dismissed.
  • Animations reuse the x-dl.modal preset vocabulary (Modal::enterPresets() / exitPresets() — tailwindcss-animate classes already force-emitted in the public bundle); bars slide in from their edge. Campaign design templates must NOT use x-dl.modal themselves — the shell owns the overlay/panel and dl-popup.js would double-drive.
  • Conversions & promo. A conversion is any link click inside the item (or an explicit [data-wpcms-popup-cta] element), excluding the close button and [data-popup-close] dismissals, plus signup submits. The promo code ships in the payload and popups.js rewrites the fragment's links (?promo=CODE) once at bind time — middle-click and copy-link safe.
  • CSS bundles. The shells live in the module tree (Tailwind auto-scans for public.css); the fragments tree is gitignored, so it's @sourced into public.css AND scanned by the runtime CSS supplement (RuntimeCssSupplement::sourceBladeFiles) like shared rows — editor-typed classes render on node-free installs.
  • Legacy cutover. The materialize_popup_designs migration converted pre-design rows to classic templates with their headline/message/CTA carried as content overrides and old bar style presets carried as section-classes overrides.
  • Multilingual sites: item content is single-language in v1 — create one item per language and target it with the language-prefixed paths (/es/*).
Popup rows (free) Popups & Announcement Bars (addon)
Where managed Page editor (design-library row) Dashboard → Popups & Bars (design edited in the page editor)
Scope The page the row is on Site-wide or path-targeted
Display types Modal popup Modal popup + top/bottom bars
Triggers Delay Immediate, delay, scroll depth, exit intent
Frequency Once per day / once / every visit (per page) Every view / session / N days / until dismissed (site-wide)
Scheduling — Start/end datetimes
Content Full design-library rows (images, buttons, AI, translations) Full design-library designs too — a campaign gallery pick, then page-builder editing
Dismissal memory reset — Per-item generation bump on edit

Deferred to a future version: per-item multilingual content.

v2: audience targeting, signup, countdown, recurrence, A/B tests

All v2 additions keep the caching architecture intact — the cached HTML stays identical for every visitor, and the new per-visitor decisions run client-side in popups.js:

  • Audience targeting. Rules live in a JSON audience column and ship in the client payload; popups.js evaluates them at boot. UTM params and the landing referrer are captured once per session into sessionStorage (wpcms_landing) so a rule keeps matching as the visitor navigates past the landing page; new-vs-returning uses a localStorage first-seen stamp plus a session flag; member-vs-guest reads the existing wpcms_visitor cookie (written server-side for the visibility shim); cart-not-empty reads the wpcms_cart_n cookie that Ecommerce\Support\Cart now syncs on every mutation (both cookies are excluded from cookie encryption in bootstrap/app.php). Audience-targeted immediate bars render hidden (not pre-paint visible) — a moment of delay beats a flash of the wrong audience.
  • Newsletter signup form. The newsletter popup design bakes in an email field + honeypot posting to the existing CSRF-exempt newsletter/subscribe endpoint (the form renders while Marketing is enabled — the gate is a live Blade conditional in the fragment) — double opt-in, resubscribes, and the Analytics subscriber goal all come along for free. Submits also count as popup conversions.
  • Countdown timer. Designs that carry a data-wpcms-countdown spot (the flash-sale bar and popup) tick down to the item's end date via popups.js; at zero the item hides itself. No end date, no ticking.
  • Weekly recurrence. Day-of-week checkboxes plus an optional daily time window, enforced against the visitor's local clock ("every weekend" means the visitor's weekend). An overnight window (start > end) wraps through midnight. The pre-paint head script enforces recurrence too, so an out-of-window immediate bar never flashes.
  • A/B variants. Built on the shared A/B testing engine. "Create A/B variant" on the edit page clones the item, labels the pair A/B (up to 4 variants), and groups them under one experiment key; every variant renders into the cached page and the visitor's browser stickily picks one by weight. Impressions (reveal/open) and conversions (CTA click, signup submit) report to the engine's beacon; the edit page shows per-variant stats with a two-proportion confidence readout, editable traffic split, and an "end test" action that keeps the winner and disables the losers. For immediate bars the pre-paint head script performs the same assignment before first paint, so a test never flashes both variants. Standalone items report too (per-item key), giving every popup an impressions → conversions column on the index.