# Lead Form Builder — Implementation Plan

**Spec:** docs/specs/2026-05-31-lead-form-builder.md  ·  **Slug:** lead-form-builder  ·  **Wave:** 8
**Depends on:** foundation-auth-rbac, marketing-leads-pipeline

## Goal
Add the visual lead-form builder UI at `/marketing/forms` (list, detail, drag-drop builder, field editor, live preview, submissions, settings, embed-code tabs) on top of the already-built `marketing-leads-pipeline` data layer. This spec is an **extend** layer, not a rebuild: the upstream plan already ships `lead_forms`/`lead_form_submissions` tables, the `lead-forms.ts` repo (`createForm`/`updateForm`), and the full `/api/lead-forms*` CRUD + public `POST /api/forms/:slug` handler. We add a small schema delta (5 columns), a `hidden` field type + "map to lead" field mapping, hCaptcha verification gated on `captcha_enabled`, and the versioned `embed.v1.js` script with build-time SRI hash served from `zync-www`.

## Architecture
- **Schema delta** (Task 1): `ALTER TABLE lead_forms` adds `submit_button_label`, `success_action` (CHECK enum), `success_message`, `success_redirect_url`, `captcha_enabled`. We **supersede** upstream's `redirect_url` with the explicit `success_action`/`success_message`/`success_redirect_url` triad — the public handler reads `success_action` to decide inline-message vs redirect, and a back-fill migrates existing `redirect_url` values. `notify_email` already exists upstream → NOT re-added.
- **Type extension** (Task 2): extend upstream `FieldConfig` (in `packages/types/src/marketing.ts`) with the `'hidden'` type and optional `mapTo?: 'name'|'email'|'phone'|'company'|'notes'`. Consumes upstream `FieldConfig`, `LeadForm` types.
- **Repo + API extension** (Task 3): `lead-forms.ts` repo + `apps/zync-api/src/routes/marketing/lead-forms.ts` already exist upstream; we extend `createForm`/`updateForm` payloads to accept the new columns, and the form serializer to return them and the embed metadata (`slug`, current SRI hash).
- **Public submission handler extension** (Task 4): the upstream `POST /api/forms/:slug` handler is extended to (a) route `mapTo`-flagged fields to `leads` columns vs the `payload` JSONB, (b) verify hCaptcha when `captcha_enabled`, (c) honor `success_action`, (d) derive duplicate-status by email lookup so the submissions tab can show `✓ Lead created` vs `✗ Duplicate`.
- **Embed script + SRI** (Task 5): `embed.v1.js` served from `zync-www` (Astro static asset at `zync.is/embed.v1.js`); a build-time script computes its `sha384` SRI hash and writes it to a generated module the API reads, so `GET /api/lead-forms/:id` returns the current hash alongside the embed snippet.
- **Builder UI** (Tasks 6–11) in `apps/zync-app` (Vite+React): forms list, form detail shell with tabs, drag-drop builder + live preview, field editor, submissions tab, settings + embed-code tabs. Drag-drop reorders the `fields` JSONB array by index (no fractional indexing — array order is display order). All create/edit gated `requireTier('business')` server-side and `useTierGate('business')` client-side; viewing submissions is all-tiers.
- Consumes upstream tables: `lead_forms`, `lead_form_submissions`, `leads`. Consumes upstream exports: `FieldConfig`, `createForm`, `updateForm`, `RATE_LIMITER_LEAD_FORM`, `requirePermission`, `requireTier`, `meetsMinimumTier`, `useTierGate`, `authMiddleware`, `requireModuleEnabled`. Consumes design-system: `DataTable`, `Sheet`, `Dialog`, `Tabs`, `Button`, `Input`, `Textarea`, `Select`, `Checkbox`, `Switch`, `Card`, `EmptyState`, `toast`.

