# Popup JSON Schema

> The on-disk format for a single popup, keyed by `<wid>/<id>`. Served to the
> pixel at `GET https://app.zalify.com/api/v1/forms/:wid/:id`.

## Design philosophy

- The **visual body** is opaque HTML + inline CSS. Vibe-coded by an AI in the dashboard. No schema constraint — any HTML/CSS the LLM produces is valid.
- The **behavior and tracking** are structured. The pixel runtime handles overlay-click-to-close, esc-to-close, auto-close timers, fire-once gating, and event dispatch. The HTML body does **not** wire its own JS for these.
- Action buttons inside the body use **`data-zalify-action`** attributes. The runtime walks the shadow root, finds these, and wires them to the event map declared in `events`.
- Rendered inside a **Shadow DOM** root on the merchant's page. Style isolation in both directions. Inline `<script>` tags in the body do not execute (browsers do not run inline scripts in shadow roots) — that's by design.

## Top-level shape

```ts
interface PopupDocument {
  id: string;                    // matches the file path
  version: 1;
  title: string;                 // dashboard label only, never rendered
  behaviors: Behaviors;
  events: EventMap;
  html: string;                  // <style> + DOM, opaque
  display?: Display;             // optional, defaults to { mode: "modal" }
  form?: Form;                   // optional, wire <form data-zalify-form> in html to the subscribe API
  updated_at: string;            // ISO 8601, set by Worker on write
  updated_by?: string;           // user id, optional, set by Worker on write
}

interface Form {
  list_id: string;               // becomes subscribe.list_id in the unisubmit submit
}

interface Behaviors {
  closable_on_overlay: boolean;  // click outside the popup closes it (modal only — ignored in corner mode)
  closable_on_esc: boolean;      // Esc key closes it
  auto_close_ms: number | null;  // auto-close after N ms, null = manual only
  max_show_per_session: number;  // 1 = show once per session, 0 = unlimited
}

interface EventMap {
  // Each key is a value used in `data-zalify-action="..."` in the html.
  // Each value is the Zalify event name to fire when triggered.
  // Special keys: `open` fires on render, `close` fires on dismiss.
  open?:  string;                // e.g. "popup_opened"
  close?: string;                // e.g. "popup_closed"
  [action: string]: string | undefined;  // custom keys like "cta", "secondary"
}

// How the popup is presented on the page. Two modes — pick based on what
// the popup *is*, not what it should *look like*. Visual specifics
// (position, animation, sizing) live in the popup's own HTML/CSS.
type Display =
  | { mode: "modal" }    // (default) runtime renders a 50% black backdrop and centers the popup
  | { mode: "corner" };  // runtime is a transparent passthrough — popup CSS owns position + animation
```

### `mode: "modal"`

The runtime:
- creates a fixed-position, full-viewport flex container with `background: rgba(0,0,0,0.5)`
- centers the popup card inside it
- fades the host in on render, fades out on close
- routes overlay clicks to close (when `closable_on_overlay` is true)

The popup HTML is just the card. **Do not** set `position: fixed` on the outer container.

### `mode: "corner"`

The runtime:
- creates a fixed-position, full-viewport host with `pointer-events: none` and `z-index` max
- attaches the Shadow DOM, mounts the html
- on close, flips `data-zalify-state="closing"` on the host, then destroys it ~200ms later

The popup HTML owns **everything visual**:
- positioning (`position: fixed; bottom: 24px; right: 24px;` on the card, etc.)
- entry/exit animation (`@keyframes` on the card; key off `:host([data-zalify-state="closing"]) .card` for the exit)
- `pointer-events: auto` on the card so it catches clicks (the host is passthrough)
- no backdrop — empty regions are click-through to the merchant page

Use this when the popup should *not* feel like a modal: bottom-right toasts, slide-in side panels, branded "free shipping" prompts, etc.

## Full example

