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-bannerheader variants, which show social links and a phone number rather than announcements, remain). - Page-embedded popup rows (the free
Popupsdesign-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/subscribeendpoint; 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-popuppayload. - 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 fromlocalStorage/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>(frompopups::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-cacheLazyCron 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 theage-gate:passedevent — 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.jsonsibling; row slug{template}:{rand6}onpopup_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 incontent_overridesat the global tier keyed by the fragment slug;PopupsRuntimebulk-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 oflayouts.popup-preview, reusing the same_preview-shellpartial the preview-on-page flow uses, but fed byPopupsRuntime::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 indesign_library_preview(), which is what keeps the campaign runtime off the canvas: without it,partials.headbootspopups::public.headand 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-userpages/_editor-previews/partials/{uid}-popup-{fileSlug}.blade.php+ matching preview sidecar (warmed byPagePreload::warmPartialPreviewSidecars, which globsheader/footer/popuptypes).layouts.publicforce-includes _preview-shell.blade.php viaPopupsRuntime::editorPreviewShell()— which only resolves on thedesign-library.previewroute for the authed user's own preview file, so nothing ever leaks to the public render, and the blanketshouldRender()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.jsmeasures the container into--wpcms-topbar-hand addshtml.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.modalpreset 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 usex-dl.modalthemselves — 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 intopublic.cssAND scanned by the runtime CSS supplement (RuntimeCssSupplement::sourceBladeFiles) like shared rows — editor-typed classes render on node-free installs. - Legacy cutover. The
materialize_popup_designsmigration 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 vs. this addon
| 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
audiencecolumn and ship in the client payload; popups.js evaluates them at boot. UTM params and the landing referrer are captured once per session intosessionStorage(wpcms_landing) so a rule keeps matching as the visitor navigates past the landing page; new-vs-returning uses alocalStoragefirst-seen stamp plus a session flag; member-vs-guest reads the existingwpcms_visitorcookie (written server-side for the visibility shim); cart-not-empty reads thewpcms_cart_ncookie thatEcommerce\Support\Cartnow 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/subscribeendpoint (the form renders while Marketing is enabled — the gate is a live Blade conditional in the fragment) — double opt-in, resubscribes, and the Analyticssubscribergoal all come along for free. Submits also count as popup conversions. - Countdown timer. Designs that carry a
data-wpcms-countdownspot (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.