## Tech Stack
- **DB:** Neon Postgres via Hyperdrive, Drizzle ORM. Migration in `packages/db/migrations/`.
- **Types:** `packages/types` (extend `marketing.ts`).
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — extend `routes/marketing/lead-forms.ts` and `routes/public/forms.ts`. Bindings: `RATE_LIMITER_LEAD_FORM` (existing), hCaptcha verify via `fetch` (secret `HCAPTCHA_SECRET`).
- **Public site / embed:** `zync-www` (Astro) — static `embed.v1.js`, build-time SRI generation.
- **App UI:** `apps/zync-app` (Vite + React, TanStack Query, `@dnd-kit/core` + `@dnd-kit/sortable` for drag-reorder), `@zync/ui` components.
- **Turborepo + pnpm.**

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A. Schema & types | 1, 2 | `packages/db/migrations/*`, `packages/db/src/schema/marketing.ts`, `packages/types/src/marketing.ts` | 1 and 2 parallel |
| B. API extension | 3, 4, 5 | `apps/zync-api/src/routes/marketing/lead-forms.ts`, `apps/zync-api/src/routes/public/forms.ts`, `packages/db/src/repos/lead-forms.ts`, `apps/zync-www/*` | After A; 3/4 parallel, 5 parallel |
| C. Builder UI | 6, 7, 8, 9, 10, 11 | `apps/zync-app/src/features/marketing/forms/*` | After B; 6 first, then 7–11 parallel |

## Tasks

### Task 1: Schema delta — extend `lead_forms`
**Blocks:** 3, 4  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/<ts>_lead_form_builder_delta.sql`
- Modify: `packages/db/src/schema/marketing.ts` (add columns to the existing `leadForms` Drizzle table)
**Steps:**
- [ ] Write the ALTER migration adding the 5 net-new columns. Do **not** add `notify_email` — it already exists in the upstream `lead_forms` DDL; re-adding it errors the migration.
- [ ] Back-fill `success_action`/`success_redirect_url` from the existing `redirect_url`: where `redirect_url IS NOT NULL`, set `success_action='redirect'`, `success_redirect_url=redirect_url`; otherwise leave defaults (`success_action='message'`).
- [ ] Mark `redirect_url` as superseded (kept for back-compat, no longer the source of truth). The public handler now reads `success_action`.
- [ ] Add the matching Drizzle column definitions to the `leadForms` table object in `marketing.ts`.
**Schema / Interfaces:**
```sql
ALTER TABLE lead_forms
  ADD COLUMN submit_button_label TEXT NOT NULL DEFAULT 'Submit',
  ADD COLUMN success_action TEXT NOT NULL DEFAULT 'message'
    CHECK (success_action IN ('message', 'redirect')),
  ADD COLUMN success_message TEXT DEFAULT 'Thank you! We''ll be in touch.',
  ADD COLUMN success_redirect_url TEXT,
  ADD COLUMN captcha_enabled BOOLEAN NOT NULL DEFAULT true;

-- Back-fill from upstream redirect_url (superseded going forward)
UPDATE lead_forms
  SET success_action = 'redirect',
      success_redirect_url = redirect_url
  WHERE redirect_url IS NOT NULL;
```
Drizzle (append to existing `leadForms` in `packages/db/src/schema/marketing.ts`):
```ts
submitButtonLabel: text('submit_button_label').notNull().default('Submit'),
successAction: text('success_action').notNull().default('message'), // CHECK in migration: 'message' | 'redirect'
successMessage: text('success_message').default("Thank you! We'll be in touch."),
successRedirectUrl: text('success_redirect_url'),
captchaEnabled: boolean('captcha_enabled').notNull().default(true),
```
**Acceptance:**
- [ ] Migration applies cleanly on a DB that already has the upstream `lead_forms` (no duplicate-column error on `notify_email`).
- [ ] A pre-existing form with `redirect_url='https://x.com'` reads back `success_action='redirect'`, `success_redirect_url='https://x.com'`.
- [ ] `success_action` rejects any value other than `'message'`/`'redirect'`.

### Task 2: Extend `FieldConfig` type — `hidden` type + `mapTo`
**Blocks:** 4, 8, 9  ·  **Blocked by:** —
**Files:**
- Modify: `packages/types/src/marketing.ts` (extend the existing `FieldConfig` type)
**Steps:**
- [ ] Add `'hidden'` to the `FieldConfig['type']` union.
- [ ] Add optional `mapTo?: LeadFieldMapTarget` and `defaultValue?: string` (used by hidden fields to submit a fixed value, e.g. source campaign id).
- [ ] Export `LeadFieldMapTarget` so the API handler and builder UI share it.
- [ ] Re-export from `packages/types/src/index.ts` (already re-exports `marketing.ts`; verify `LeadFieldMapTarget` is included).
**Schema / Interfaces:**
```ts
export type LeadFieldMapTarget = 'name' | 'email' | 'phone' | 'company' | 'notes';

