# Popup Component — JSON Schema

**Purpose:** Define the `PopupModal` JSON that the builder authors and the renderer consumes. This is **the data shape only**: how a host wires the editor and acts on the result is [`docs/EMBEDDING.md`](./docs/EMBEDDING.md), and the guided tour is the [README](./README.md).

> The TypeScript in `src/schema/types.ts` is the source of truth; this document explains it. Where a field has an exported helper for reading it (`spanOf`, `placementOf`, `defaultCardWidth`, `effectiveTargets`), prefer the helper over defaulting at each call site.

---

## 1. Top-level object — `PopupModal`

```ts
interface PopupModal {
  id: string; // unique id of this modal component
  name: string; // human label for the merchant (list view, not rendered)
  url: string; // merchant API endpoint hit on submit
  method: "GET" | "POST"; // request type to the merchant API
  trigger: PopupTrigger; // when the modal opens
  design: PopupDesign; // layout template
  placement?: PopupPlacement; // 'inline' (default) embeds in the page; 'modal' overlays it (see §7)
  formLayout?: PopupFormLayout; // 'stack' (default) or 'row' — how the body fields flow
  steps?: PopupSteps; // split the form across screens at its `page-break` items — omitted = one screen
  language?: "en" | "he"; // the language the popup speaks — decides its own wording and direction
  direction?: "ltr" | "rtl"; // text direction of the popup — default 'ltr', overruled by `language`
  launcher?: PopupLauncher; // the button that opens a `click`-triggered form (see §2)
  borderRadius?: number; // modal corner radius in px — default 14
  fontFamily?: string; // 'host' to inherit the page's font, or a curated Google font name
  imageUrl?: string; // image source for the image-* designs

  // --- Card appearance (each overrides a built-in default) ---
  backgroundColor?: string; // card fill (hex) — default white
  backgroundOpacity?: number; // 0–1 alpha on backgroundColor — default opaque
  cardOpacity?: number; // 0–1 on the whole card, content included — default 1
  backdropOpacity?: number; // 0–1 dim behind a modal — default 0.55
  imageScrimOpacity?: number; // 0–1 scrim over the image-behind photo — default 0.45
  imageFit?: "cover" | "contain"; // how the image fills its area — default 'cover'
  imagePosition?: "center" | "top" | "bottom" | "left" | "right"; // — default 'center'
  padding?: number; // body padding in px — default 28
  width?: number; // card max width, in widthUnit (see §8)
  widthUnit?: "px" | "%"; // unit for width — default 'px'
  minHeight?: number; // card min height in px — default 350

  htmlId?: string; // merchant-side mount point / page gate (see §7)
  urls?: string[]; // the page addresses it runs on — empty/absent = everywhere (see §7)
  dismissible?: boolean; // close button / overlay-click / esc — default true; forced on for a `click` trigger
  frequency?: PopupFrequency; // how often it may re-open — default 'always'
  onSuccess?: SubmitSuccess; // what the shopper sees after a successful submit (§6)
  onError?: SubmitError; // what the shopper sees after a failed submit (§6)
  onSubmitCallbackPayload?: CallbackPayloadEntry[]; // static extra key/value pairs sent with every submit (§6)
  submitTargets?: SubmitTarget[]; // extra hidden endpoints a host appends (§6)
  emailAutomations?: EmailAutomation[]; // declarative "email someone on submit" intents (§6)
  contentItems: ContentItem[]; // ordered items rendered in the modal body
}

interface CallbackPayloadEntry {
  key: string; // the payload key
  value: string; // the static value sent under that key
}
```

