Beauty Affairs HK

Popup system

How PopupMonitor coordinates Klaviyo forms and the theme's own popups, including timing, priority, the per-page limit and render-time hiding.

The problem it solves

Klaviyo decides for itself when to show a form. The theme has its own popups. Neither knows about the other, so without coordination a shopper can get two overlapping modals, or a form the instant the page loads.

PopupMonitor puts one object in charge. Every popup registers with the monitor, and the monitor decides which one opens and when.

The parts

PartFileResponsibility
PopupMonitorcomponents/popup-logic/index.tsHolds every registered popup, enforces priority and the per-page limit
Popupcomponents/popup-logic/popup.tsOne popup, with an ID, a display name and a priority
Adapterscomponents/popup-logic/adapters/Show and hide one kind of popup
Snifferscomponents/popup-logic/sniffers/Watch the DOM for popups that appear after page load
PopupTimercomponents/popup-logic/timer.tsFires a callback every 15 seconds
Definitionscomponents/popup-logic/definitions/One file per popup, describing when it may show

Adapters exist because a Klaviyo form and a theme popup are shown and hidden in different ways. Every adapter has the same methods, show, hide, isVisible, getId and getForceShow, so the monitor does not need to know which kind of popup it holds.

Sniffers exist because Klaviyo adds its forms to the DOM at a time the theme does not control. The Klaviyo sniffer watches for elements matching [data-testid^="klaviyo-form-"] and registers each new form with the monitor.

When a popup opens

src/entrypoints/popup-logic.ts sets up the monitor on DOMContentLoaded and makes two kinds of attempt.

The first attempt runs 5 seconds after setup. It asks the monitor for the next popup that has not been shown and tries to open it.

After that, PopupTimer fires every 15 seconds and makes the same request. The timer counts with requestAnimationFrame, so it pauses while the tab is in the background. Closing any popup resets the count to zero, so the shopper always gets the full 15 seconds between popups.

If a popup fails to open, the entrypoint removes it from the monitor, and it cannot open again on that page view. The Klaviyo adapter fails to open a form that the shopper has already closed, according to the klaviyoOnsite entry in localStorage.

The rules the monitor enforces

canShowPopup returns true only when fewer popups have opened than the per-page limit and no popup is open now.

The limit is maxPerPage, which defaults to two. The entrypoint constructs the monitor without that option, so the limit is always two. snippets/popup-logic.liquid writes a product's custom.max_allowed_popups metafield to window.theme.popupManagerConfig.maxAllowedPopups, and only when the blacklist has entries. No script reads that value.

getNextPopup returns the first registered popup that has not been shown yet. The monitor keeps popups sorted by priority, highest first. A popup that is not chosen stays in the list and can open on a later timer tick, if the limit allows.

The four registered definitions use the default priority of 0. The sort keeps equal priorities in registration order, so they open in the order they register. The Klaviyo sniffer gives each detected form a negative priority equal to the number of popups already registered, so detected Klaviyo forms rank below the definitions, in the order they appeared.

A popup whose adapter sets forceShow skips canShowPopup, so it ignores both the limit and the rule that nothing else is open. If another popup is open, the forced popup replaces it only when its priority is higher. Otherwise it does not open.

Hiding at render time

snippets/popup-logic.liquid reads the Blacklist theme setting and writes a <style> tag for each entry when the page is rendered. Each tag hides its popup with display: none before any script runs, so the popup does not flash on screen.

Each blacklist line is a selector and a type, separated by a comma. For a klaviyo entry, the snippet builds a selector from the form ID that targets Klaviyo's rendered form. For a custom entry, it uses the text as a CSS selector. The style tag's ID is popup-logic-style- followed by the selector with any . and # removed.

When the monitor opens a popup, clearSSRStyle removes the style tag whose ID matches that popup's ID. No script reads the blacklist itself. If Klaviyo injects a blacklisted form, the sniffer registers it like any other form. When the monitor later opens it, clearSSRStyle removes the hiding style and the form appears. The blacklist is therefore reliable only for popups that never register with the monitor.

Which popups are registered

src/entrypoints/popup-logic.ts registers four definitions:

DefinitionScope
sign-up-formsEverywhere except Medispa pages
sign-up-forms-medispaMedispa pages only
select-brandsA whitelist of brand product URLs
need-help-popupPages that include the section

Three more definition files are not registered: global-popup, cash-vouchers and street-sale. The global-popup import is commented out in the entrypoint, and nothing imports the other two. See Legacy and unused code.

Store staff should know this. The theme editor still shows settings for the global popup, and editing them changes nothing.

Popups outside the definitions

src/entrypoints/klaviyo-popup-trigger.ts runs the offers link on product and Medispa pages. It returns early unless window.offerPopupConfig has both a desktop and a mobile form ID. On click, it picks the desktop ID when window.outerWidth is above 768 pixels and the mobile ID otherwise, creates a Popup with forceShow set, adds it to the monitor and opens it. It creates the popup at click time because those form IDs come from theme settings, and no sniffer has registered them. The popup has the default priority of 0, so it does not open while a popup of priority 0 or higher is open.

snippets/popup-logic.liquid also contains an inline script for collection templates that bypasses the monitor. On DOMContentLoaded it calls Klaviyo's openForm with the form ID from the collection metafield custom.klaviyo_form_id_desktop or custom.klaviyo_form_id_mobile, choosing at 768 pixels of window.innerWidth. When routes.root_url has a language or market prefix, the snippet appends it to the metafield key, for example _zh_TW. The script reads a cookie named for the device type but always writes view_klavyio_popup_mobile-<language>, with a 5-day expiry. On desktop the cookie it reads is never set, so the form opens on every collection page load.

Failure modes

No popup appears at all. The manager is turned off in theme settings, or the shopper already has a Klaviyo profile cookie and every registered definition skips itself.

A blacklisted popup still appears. Either the selector does not match, or the popup registered with the monitor and the monitor opened it. For a Klaviyo form, check the form ID, not a CSS class, because the snippet builds the selector from the ID. If the ID is right, turn the form off in Klaviyo.

Only one popup ever shows. This is often expected. The next popup waits 15 seconds after the first closes, and the limit is two per page view.

A popup flashes then disappears. It rendered before the monitor registered it, and the monitor then hid it. A sniffer usually found it late.

The offers link does nothing. A desktop or mobile form ID is missing from theme settings, or another popup was open when the shopper clicked.

A Klaviyo form opens on every collection page on desktop. This is the collection page script's cookie mismatch described above.

Source map

ConcernFile
Blacklist styles, config and the collection popupsnippets/popup-logic.liquid
Registration, first attempt and timersrc/entrypoints/popup-logic.ts
Monitor and its rulessrc/components/popup-logic/index.ts
Timingsrc/components/popup-logic/timer.ts
Interfacessrc/components/popup-logic/types.ts
Klaviyo detection and prioritysrc/components/popup-logic/sniffers/klaviyo.ts
Klaviyo show and hide, including the dismissed checksrc/components/popup-logic/adapters/klaviyo.ts
Offers linksrc/entrypoints/klaviyo-popup-trigger.ts
Cookie helpers used by definitionssrc/components/utils/cookies.ts

On this page