// extends upstream FieldConfig (do not redefine the existing keys)
export type FieldConfig = {
  id: string;                 // stable UUID, key in submission payload
  type: 'text' | 'email' | 'phone' | 'textarea' | 'select' | 'checkbox' | 'hidden';
  label: string;
  placeholder?: string;
  required: boolean;
  options?: string[];         // type='select' only
  mapTo?: LeadFieldMapTarget; // routes value to leads column; unmapped → payload JSONB
  defaultValue?: string;      // type='hidden' fixed value
};
```
**Acceptance:**
- [ ] `type: 'hidden'` and `mapTo: 'company'` typecheck against `FieldConfig`.
- [ ] `LeadFieldMapTarget` importable from `@zync/types`.

### Task 3: Extend lead-forms repo + authed API for new columns and embed metadata
**Blocks:** 6, 7, 10  ·  **Blocked by:** 1
**Files:**
- Modify: `packages/db/src/repos/lead-forms.ts` (extend `createForm`/`updateForm`/serializer)
- Modify: `apps/zync-api/src/routes/marketing/lead-forms.ts` (extend zod schemas + response shape)
**Steps:**
- [ ] Extend the `createForm`/`updateForm` input objects to accept `submitButtonLabel`, `successAction`, `successMessage`, `successRedirectUrl`, `captchaEnabled`, and the extended `fields` (with `mapTo`/`hidden`).
- [ ] Extend the form serializer so `GET /api/lead-forms/:id` returns the new columns plus an `embed` object: `{ slug, scriptHash }` where `scriptHash` is the current `embed.v1.js` SRI hash (from Task 5's generated module) and `formUrl = https://zync.is/f/{slug}` derived from `slug`.
- [ ] Keep all routes mounted under `authMiddleware` + `requireModuleEnabled('marketing')` (already upstream).
- [ ] Keep create/update/delete behind `requirePermission('marketing:write')` + `requireTier('business')`; keep `GET .../submissions` and `GET /api/lead-forms` behind `requirePermission('marketing:read')` only (viewing is all-tiers).
- [ ] Update the zod body schemas to validate `successAction IN ('message','redirect')`, require `successRedirectUrl` when `successAction='redirect'`, and validate each `fields[]` item incl. `mapTo` enum and `options` required for `type='select'`.
- [ ] Use the existing `slug` immutability rule (reject slug change when `lead_form_submissions` count > 0) — already upstream; do not invent a `public_token` column (it does not exist; the public identifier is `slug`).
**Schema / Interfaces:**
```ts
// response addition for GET /api/lead-forms/:id
interface LeadFormDetailResponse {
  // ...existing upstream fields...
  submitButtonLabel: string;
  successAction: 'message' | 'redirect';
  successMessage: string | null;
  successRedirectUrl: string | null;
  captchaEnabled: boolean;
  embed: { slug: string; formUrl: string; scriptHash: string };
}
```
**Acceptance:**
- [ ] `PATCH /api/lead-forms/:id` with `successAction:'redirect'` and no `successRedirectUrl` returns 400.
- [ ] `POST /api/lead-forms` from a Freelancer-tier tenant returns 402/upgrade (tier gate).
- [ ] `GET /api/lead-forms/:id` returns `embed.scriptHash` matching the value generated in Task 5.