| Field          | Type              | Notes                                                                                                                                                                           |
| -------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`           | `string`          | Stable unique id for the modal.                                                                                                                                                 |
| `name`         | `string`          | Internal name; shown in the Dashboard list, never rendered in the popup.                                                                                                        |
| `url`          | `string`          | The merchant's endpoint the form submits to.                                                                                                                                    |
| `method`       | `'GET' \| 'POST'` | How the submit request is sent (see [§6 Submit assembly](#6-how-a-submit-is-assembled)).                                                                                        |
| `trigger`      | `PopupTrigger`    | See [§2](#2-popuptrigger).                                                                                                                                                      |
| `design`       | `PopupDesign`     | See [§3](#3-popupdesign).                                                                                                                                                       |
| `placement`    | `"inline" \| "modal"?` | `inline` (the default) embeds the form in the page flow at full width; `modal` is the full-page overlay. See [§7](#7-htmlid--merchant-controlled-placement).                |
| `formLayout`   | `"stack" \| "row"?` | How the body fields flow. `stack` (default) gives each item its own line; `row` lays them out as a wide single-row signup bar. Independent of `placement`.                     |
| `steps`        | `PopupSteps?`     | Splits the form across screens at its `page-break` items. Omitted → one screen, which is what every form did before steps existed. See [§3](#popupsteps--one-screen-at-a-time).                     |
| `language`     | `"en" \| "he"?`   | The language the popup speaks: the renderer's own wording (Next, Submitting…, validation messages) and, through it, the direction it lays out in. Authored copy isn't translated. Independent of the builder UI language. |
| `direction`    | `"ltr" \| "rtl"?` | Text direction of the rendered popup. Default `ltr`, and overruled by `language` when the form carries one — the builder writes both together. A form saved before `language` existed is read as Hebrew when its direction is `rtl`, which is what that control meant at the time. |
| `borderRadius` | `number?`         | Corner radius of the modal card, in px. Default `14`.                                                                                                                           |
| `fontFamily`   | `string?`         | Omitted → the built-in default (Arial). `'host'` → inherit the surrounding page's font (only meaningful on the live popup; the builder preview can't see it). Anything else is a curated Google font family, loaded on demand by the renderer. |
| `imageUrl`     | `string?`         | Image URL used by `image-behind` / `image-right` / `image-left` / `image-top`. Ignored by `basic`.                                                                              |
| `backgroundColor` | `string?`      | Card fill, as hex. Default white. See [§8](#8-card-sizing--appearance).                                                                                                          |
| `backgroundOpacity` | `number?`    | `0`–`1` alpha applied to `backgroundColor` (a translucent card fill). Default opaque.                                                                                            |
| `cardOpacity`  | `number?`         | `0`–`1` on the whole card, its content included. Default `1`.                                                                                                                    |
| `backdropOpacity` | `number?`      | `0`–`1` dim of the overlay behind a **modal**. Default `0.55`. Ignored when `placement` is `inline`.                                                                             |
| `imageScrimOpacity` | `number?`    | `0`–`1` darkening scrim over the photo in the `image-behind` layout, so overlaid copy stays readable. Default `0.45`.                                                            |
| `imageFit`     | `"cover" \| "contain"?` | How the image fills its area. Default `cover` (crops to fill); `contain` fits the whole image.                                                                             |
| `imagePosition` | `"center" \| "top" \| "bottom" \| "left" \| "right"?` | Where the image sits in its area (a CSS `background-position` keyword). Default `center`.                                             |
| `padding`      | `number?`         | Card body padding in px. Default `28`.                                                                                                                                          |
| `width`        | `number?`         | Card max width, read in `widthUnit`. Picking a template writes the width it's designed around. See [§8](#8-card-sizing--appearance).                                            |
| `widthUnit`    | `"px" \| "%"?`    | Unit for `width`. Default `px`; `%` is read against the container the form sits in.                                                                                             |
| `minHeight`    | `number?`         | Card minimum height in px. Default `350`.                                                                                                                                       |
| `htmlId`       | `string?`         | Page gate, and the anchor an `inline` form embeds into (see [§7](#7-htmlid--merchant-controlled-placement)).                                                                     |
| `urls`         | `string[]?`       | The page addresses the form runs on. Empty or absent means every page it's loaded on. See [§7](#urls--which-pages-it-runs-on).                                                   |
| `dismissible`  | `boolean?`        | Whether the shopper can close it (X / overlay click / esc). Default `true`. Modal-only, like `trigger` and `frequency`: switching to `inline` normalizes all three. Ignored (always closable) when the trigger is `click` — a form the visitor opened is one they can always close. |
| `frequency`    | `PopupFrequency?` | Re-open cap, enforced via `localStorage`. Default `'always'`. Modal-only.                                                                                                       |
| `onSuccess`    | `SubmitSuccess?`  | Post-submit success behavior. See [§6](#after-submit--onsuccess--onerror). Default: close.                                                                                      |
| `onError`      | `SubmitError?`    | Post-submit error behavior. See [§6](#after-submit--onsuccess--onerror). Default: inline message, form stays open.                                                              |
| `onSubmitCallbackPayload` | `CallbackPayloadEntry[]?` | Static extra `key`/`value` pairs merged into every submit (the "custom submit callback values", currently hidden from the builder UI but still honored by the renderer). See [§6](#6-how-a-submit-is-assembled).                        |
| `submitTargets` | `SubmitTarget[]?` | Extra hidden endpoints fired alongside the primary `url`: mailing-list automations, webhooks. Typically appended by the embedding host rather than authored in the builder. See [§6](#extra-submit-targets). |
| `emailAutomations` | `EmailAutomation[]?` | Declarative "on submit → email this address" intents. **The builder and renderer never send anything** — the embedding host reads these off the JSON and sends the mail. See [§6](#email-automations). |
| `contentItems` | `ContentItem[]`   | See [§4](#4-contentitem). Rendered in `order` order.                                                                                                                            |

---

## 2. `PopupTrigger`

Discriminated union on `type` — when the modal opens.

```ts
type PopupTrigger =
  | { type: "delay"; seconds: number } // open N seconds after page load
  | { type: "scroll"; percent: number } // open after scrolling N% of the page
  | { type: "immediate" } // open as soon as the component mounts
  | { type: "click" }; // wait for a click — on our button, or on one of the site's own