```json
{
  "id": "p_summer_sale",
  "version": 1,
  "title": "Summer sale 30% off",
  "behaviors": {
    "closable_on_overlay": true,
    "closable_on_esc": true,
    "auto_close_ms": null,
    "max_show_per_session": 1
  },
  "events": {
    "open":  "popup_opened",
    "close": "popup_closed",
    "cta":   "popup_cta_clicked"
  },
  "html": "<style>.card{padding:32px;border-radius:16px;background:linear-gradient(135deg,#fde68a,#fb923c);box-shadow:0 20px 60px rgba(0,0,0,.2);max-width:420px;font-family:system-ui}.card h1{margin:0 0 8px;font-size:24px;color:#7c2d12}.card p{margin:0 0 16px;color:#7c2d12}.btn{display:inline-block;padding:10px 18px;border-radius:8px;background:#7c2d12;color:#fff;border:0;cursor:pointer;font-size:14px}.btn.secondary{background:transparent;color:#7c2d12;margin-left:8px}</style><div class=\"card\"><h1>Summer sale — 30% off</h1><p>Add code SUMMER30 at checkout. Valid through Friday.</p><button class=\"btn\" data-zalify-action=\"cta\">Shop now</button><button class=\"btn secondary\" data-zalify-action=\"close\">No thanks</button></div>",
  "updated_at": "2026-05-13T20:00:00Z"
}
```

When the pixel fires this popup:

1. Fetches the JSON from `app.zalify.com/api/v1/forms/<wid>/p_summer_sale` (same origin as pixel-config).
2. Checks `max_show_per_session` against `sessionStorage` — bails if already shown.
3. Creates a host `<div>` on `document.body`, attaches a Shadow DOM root.
4. Injects the `html` string into the shadow root.
5. Walks for `[data-zalify-action]` elements; binds each `click` to:
   ```
   zalify('track', events[action]);
   if (action === 'close') closePopup();
   ```
6. Wires overlay-click (if `closable_on_overlay`) and Esc (if `closable_on_esc`) to close.
7. Fires `events.open` event (if defined).
8. On close, fires `events.close` event (if defined), removes the host element.

## Form submit (email capture)

> Capturing email on a page that *isn't* a Zalify popup (e.g. your storefront's footer signup or a dedicated landing page)? See [forms.md](forms.md) — same submit endpoint, you own the form and submit handler.

When `form.list_id` is set on the popup doc, any `<form data-zalify-form>` inside the html gets wired up automatically. Authoring contract:

- Use **input `name` attributes** matching the wire keys: `email`, `phone`, `firstName`, `lastName`, `language`, `emailMarketingConsent`, `smsMarketingConsent`. Other inputs are ignored on the wire (useful for visual-only fields). `smsMarketingConsent` is accepted but ignored server-side until SMS support lands.
- The submit button is a normal `<button type="submit">` — no `data-zalify-action` needed.
- Declare success/error panels with `<div data-zalify-form-show="success" hidden>...` and `<div data-zalify-form-show="error" hidden>...`. The runtime toggles `hidden` based on form state.
- Style by state: `form[data-zalify-form-state="submitting"] button { opacity: .6 }` etc. The state attribute flips through `idle → submitting → success | error`.

Runtime behavior on submit:
1. `preventDefault()`, collect FormData restricted to the allowed keys above.
2. Fire `form_submitted` event with `{ form_id, list_id, ...fields }`.
3. Flip state to `submitting`; drop re-submits while in flight.
4. `POST <subscribeBaseUrl>/v1/public/unisubmit` with the submit envelope as JSON (see [forms.md](forms.md)). Default `subscribeBaseUrl` is `https://reach.zalify.com`; override on pixel init for staging.
5. On 2xx: fire `lead` event with `{ email }`, flip state to `success`.
6. On non-2xx or network error: flip state to `error`. No `lead` event.

Example html with the form:

```html
<style>
  .card { padding: 24px; max-width: 360px; }
  .card[data-zalify-form-state="submitting"] button { opacity: .5; cursor: wait; }
  .card[data-zalify-form-state="success"] form,
  .card[data-zalify-form-state="error"] form { display: none; }
</style>
<div class="card">
  <h1>Join us for 10% off</h1>
  <form data-zalify-form>
    <input name="email" type="email" required placeholder="you@example.com" />
    <input name="firstName" type="text" placeholder="First name" />
    <label><input name="emailMarketingConsent" type="checkbox" checked /> Send me promos</label>
    <button type="submit">Subscribe</button>
  </form>
  <div data-zalify-form-show="success" hidden>Thanks! Check your inbox.</div>
  <div data-zalify-form-show="error" hidden>Something went wrong — please try again.</div>
</div>
```

## Authoring rules (for the AI vibe-coding)

These are the rules the dashboard system prompt teaches the LLM:

1. **No `<script>` tags** in the html. They won't execute in shadow DOM, and if they did they'd be a security mess.
2. **No `<link rel="stylesheet" href="...">`**. All styles go inline in a `<style>` block at the top of the html. (`<link rel="stylesheet">` to a same-origin host is technically allowed but kills the self-contained guarantee.)
3. **`<img src="https://...">` is allowed.** Use stable image URLs (CDN-hosted, not localhost).
4. **Action buttons use `data-zalify-action="<key>"`.** Don't write `onclick="..."`. Don't fire events manually. The runtime does it.
5. **Event keys**: `close` always works (built-in close). `open`/`close` event names fire automatically. Any custom key like `cta`, `secondary`, `learn_more` must appear in `events` with a tracked event name.
6. **Scope your CSS**: prefix all selectors with a unique class (e.g. `.popup-summer-sale h1 { ... }`) — Shadow DOM gives you isolation, but defensive scoping makes the html portable if it's ever rendered outside shadow context (e.g. preview iframe).
7. **Positioning rules depend on `display.mode`:**
   - `modal` (default): **do not** set `position: fixed` on the outer card. The runtime centers it inside a backdrop.
   - `corner`: **do** set `position: fixed; bottom/right/...` on the outer card. The runtime is a transparent stage; the card positions itself. Also set `pointer-events: auto` on the card (the host has `pointer-events: none` so empty regions are click-through).
8. **Entry/exit animation:**
   - `modal`: handled by the runtime (fades in and out). Don't write `@keyframes` for entry on the outer card.
   - `corner`: **owned by the popup CSS.** Write `@keyframes` on the card for entry (e.g. slide-up). For the exit animation, use the selector `:host([data-zalify-state="closing"]) .your-card { animation: ... forwards }` — the runtime flips this attribute on close and waits ~200ms before destroying the host.

## Path layout in R2

```
zalify-popups/
  <wid>/
    <id>.json                ← current version, what GET serves
    _versions/<id>/<ts>.json ← snapshots on write (forward-compat, not in v1 reads)
  _trash/
    <wid>/<id>.<ts>.json     ← soft deletes (forward-compat, not in v1 reads)
```

The read route only ever fetches `<wid>/<id>.json`. Versions and trash are write-side concerns for when the write endpoints land.

## Forward-compat rules

The pixel runtime:
- Rejects responses with `version !== 1` (falls back to "popup unavailable").
- Ignores unknown keys in `behaviors`.
- Ignores unknown keys in `events`.
- Ignores unknown top-level keys.

Means **adding** new behaviors (e.g. `closable_on_outside_tap`), new event keys, or new top-level metadata is non-breaking. Renames or removals require a `version` bump.

## Out of scope for v1

- A/B variants (`variants: [{...}, {...}]` with weights) — add later.
- Targeting overrides inside the popup doc — targeting lives in the pixel-config `triggers[]` array, not here. This file describes *what* the popup looks like, not *when* to show it.
- Per-locale variants — add later if needed.
- Server-side rendering — popups render client-side only.