### Task 4: Extend public submission handler — field mapping, hCaptcha, success_action, duplicate status
**Blocks:** 10  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `apps/zync-api/src/routes/public/forms.ts` (the existing `POST /api/forms/:slug` handler)
- Modify: `apps/zync-api/wrangler.toml` (declare `HCAPTCHA_SECRET` secret reference; do NOT commit the secret value)
**Steps:**
- [ ] Field routing: when building the `leads` record and the submission `payload`, iterate `lead_forms.fields`; for each field with a `mapTo` target, write the value to the corresponding `leads` column (`name`/`email`/`phone`/`company`/`notes`); all other fields go into the `payload` JSONB. Hidden fields submit their `defaultValue`/posted value the same way.
- [ ] hCaptcha: when `lead_forms.captcha_enabled = true`, read `h-captcha-response` (or `g-recaptcha-response` token field posted by hCaptcha) from the body, POST to `https://api.hcaptcha.com/siteverify` with `secret=HCAPTCHA_SECRET` + `response=<token>` + `remoteip=<ip>`; reject with 400 if `success !== true`. Skip when `captcha_enabled = false`.
- [ ] Success action: after creating the lead/submission, honor `success_action` — return `{ ok: true, redirectUrl: success_redirect_url }` when `'redirect'`, else `{ ok: true, message: success_message }`.
- [ ] Duplicate status for submissions tab: before creating the lead, look up an existing non-archived `leads` row in the tenant with the same `email` (case-insensitive). If found, still create the `lead_form_submission` but flag `payload._dedupStatus = 'duplicate'` (or set `lead_id` to the existing lead) so the submissions list can render `✗ Duplicate`; if none, status is `lead_created`. Record the resolved status on the submission row's `payload` metadata so the submissions API can derive it without recomputation.
- [ ] Preserve all existing upstream steps: `RATE_LIMITER_LEAD_FORM` (20/min per form+IP), UTM extraction, `Referer` capture, `lead_activities` (type='form_submitted'), `lead.created` outbound webhook, `lead_captured` AE event, notify-email via Resend, 404 when `is_active=false`.
**Schema / Interfaces:**
```ts
// hCaptcha verify (Cloudflare Workers fetch)
async function verifyHcaptcha(token: string, ip: string, secret: string): Promise<boolean> {
  const body = new URLSearchParams({ secret, response: token, remoteip: ip });
  const r = await fetch('https://api.hcaptcha.com/siteverify', { method: 'POST', body });
  const j = await r.json<{ success: boolean }>();
  return j.success === true;
}

// success response union
type FormSubmitResponse =
  | { ok: true; redirectUrl: string }
  | { ok: true; message: string }
  | { ok: false; errors: Record<string, string> };
```
**Acceptance:**
- [ ] A submission with a field `{ mapTo: 'company' }` writes that value to `leads.company`, not only to `payload`.
- [ ] With `captcha_enabled=true` and a missing/invalid hCaptcha token, the handler returns 400 and creates no lead.
- [ ] With `success_action='redirect'`, response is `{ ok: true, redirectUrl }`.
- [ ] Submitting an email that already exists as a lead produces a submission whose derived status is `duplicate`.

### Task 5: `embed.v1.js` script + build-time SRI hash
**Blocks:** 3, 11  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-www/public/embed.v1.js`
- Create: `apps/zync-www/scripts/compute-embed-sri.mjs`
- Create: `packages/config/src/embed-sri.generated.ts` (written by the script; API reads it in Task 3)
- Modify: `apps/zync-www/package.json` (run the SRI script in `prebuild`)
- Modify: `packages/config/src/index.ts` (re-export `EMBED_V1_SRI`)
**Steps:**
- [ ] Author `embed.v1.js`: reads `data-form="{slug}"` from its own `<script>` tag, injects an `<iframe src="https://zync.is/f/{slug}">`, and listens for `postMessage` height events from the iframe to auto-resize. No external deps; self-contained; no eval.
- [ ] Write `compute-embed-sri.mjs`: read `public/embed.v1.js`, compute `sha384` base64 digest, write `packages/config/src/embed-sri.generated.ts` exporting `export const EMBED_V1_SRI = 'sha384-<hash>' as const;`.
- [ ] Wire the script into `zync-www` `prebuild` so the hash regenerates on every deploy of the script.
- [ ] Re-export `EMBED_V1_SRI` from `@zync/config` so the API (Task 3) returns it as `embed.scriptHash`.
- [ ] Ensure CSP on the public form page allows the iframe and `postMessage` resize (frame-ancestors handled by the page; the script is consumed on third-party sites with SRI so no zync-www CSP change is needed for the script itself).
**Schema / Interfaces:**
```ts
// packages/config/src/embed-sri.generated.ts (generated; example value)
export const EMBED_V1_SRI = 'sha384-<base64digest>' as const;
```
**Acceptance:**
- [ ] `GET https://zync.is/embed.v1.js` serves the static script.
- [ ] `EMBED_V1_SRI` value equals the `sha384` of the served file, and matches the `integrity` shown in the builder embed snippet.
- [ ] Embedding the `<script ... integrity="{EMBED_V1_SRI}" crossorigin="anonymous">` snippet on a third-party page renders the form iframe and auto-resizes.