```

### `click` — opening from a button

A `click` form renders nothing until something asks for it. Two things can:

- **The launcher** — a button the merchant designs in the builder and the renderer
  draws where the form would have gone (inside the `htmlId` element, or at the end
  of `<body>` when there is no id). Unstyled, it takes the submit button's fill,
  the card's rounding, and the renderer's own wording in the form's language, so a
  merchant who picks "on button click" and touches nothing else still gets a button
  that matches the form it opens.
- **The site's own element** — anything on the page carrying
  `data-creaditor-open="<form id>"`. The attribute may also be left empty
  (`data-creaditor-open`), which opens whichever form is mounted — with several
  click forms on a page it opens all of them, so name the id when there is more
  than one. Bound on the document, so an element rendered after the form mounted
  still works.

Two mount-layer settings don't apply to a `click` form, because both describe a
form that shows itself and this one waits to be asked:

- **`frequency` is not consulted.** The button answers every press, on every
  visit. A cap here would spend itself on the first press and leave the page with
  a hole where the button was.
- **`dismissible` is forced on** for a `click` modal. A visitor who opened
  something by choice has to be able to close it; a full-page overlay with no way
  out, entered deliberately, reads as a broken page. Use a timer trigger if a
  form genuinely must be read before it goes away.

```ts
interface PopupLauncher {
  label?: string; // omitted → the renderer's own wording for the language
  styleProps?: StyleProps; // fill, text colour, size, width %, alignment
  borderRadius?: number; // omitted → the card's own borderRadius
}
```

Unlike the other triggers, `click` survives a switch to `inline` placement: an
embedded form can sit behind a button the same way a modal can, and it's the one
mount-layer setting an inline form still honours.

---

## 3. `PopupDesign`

A pre-defined layout template the merchant picks between.

```ts
type PopupDesign =
  | "basic" // plain centered card, no image
  | "image-behind" // full-bleed background image (imageUrl), content overlaid
  | "image-right" // image (imageUrl) on the right, content on the left
  | "image-left" // image (imageUrl) on the left, content on the right
  | "image-top"; // image (imageUrl) across the top, content beneath
```

The image-based designs read the top-level `imageUrl`, and place it per `imageFit` / `imagePosition`. Only `image-behind` uses `imageScrimOpacity`. The design a form carries also decides its default card width — see [§8](#8-card-sizing--appearance).

`design` is about *where the image sits*; [`formLayout`](#1-top-level-object--popupmodal) is about *how the fields flow*, and the two combine freely.

### `PopupSteps` — one screen at a time

Present and `enabled`, the form is split at its `page-break` items. Absent or off, every item renders on one screen, which is what a form without this field has always done.

```ts
interface PopupSteps {
  enabled: boolean;
  progress?: "bar" | "count" | "dots" | "none"; // the indicator above the fields — omitted = 'bar'
  autoAdvance?: boolean; // a radio choice moves to the next step on its own — omitted = off
  nextLabel?: string; // forward button on every step but the last — omitted = 'Next'
  backLabel?: string; // back button on every step but the first — omitted = 'Back'
}
```

- The breaks live in `contentItems`, not here, so switching the mode off **keeps** them: an author trying it out and changing their mind gets their steps back on the way in.
- `autoAdvance` is radio-only. A `checkbox` left unticked is a real answer, a `multi-select`'s first pick isn't its last, and a `select` fires `change` on arrow-key navigation in several browsers — any of those would advance a visitor before they had chosen.
- The submit button needs no rule: it is sunk past every other item, page breaks included, so it is always on the last step.

---

## 4. `ContentItem`

Each entry in `contentItems`. Shared shape below; per-`type` specifics in [§5](#5-per-type-details).

```ts
interface ContentItem {
  id: string; // unique id for this item
  order: number; // sort order within the modal body
  type: ContentType; // what kind of item this is
  value?: string; // display text (heading / text); the markup for `html`; label for inputs / buttons
  placeholder?: string; // text-ish inputs — placeholder; falls back to `value` when omitted. On `select` it's the empty first row.
  height?: number; // spacer only — vertical gap in px (default 16)
  rows?: number; // textarea only — visible rows (default 4)
  min?: number; // number only — lowest value allowed; either bound may stand alone
  max?: number; // number only — highest value allowed
  span?: number; // columns of the 12-column body grid this item takes (see below)
  styleProps?: StyleProps; // merged into the React component's style
  options?: PopupOption[]; // radio / select / multi-select — the selectable choices (see §5)
  required?: boolean; // inputs only — block submit if empty
  private?: boolean; // inputs only — never rendered; value seeded from the page URL (see below)
  onSubmitRequest?: OnSubmitRequest; // how this item contributes to the request
  fieldKey?: string; // the host field this came from (CustomFieldDef.key); editor-only provenance, ignored by the renderer
}