### Task 6: Forms list page (`/marketing/forms`)
**Blocks:** 7  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/features/marketing/forms/FormsListPage.tsx`
- Create: `apps/zync-app/src/features/marketing/forms/useForms.ts` (TanStack Query hooks)
- Modify: `apps/zync-app/src/routes.tsx` (register `/marketing/forms`, `/marketing/forms/:id`, `/marketing/forms/:id/builder`)
**Steps:**
- [ ] Build the list with `DataTable`: columns Name, Status, Submissions (count), Created. Use `GET /api/lead-forms`.
- [ ] Inline Active/Inactive `Switch` per row → `PATCH /api/lead-forms/:id { is_active }`; show toast on change. Disable the switch (and the `[+ New form]` button) via `useTierGate('business')` when tier is below Business, surfacing the upgrade modal on click.
- [ ] `[+ New form]` → creates a draft form (`POST /api/lead-forms` with a default field set: Name*, Email*) and navigates to `/marketing/forms/:id/builder`.
- [ ] Empty state via `EmptyState` when no forms exist.
- [ ] Row click → `/marketing/forms/:id`.
**Acceptance:**
- [ ] Toggling status updates the row and persists across reload.
- [ ] Below-Business tenant sees disabled create/toggle with an upgrade prompt; can still open detail to view submissions.

### Task 7: Form detail shell with tabs (`/marketing/forms/:id`)
**Blocks:** 9, 10, 11  ·  **Blocked by:** 6
**Files:**
- Create: `apps/zync-app/src/features/marketing/forms/FormDetailPage.tsx`
**Steps:**
- [ ] Fetch `GET /api/lead-forms/:id`. Render header with form name + an "Edit form" link to `/marketing/forms/:id/builder` (gated `useTierGate('business')`).
- [ ] Render a `Tabs` shell with four tabs: **Submissions** (Task 10), **Settings** (Task 11), **Embed code** (Task 11), plus a read-only summary. Submissions tab is visible to all tiers; Settings/Embed gated to Business+.
- [ ] Wire loading/error states with the `ErrorState`/`Skeleton` components.
**Acceptance:**
- [ ] Tabs render and lazy-load their panels; deep-linking to a tab via `?tab=` works.

### Task 8: Drag-drop builder + live preview (`/marketing/forms/:id/builder`)
**Blocks:** —  ·  **Blocked by:** 2, 7
**Files:**
- Create: `apps/zync-app/src/features/marketing/forms/FormBuilderPage.tsx`
- Create: `apps/zync-app/src/features/marketing/forms/FormPreview.tsx`
- Create: `apps/zync-app/src/features/marketing/forms/fieldTypes.ts` (field-type catalog + default field factory)
**Steps:**
- [ ] Split-panel layout: left = fields editor, right = live `FormPreview` rendered from the in-memory `fields` array + `submitButtonLabel`.
- [ ] Drag-to-reorder field rows with `@dnd-kit/sortable`; reorder mutates the `fields` array order (array index = display order; no fractional index). Drag handle `[⠿]` per row.
- [ ] `[+ Add field ▾]` menu offering: Text input, Email input, Phone input, Textarea, Select dropdown, Checkbox, Hidden field — each appends a default `FieldConfig` (new `id` = `crypto.randomUUID()`).
- [ ] "Submit button" section: editable `submitButtonLabel` `Input`.
- [ ] "After submission" radio: `● Show thank you message` (`success_action='message'` + `success_message` textarea) / `○ Redirect to URL` (`success_action='redirect'` + `success_redirect_url` input).
- [ ] `[Save draft]` → `PATCH /api/lead-forms/:id` (keeps `is_active=false`); `[Publish]` → `PATCH` with `is_active=true`. Respect `requireTier('business')` (the API enforces; UI also gates).
- [ ] Preview honors field types: text/email/phone/textarea/select/checkbox render their inputs; `hidden` fields are not rendered in the preview. Phone input shows IL flag prefix. Textarea is 3 rows, resizable. `prefers-reduced-motion`: disable drag animation transitions when the media query matches.
- [ ] Apply `Map to lead` value to no visual change in preview but persists in `FieldConfig.mapTo`.
**Schema / Interfaces:**
```ts
// fieldTypes.ts
export const FIELD_TYPES = [
  { type: 'text',     label: 'Text input' },
  { type: 'email',    label: 'Email input' },
  { type: 'phone',    label: 'Phone input' },
  { type: 'textarea', label: 'Textarea' },
  { type: 'select',   label: 'Select dropdown' },
  { type: 'checkbox', label: 'Checkbox' },
  { type: 'hidden',   label: 'Hidden field' },
] as const;