type ContentType =
  | "heading"
  | "text"
  | "spacer" // a fixed-height vertical gap (see §5)
  | "html" // an author-written HTML block (see §5)
  | "page-break" // where a step form starts its next screen (see §1, `steps`)
  | "email"
  | "tel"
  | "date"
  | "number"
  | "radio"
  | "select"
  | "multi-select"
  | "checkbox"
  | "toggle" // the same yes/no as a checkbox, drawn as a switch
  | "free-text-input"
  | "textarea"
  | "hidden" // sent with the form, never rendered
  | "submit-button"; // the control that fires the submit (see §5)

interface PopupOption {
  label: string; // shown to the shopper
  value: string; // sent in the request if selected
}
```

### `span` — the 12-column body grid

The form body is a **12-column grid** and every item declares how many columns it takes. Two fields at `6` sit side by side; three at `4` make a row of thirds. Items flow onto one line while their spans fit and wrap when they don't, so there is **no "row" in the schema** — `contentItems` stays a flat, ordered list and reordering stays a splice.

- Omitted → the full 12 columns, which is what every item did before spans existed.
- Values are clamped to a whole `1`–`12`, and a line holds at most 4 items.
- Read it through the exported `spanOf(item)`, which falls back to a percentage `styleProps.width` so forms authored against the older width control keep their proportions.

### `private` — a field the visitor never sees

An input marked `private` is not rendered in the live popup, but it still submits. In the builder it appears dimmed, so the author can see and configure it, and the switch is labelled **Hidden**: the key is named `private` from before the [`hidden` content type](#content-types) existed, and the name stays only because forms are saved with it. Nothing user-facing says "private". Layout-only items ignore the flag.

The flag only hides. Filling the field from the URL is not its doing, and never was exclusive to it: **every** field is seeded from the page URL's query string (see below), and a hidden one simply has no other way to be filled.

### Seeding a form from the page URL

When a popup mounts, every field is matched against the **page URL's query string** by its submit key, and a match becomes that field's starting value. So `?email=dana@example.com` opens the form with the email box already filled, and the visitor can still edit or clear it.

- A `hidden` item takes part too: carrying a value in from the link is the point of the type, so a param overrides the value the author fixed. With no param, the authored value stands.
- A `radio` or `select` only accepts a param matching one of its authored `options`, and a `multi-select` keeps only the matching parts. A choice that was never offered is dropped rather than submitted.
- A `number` ignores a param that isn't a number.

### `StyleProps`

Applied to the rendered item's `style`. Each key has a default when omitted.

```ts
interface StyleProps {
  align?: "left" | "center" | "right"; // default: 'center'  (maps to textAlign)
  color?: string; // default: inherit / theme text color
  backgroundColor?: string; // default: transparent / theme background
  fontSize?: number; // px. default: the renderer's per-type size
  placeholderFontSize?: number; // px, the hint text inside a field. default: follows `fontSize`
  width?: number; // percent of the form column — superseded by `span`; still what the
  //                 canvas writes when a submit button is dragged narrower
}
```

---

## 5. Per-`type` details

Which fields are meaningful per type:

| `type`            | `value`           | `options`   | `onSubmitRequest` | `required` | Renders as        |
| ----------------- | ----------------- | ----------- | ----------------- | ---------- | ----------------- |
| `heading`         | ✅ text shown     | —           | —                 | —          | a heading         |
| `text`            | ✅ text shown     | —           | —                 | —          | a paragraph       |
| `spacer`          | —                 | —           | —                 | —          | a vertical gap    |
| `html`            | ✅ the markup     | —           | —                 | —          | the markup itself |
| `page-break`      | —                 | —           | —                 | —          | nothing — it *is* the break |
| `email`           | field label       | —           | ✅                | optional   | an email input    |
| `tel`             | field label       | —           | ✅                | optional   | a phone input     |
| `date`            | field label       | —           | ✅                | optional   | a date picker     |
| `number`          | field label       | —           | ✅                | optional   | a numeric input   |
| `radio`           | group label       | ✅ required | ✅                | optional   | a radio group     |
| `select`          | field label       | ✅ required | ✅                | optional   | a dropdown        |
| `multi-select`    | group label       | ✅ required | ✅                | optional   | a checkbox list   |
| `checkbox`        | label             | —           | ✅                | optional   | a checkbox        |
| `toggle`          | label             | —           | ✅                | optional   | a switch          |
| `free-text-input` | field label       | —           | ✅                | optional   | a text input      |
| `textarea`        | field label       | —           | ✅                | optional   | a multi-line box  |
| `hidden`          | ✅ the fixed value | —          | ✅                | —          | nothing           |
| `submit-button`   | ✅ button label   | —           | —                 | —          | the submit button |

- **`email` / `tel` / `number` / `free-text-input` / `textarea`** have a separate `placeholder`. `value` is the visible field label; `placeholder` is the greyed-out hint inside the input, edited independently in the builder. When `placeholder` is omitted it falls back to `value`. A `date` has no placeholder — the browser paints its own format hint.
- **`spacer`** ignores `value`; it renders an empty block whose height is `height` px (default `16`). Use it to add breathing room between items.
- **`page-break`** carries nothing and renders nothing: it marks *where* the next screen of a step form starts. A step is the run of items between two breaks, which is why there is no "step" object in the schema — `contentItems` stays one flat, ordered list and moving a field between steps is an ordinary reorder. Breaks are inert unless `steps.enabled` is on, so switching the mode off keeps them for when it's switched back on. See `steps` in [§1](#1-top-level-object--popupmodal).
- **`html`** renders `value` as markup. It is sanitized first, and the result is what both the renderer and the builder canvas show: layout, text, table, `<img>`, `<a>` and `<iframe>` tags pass through with their `class` / `id` / `style` / `aria-*` / `data-*` attributes; `<script>`, `<style>`, `<link>`, `<base>` and the form controls (`<form>`, `<input>`, `<button>`, …) are dropped, along with every `on*` handler and any `href` / `src` that isn't `http(s)`, `mailto:`, `tel:`, a relative URL, or (on `src`) a `data:image/*`. Style the block with inline `style` attributes: a `<style>` block would reach the whole page the form sits on, so it does not survive.
- **`textarea`** takes `rows` (default `4`) for its visible height. Visitors can drag it taller, never wider.
- **Option types (`radio` / `select` / `multi-select`)** carry `options: { label, value }[]`. The label is rendered; the selected option's `value` is what gets submitted. Each `value` must be **URL-safe** (see §6). On a `select`, `placeholder` is the text of the empty first row (default "Choose one"), which is also how an optional select stays unanswered.
- **`number`** renders a native `<input type="number">`, so the browser restricts what can be typed and offers its own steppers. The value is submitted as the string the input held, the same as a `date` is: an untouched field stays empty rather than sending a `0` nobody typed.
  - `min` / `max` bound it, and **either may stand alone** — a `min` of `1` with no `max` is "at least one". They reach the input as its `min`/`max` attributes *and* are re-checked on submit, because a visitor can type a value the steppers would never reach, and a `private` number is never rendered at all (its value is seeded from the page URL, where nothing has been checked). A value outside the range blocks the submit with a message naming the bounds.
  - No `step`: the field submits whatever was typed, decimals included, so bounding a price to `0`–`99.99` works without anyone reasoning about step arithmetic.
  - A `min` above its `max` is a **validation error** (`validatePopup`) — the field could never accept anything, and unlike an out-of-grid `span` there is no sensible reading to clamp it to.
- **`toggle`** is a `checkbox` by another drawing: the same boolean value, rendered as a switch with the label leading and the control at the trailing edge. Use it for a setting ("Keep me posted"), and a `checkbox` for an agreement ("I accept the terms").
- **`tel`** is validated leniently: digits plus `+ ( ) - . space`, at least six digits. Phone formats vary too much by country for anything stricter, and the value is submitted exactly as typed.
- **`hidden`** is submitted but never rendered: `value` is the fixed value the author set, sent under `onSubmitRequest.key`. Use it for a source/campaign tag the visitor has no say in. (For a value that varies per visit, use a `private` input instead — same invisibility, but seeded from the page URL.)
- **`submit-button`** is what actually triggers the request assembly + `fetch`. Without it there's no way to submit, which `validatePopup` reports as a warning.

---

## 6. How a submit is assembled

Each input item declares, via `onSubmitRequest`, the **key** it contributes to the request under. The **value is always resolved at submit time** from the item's `type` — that's why there's no static value here.

```ts
interface OnSubmitRequest {
  key?: string; // the query param / body key. Defaults to 'email' for the email item; required otherwise.
}
```

**Where** the value lands is *not* configured per item: it follows the HTTP method of the target being called — `GET` → query string, `POST` → JSON body. Only the key is authored. That's what lets the same item ride in the body of a POST target and the query string of a GET one when a form fires several ([extra targets](#extra-submit-targets) below).

> **Key format.** Every submit key — `onSubmitRequest.key`, option `value`s, and
> `onSubmitCallbackPayload` keys — is used verbatim as a URL query/body key, so it must be
> **URL-safe**: no spaces, only RFC 3986 unreserved characters (letters, digits, and `-` `.` `_` `~`).
> The builder flags violations inline and as validation errors.

**Where the value comes from (by item `type`):**

| `type`            | submitted value                            |
| ----------------- | ------------------------------------------ |
| `email`           | the email the shopper typed                |
| `tel`             | the phone number as typed                  |
| `date`            | the picked date as `YYYY-MM-DD`            |
| `number`          | the digits the shopper typed, as a string  |
| `free-text-input` | the text the shopper typed                 |
| `textarea`        | the text the shopper typed, newlines kept  |
| `checkbox`        | the boolean checked state (`true`/`false`) |
| `toggle`          | the boolean on/off state (`true`/`false`)  |
| `radio` / `select`| the selected option's `value`              |
| `multi-select`    | every selected `value`: a JSON array in a POST body, comma-joined in a query string |

**Assembly at submit time:**

- Campaign params found on the page URL go in first, under their own names. See [Forwarded tracking params](#forwarded-tracking-params) below.
- Every input's resolved value, plus every `hidden` item's value, is collected under its `onSubmitRequest.key`, overwriting a tracking param of the same name.
- `onSubmitCallbackPayload` entries are merged in as static `{ key: value }` pairs alongside them, overwriting either. These are fixed values the merchant sets in the builder, not tied to any input.
- The whole set is then placed per the target's method: **`POST`** → a JSON body, **`GET`** → the URL's query string.
- Each [extra target](#extra-submit-targets) repeats that with its own method and its own static `payload`.

#### Forwarded tracking params

A submit carries campaign attribution on its own, with no field authored for it. **Your endpoint should expect these keys to arrive even though nothing in the form JSON mentions them**, on every target the form fires to:

`utm_source` · `utm_medium` · `utm_campaign` · `utm_term` · `utm_content` · `gclid` · `fbclid` · `msclkid` · `ttclid` · `ref`

- Only these keys travel. The rest of the page's query string is left alone, because a host's URLs also carry session tokens, order ids and sometimes an email address.
- The value is read off the page URL at submit time and not remembered between pages. A form opened on a page the campaign link didn't land on has nothing to forward.
- An empty param (`?utm_source=`) sends nothing.
- Anything authored under the same key wins: a form with its own `ref` field, or a target whose static `payload` pins `ref`, submits exactly what it submitted before.

### Example

```jsonc
{
  "id": "welcome-15",
  "name": "Welcome 15% off",
  "url": "https://shop.example.com/api/subscribe",
  "method": "POST",
  "trigger": { "type": "delay", "seconds": 5 },
  "design": "image-right",
  "imageUrl": "https://cdn.example.com/promo.jpg",
  "dismissible": true,
  "frequency": "session",
  "contentItems": [
    {
      "id": "h1",
      "order": 0,
      "type": "heading",
      "value": "Get 15% off your first order",
      "styleProps": { "align": "center", "color": "#111827" },
    },
    {
      "id": "e1",
      "order": 1,
      "type": "email",
      "value": "Your email",
      "required": true,
      "onSubmitRequest": { "key": "email" },
    },
    {
      "id": "c1",
      "order": 2,
      "type": "checkbox",
      "value": "Email me deals",
      "onSubmitRequest": { "key": "marketingOptIn" },
    },
    {
      "id": "btn",
      "order": 3,
      "type": "submit-button",
      "value": "Claim my discount",
    },
  ],
}
```

Resulting `POST https://shop.example.com/api/subscribe` body:

```json
{ "email": "shopper@example.com", "marketingOptIn": true }
```

### After submit — `onSuccess` / `onError`

Once the merchant request resolves, the modal reacts per these top-level fields. Both are discriminated unions on `type`.

```ts
type SubmitSuccess =
  | { type: "close" } // just close the modal (default when onSuccess omitted)
  | { type: "rich"; html: string } // an author-composed success screen — what the builder writes
  | {
      type: "redirect"; // navigate the shopper somewhere
      url: string;
      newTab?: boolean;
      forwardValues?: boolean; // append the submitted values to the URL as query params
      thankYouPage?: { id: string; label?: string }; // set when `url` is a page the host created
    }
  // --- legacy variants: still rendered, no longer authored ---
  | {
      type: "message"; // swap the body for a success message
      text: string;
      autoCloseMs?: number;
    } // optional auto-close after N ms (else stays until dismissed)
  | {
      type: "coupon"; // reveal a discount code to copy
      text?: string; // e.g. "Here's your 15% off code:"
      code?: string; // static code, OR…
      codeFromResponsePath?: string; // …pull a per-shopper code from the merchant JSON response, e.g. "data.coupon"
      copyable?: boolean;
    }; // show a copy button — default true

type SubmitError =
  | { type: "rich"; html: string } // author-composed error copy, rendered under the form
  | { type: "message"; text: string }; // plain text; also used for the renderer's own validation errors
```

**The `rich` variant** is what the builder authors for both fields, and it supersedes `message` and `coupon` (both of which are now composed *inside* the rich editor). `html` is a fragment; inline coupons are serialized as placeholder elements the renderer hydrates into live chips:

```html
<p>You're in! Use <span data-coupon data-code="WELCOME15" data-path="data.discountCode" data-copyable="true"></span> at checkout.</p>
```

| Attribute | Meaning |
| --- | --- |
| `data-coupon` | marks the placeholder — any tag carrying it becomes a chip |
| `data-code` | the static fallback code |
| `data-path` | dot-path into the parsed JSON response of the primary target, e.g. `data.discountCode`. Wins over `data-code` when it resolves to something non-empty |
| `data-copyable` | `"false"` drops the copy button; absent or anything else keeps it |

The fragment is **never injected raw**: the renderer parses it and walks it through a tag/attribute whitelist (paragraphs, headings, lists, `blockquote`, `code`, links, inline emphasis; only `text-align` survives from inline styles). An unknown tag loses the tag and keeps its text. A chip whose code resolves to nothing renders nothing, leaving the sentence intact.

Notes:

- **Default success** (no `onSuccess`) = `{ type: 'close' }`. **Default error** = an inline generic message; the form stays open so the shopper can retry.
- **`redirect.forwardValues`** appends the submitted values to the destination URL, keyed by each field's submit key, so a thank-you page can greet the visitor by name. It puts those values in the URL, so it's the wrong switch for anything sensitive. Redirects are authored under the builder's **Automations** tab, not the Submission section.
- **`redirect.thankYouPage`** is provenance, not behaviour: the renderer navigates to `url` and never reads it. It's written when the author presses "create a thank-you page" in the builder and the embedding host creates one (the page lives in *their* app — this package hosts nothing), and it's what lets the editor still name the attached page after a reload. Editing the URL by hand clears it.
- **What a redirect looks like while it happens.** The navigation starts the moment the submit resolves, but the browser keeps painting the current page until the destination answers, so the popup is on screen for that whole wait. It shows a spinner and one line ("Taking you there…" / "מעבירים אתכם…"), deliberately without a countdown: the wait is the destination page loading over the visitor's connection, which nothing here can predict, and a promised "3 seconds" that takes five reads as a broken page. With `newTab` there is no wait in this tab, so it says so instead of spinning.
- **`coupon.codeFromResponsePath`** covers the older "merchant mints a unique code and returns it" case — a dot-path into the parsed JSON response, the same resolution `data-path` now does. If both `code` and `codeFromResponsePath` are set, the response path wins; if the path resolves to nothing, fall back to `code`.
- **Migration.** A form carrying `message` or `coupon` keeps rendering; the builder rewrites it into `rich` on the first edit of that setting.
- "Success" vs "error" is decided by HTTP status (2xx = success). Whether we also inspect the response body for a merchant-signalled failure is an open question ([§10](#10-open-questions)).

Example success block for the welcome-15 popup above:

```jsonc
{
  "onSuccess": {
    "type": "rich",
    "html": "<h2>You're in!</h2><p>Use <span data-coupon data-path=\"data.discountCode\" data-copyable=\"true\"></span> at checkout.</p>",
  },
  "onError": {
    "type": "message",
    "text": "Something went wrong, please try again.",
  },
}
```

### Extra submit targets

Beyond the primary `url` + `method`, a form may carry hidden endpoints in `submitTargets`. These are typically appended by the embedding host (mailing-list automations, webhooks) rather than authored in the builder:

```ts
interface SubmitTarget {
  id: string; // stable, so a host can find and update its own target on re-publish
  url: string;
  method: "GET" | "POST";
  payload?: { key: string; value: string }[]; // static pairs merged into this target only
  hidden?: boolean; // dev-owned; not shown or editable in the builder UI
  fireFromClient?: boolean; // default true; false = a declaration the renderer skips
  label?: string;
}
```

- The **primary** target is authoritative: its status decides success vs error, and its response is what `data-path` / `codeFromResponsePath` read.
- Extra targets fire **in parallel and best-effort** — a failure is swallowed, so a flaky automation never costs the merchant the lead the primary captured.
- `fireFromClient: false` makes a target a **declaration only**: it rides in the JSON for the host's backend to act on when the submission arrives, and the visitor's submit stays a single request. Mailing-list automations compile into targets with a `mailinglist:` id prefix; see [`docs/EMBEDDING.md`](./docs/EMBEDDING.md#mailing-list-automations).

### Email automations

```ts
interface EmailAutomation {
  id: string; // stable, so rows keep identity across edits
  to: string; // recipient(s), free text — the host decides how to parse and validate
  subject?: string;
}
```

A **declaration of intent, not an action**. Sending mail is server-side and this package is frontend-only, so the builder writes the author's choice here and neither it nor the renderer sends anything. **The embedding host must read `emailAutomations` off the form JSON at submit time and send the mail itself**; a host that ignores the array silently drops every email the author configured. Validation flags a blank `to` as a warning and stops there — everything else is host policy.

---

## 7. `htmlId` — merchant-controlled placement

`htmlId` is first a **page gate**, for both placements:

- `htmlId` absent → renders on every page the script loads on.
- `htmlId` set and matched → renders.
- `htmlId` set and not matched → renders nothing (no `<body>` fallback).

`placement` then decides where:

- `placement: "inline"` (default) → the form is appended **inside** the matched
  element, in the page flow, at that container's full width, with no overlay.
  The card width, backdrop, trigger timing, frequency cap and dismiss controls
  don't apply, and the builder hides them. The exception is the `click` trigger,
  which means the same thing for both placements — the form waits behind a
  button — so it survives the switch and stays offered. With no `htmlId` there's
  nothing to embed into, so it lands at the end of `<body>`.
- `placement: "modal"` → full-page overlay; the element's position is irrelevant.

### `urls` — which pages it runs on

A second, coarser gate, checked **before** `htmlId` and before anything about
placement: whether this page is one the form belongs on at all.

```jsonc
{ "urls": ["https://shop.co.il/pricing", "https://shop.co.il/products/*"] }
```

- **Empty or absent → every page.** Every form authored before this field existed
  says exactly that, and a form embedded by pasting a tag where it belongs wants
  it too — the paste already answered "where".
- Each entry is a page address, written the way it's copied out of a browser.
- **Ignored when comparing:** the scheme, a leading `www.`, a trailing slash, the
  `#fragment`, and case. None of them make a different page, and treating them as
  differences is how a form silently fails to appear for half a site's visitors.
- A **star** matches any run of characters: `…/products/*` is every product page,
  `https://*.shop.co.il/pricing` is that page on every subdomain.
- A **bare path** (`/pricing`) is matched against the path alone, ignoring the
  host — which keeps a form working on a staging domain as well as the live one.
- An entry with **no query string** ignores the page's, so a pasted address still
  covers `?ref=…` arrivals. Write the query in when it distinguishes the page.
- A **subdomain is a real difference** and is not ignored. Use a star for all of
  them.

Evaluate it with `matchesPage(popup, href)` rather than comparing strings. It's
deliberately plain data over a pure function, so a host that stores forms
server-side can answer "which forms go on this page" with the same rules without
running the renderer.

---

## 8. Card sizing & appearance

`width` + `widthUnit` cap the card. Each template carries the width it's designed around, and picking one in the builder writes that width onto the form:

| Template | Width |
| --- | --- |
| `basic`, `image-behind`, `image-top` | 500px |
| `image-left`, `image-right` | 720px |
| any design with `formLayout: "row"` | 820px |

`defaultCardWidth(design, formLayout)` is exported for hosts that need the same numbers. Leave `width` unset and the stylesheet's own defaults apply (520px modal, 500px inline). With `widthUnit: "%"`, `width` is read against the container the form sits in.

The rest of the appearance fields layer over the card in the obvious way, with two worth calling out:

- **`backgroundOpacity` vs `cardOpacity`.** The first is alpha on the *fill* only, so the card's text stays fully opaque over a translucent background. The second fades the whole card, content included.
- **Modal-only fields.** `backdropOpacity` describes the overlay behind the card, so it does nothing for an `inline` form. Same for `dismissible`, `trigger` timing and `frequency`: switching a form to `inline` normalizes all of them, and the builder hides their controls. A `click` trigger is kept through that switch — see [§7](#7-htmlid--merchant-controlled-placement).

Per-item sizing is separate: see [`span`](#span--the-12-column-body-grid) in §4.

---

## 9. Proposals folded in (all accepted)

These weren't in the original list — they were added where the schema couldn't function without them or would nag users. All five shipped:

| #   | Addition                       | Why                                                                                         |
| --- | ------------------------------ | ------------------------------------------------------------------------------------------- |
| P1  | `submit-button` content type   | Nothing else triggers the request; the form can't submit without it.                        |
| P2  | `dismissible?: boolean`        | Whether the shopper can close it (X / overlay / esc).                                       |
| P3  | `frequency?: PopupFrequency`   | Popups that re-open on every page view are a common complaint; `localStorage` cap fixes it. |
| P4  | `required?: boolean` on inputs | Validate before firing the merchant request.                                                |
| P5  | `PopupOption[]` for option types | `radio` / `select` / `multi-select` can't render or submit without their choices.          |

```ts
type PopupFrequency = "session" | "day" | "ever" | "always";
```

---

## 10. Open questions

1. **Multiple inputs of the same type** — e.g. two free-text inputs; `key` uniqueness is on the merchant/builder to enforce. Any validation needed?

**Settled since this list was written:**

- **Success detection** — the primary target's HTTP status decides, on its own: a non-2xx response fires `onError`, anything else `onSuccess`. The response body is parsed but only read for coupon extraction (`data-path` / `codeFromResponsePath`), never to override the status.
- **`GET` requests** — values go on the query string rather than being disallowed. Placement is no longer authored per item at all: it follows each target's own method ([§6](#6-how-a-submit-is-assembled)).
- **Localization** — a form carries one language, chosen by its `language` (falling back to `direction` on older forms) and the copy the author types. Item copy and success content are stored as authored (plain strings, or the `rich` HTML fragment).

---

## 11. License

This schema and the package that implements it are **proprietary and confidential** — copyright © 2026 Creaditor, all rights reserved. Availability on a public registry or CDN grants no right to use, copy, modify, redistribute, or create derivative works; those come only from a separate written agreement signed by Creaditor. Full terms in the `LICENSE` file that ships with the package. Provided "as is", without warranty of any kind.