export function makeField(type: FieldConfig['type']): FieldConfig {
  return { id: crypto.randomUUID(), type, label: '', required: false };
}
```
**Acceptance:**
- [ ] Dragging a field reorders both the editor list and the live preview.
- [ ] Adding each of the 7 field types appends a usable field; preview reflects it instantly.
- [ ] Choosing "Redirect to URL" requires a URL before Publish (client validation mirrors the API rule).
- [ ] With `prefers-reduced-motion`, drag transitions are suppressed.

### Task 9: Field editor (inline, per field)
**Blocks:** —  ·  **Blocked by:** 2, 8
**Files:**
- Create: `apps/zync-app/src/features/marketing/forms/FieldEditor.tsx`
**Steps:**
- [ ] `[Edit]` on a field row expands an inline editor below it.
- [ ] Editable: Label `Input`, Placeholder `Input`, Required `Checkbox`, "Map to lead" `Select` (options: `name`, `email`, `phone`, `company`, `notes`, plus "Don't map" → unset `mapTo`).
- [ ] For `type='select'`: an options list editor (add/remove/reorder string options → `FieldConfig.options`).
- [ ] For `type='hidden'`: a "Default value" `Input` (→ `FieldConfig.defaultValue`) and label is internal-only.
- [ ] `[Done]` collapses the editor; `[✕]` on the row removes the field.
- [ ] All edits mutate the in-memory `fields` array; saved via the builder's Save/Publish (Task 8).
- [ ] a11y: editor fields have associated `<label>`s / `aria-label`; the expand/collapse control has `aria-expanded`.
**Acceptance:**
- [ ] Setting "Map to lead → company" persists `mapTo:'company'` on that `FieldConfig` after Save.
- [ ] Select-type field shows the options editor; adding options updates the preview dropdown.
- [ ] Hidden-type field shows the default-value input and is hidden in preview.

### Task 10: Submissions tab + CSV export
**Blocks:** —  ·  **Blocked by:** 3, 4, 7
**Files:**
- Create: `apps/zync-app/src/features/marketing/forms/SubmissionsTab.tsx`
**Steps:**
- [ ] Fetch `GET /api/lead-forms/:id/submissions` (paginated). Columns: Date, Name, Email, Status.
- [ ] Render Status from the derived value (Task 4): `✓ Lead created` for `lead_created`, `✗ Duplicate` for `duplicate` (muted styling for duplicate rows).
- [ ] `[Export CSV]` button → client-side CSV from the loaded page(s) (Date, Name, Email, Status, plus mapped lead fields + payload keys); trigger download. Visible to all tiers.
- [ ] Header shows total count: "<Form name> — Submissions (N)".
- [ ] Empty state when no submissions.
**Acceptance:**
- [ ] Submissions list renders with correct per-row status badges.
- [ ] CSV export downloads a file with one header row + one row per submission.
- [ ] A below-Business tenant can view submissions and export CSV.

### Task 11: Settings tab + Embed-code tab
**Blocks:** —  ·  **Blocked by:** 3, 5, 7
**Files:**
- Create: `apps/zync-app/src/features/marketing/forms/FormSettingsTab.tsx`
- Create: `apps/zync-app/src/features/marketing/forms/EmbedCodeTab.tsx`
**Steps:**
- [ ] **Settings tab:** `captcha_enabled` `Switch` ("hCaptcha (free tier)"), notification `Switch` + `notify_email` `Input` ("Email me on new submission → To:"), and a read-only rate-limit display ("20 submissions per form per minute — defaults from RATE_LIMITER_LEAD_FORM"). Persist via `PATCH /api/lead-forms/:id`. Gated `useTierGate('business')`.
- [ ] **Embed code tab:** render three copy-able snippets using `embed.slug`, `embed.formUrl`, `embed.scriptHash` from `GET /api/lead-forms/:id`:
  - iFrame embed: `<iframe src="https://zync.is/f/{slug}" width="100%" height="600" frameborder="0"></iframe>`
  - Script embed (recommended): `<script src="https://zync.is/embed.v1.js" data-form="{slug}" integrity="{scriptHash}" crossorigin="anonymous"></script>`
  - Direct link: `https://zync.is/f/{slug}` with `[Copy]` and `[Open]`.
- [ ] Each snippet has a `[Copy code]` button (clipboard) with a toast confirmation.
- [ ] Embed tab gated Business+ (creating/managing forms is Business+; the snippets reference the tenant's own form).
**Acceptance:**
- [ ] Settings persist (`captcha_enabled`, `notify_email`) and reload correctly.
- [ ] The script-embed snippet's `integrity` equals the current `EMBED_V1_SRI`; copy buttons place the exact snippet on the clipboard.
- [ ] Direct-link `[Open]` opens `https://zync.is/f/{slug}` in a new tab.
