# Public Catalog Page (`/c/{token}`) — Implementation Plan

**Spec:** docs/specs/2026-05-31-public-catalog-page.md  ·  **Slug:** public-catalog-page  ·  **Wave:** 11
**Depends on:** foundation-design-system, marketing-catalogs-campaigns, white-label-api

## Goal
Deliver the public, unauthenticated catalog page served at `zync.is/c/{token}` on the `zync-www` Astro app. The page resolves a `catalog_shares` + `catalog_templates` record by token, applies white-label tenant branding, renders the catalog template sections (hero / text / products / pricing_table / cta / gallery), and optionally embeds a lead form at the bottom. A new REST proxy endpoint `GET /api/catalog/:token/public` on `zync-api` returns content + branding and emits the `catalog_view` Analytics Engine event exactly once per page load (server-side). This spec owns the page + the proxy endpoint + a new rate-limiter binding; the `catalog_shares`/`catalog_templates` tables, the funnel-event vocabulary, and the lead-form submit endpoint are owned upstream.

## Architecture
- **Upstream data (spec 23 `marketing-catalogs-campaigns`):** consumes existing tables `catalog_shares` (columns `id`, `tenant_id`, `template_id`, `name`, `public_token`, `utm_source`, `utm_medium`, `utm_campaign`, `is_active`, `view_count`, plus the JSONB `settings` field carrying `lead_form_id`) and `catalog_templates` (`id`, `tenant_id`, `name`, `content` JSONB). It reuses the `catalog_view` Analytics Engine event vocabulary `{ tenantId, catalogShareId, utmSource, utmMedium, utmCampaign }` and the `lead_captured` event `{ catalogShareId, utmSource, utmMedium, utmCampaign }`. This spec introduces **no new tables**.
- **Upstream branding (spec 34 white-label / proposal-view pattern):** tenant branding shape `{ logoR2Key, primaryColor, tenantName, customDomain }`. `logoR2Key` resolves to a public R2 URL; `primaryColor` becomes a CSS custom property; `customDomain` present **and** tenant on Enterprise tier hides the "Powered by Zync" footer.
- **Upstream lead form (spec 22 `lead-form-builder`):** the embedded form renders identically to the standalone `/f/{slug}` page and submits to `POST /api/forms/{slug}/submit` with a `catalogShareId` hidden field. That handler creates the `lead_form_submissions` record, creates/updates the lead with `catalog_share_id`, and emits `lead_captured`. This spec only renders and wires the form; it does not own the submit handler.
- **Data flow:** Astro SSR page (`c/[token].astro`, `output: 'hybrid'`) → server-side `fetch` of `GET /api/catalog/:token/public` (the only `catalog_view` emitter) → branding applied to the page shell → catalog content + lead-form descriptor passed to a React island (`CatalogPage`) for interactive lead-form submission. Invalid / deleted / inactive tokens render a static "link no longer active" state.
- **Single-emitter rule:** Spec 23 registered `GET /c/:token` as the canonical handler that emits `catalog_view`. This spec's `GET /api/catalog/:token/public` **is** that handler renamed for REST clarity. `catalog_view` is emitted exactly once, inside this handler. Spec 23's bare `GET /c/:token` route delegates here and never emits independently (no double-counting of the funnel top step).

## Tech Stack
- **App (page):** `apps/zync-www` — Astro (`output: 'hybrid'`), React island via `@astrojs/react`, Cloudflare Workers runtime.
- **App (API):** `apps/zync-api` — Hono route handler on Cloudflare Workers.
- **UI:** `@zync/ui` design-system primitives (`Container`, `Stack`, `Card`, `Button`, `Form`, `FormField`, `FormLabel`, `Input`, `Textarea`, `EmptyState`) + `@zync/config` Tailwind preset (no hardcoded colors/spacing/radius).
- **DB:** `@zync/db` (`createDb`, Drizzle, Neon Postgres via Hyperdrive) — read-only access to `catalog_shares`/`catalog_templates` (no schema changes).
- **Bindings:** `ANALYTICS_ENGINE` (existing, shared), `STORAGE` (R2 public bucket for logos/images), and **new** `RATE_LIMITER_CATALOG` (CF native RateLimiter, 30 req/min per IP per token).
- **i18n / a11y:** `@zync/types` `Locale`, `useDirection` for RTL; logical CSS only; `prefers-reduced-motion` honored on any hero/transition animation.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11.a | 1 | `apps/zync-api/wrangler.toml`, `apps/zync-www/wrangler.toml`, `apps/zync-api/src/env.ts` | No (binding precedes handler) |
| 11.b | 2 | `apps/zync-api/src/routes/catalog-public.ts`, `apps/zync-api/src/index.ts` | After 11.a |
| 11.c | 3, 4, 5, 6 | `apps/zync-www/src/components/catalog/*` | Tasks 3–6 parallel after 11.b types exist |
| 11.d | 7, 8 | `apps/zync-www/src/pages/c/[token].astro`, island wiring | After 11.c |
| 11.e | 9 | test files across api + www | After 11.d |

## Tasks

### Task 1: Add `RATE_LIMITER_CATALOG` binding + shared types
**Blocks:** 2  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/wrangler.toml`
- Modify: `apps/zync-www/wrangler.toml`
- Modify: `apps/zync-api/src/env.ts`
- Create: `packages/types/src/catalog-public.ts`
- Modify: `packages/types/src/index.ts`
**Steps:**
- [ ] Add CF native RateLimiter binding `RATE_LIMITER_CATALOG` to `apps/zync-api/wrangler.toml` with `simple = { limit = 30, period = 60 }` (30 req / 60s).
- [ ] Confirm `apps/zync-www/wrangler.toml` declares `output`-compatible SSR config; ensure the `zync-www` worker can reach `zync-api` (service binding or absolute URL env `ZYNC_API_URL`).
- [ ] Add `RATE_LIMITER_CATALOG: RateLimit` to the `Env` interface in `apps/zync-api/src/env.ts`.
- [ ] Define the public-response and section types in `packages/types/src/catalog-public.ts` (see Schema / Interfaces) and re-export from `packages/types/src/index.ts`.
**Schema / Interfaces:**
```ts
// packages/types/src/catalog-public.ts

export type CatalogSectionType =
  | 'hero' | 'text' | 'products' | 'pricing_table' | 'cta' | 'gallery';

export interface CatalogHeroSection {
  type: 'hero';
  title: string;
  subtitle?: string;
  backgroundImageR2Key?: string;
}
export interface CatalogTextSection {
  type: 'text';
  heading?: string;
  markdown: string;
}
export interface CatalogProduct {
  name: string;
  description?: string;
  price?: string;        // pre-formatted display string OR raw number rendered with currency
  priceAmount?: number;
  currency?: string;     // ISO 4217, e.g. 'ILS'
  imageR2Key?: string;
}
export interface CatalogProductsSection {
  type: 'products';
  heading?: string;
  products: CatalogProduct[];
}
export interface CatalogPricingTableSection {
  type: 'pricing_table';
  heading?: string;
  tiers: { name: string; price?: string }[];
  rows: { feature: string; values: (boolean | string)[] }[]; // values[i] aligns to tiers[i]
}
export interface CatalogCtaSection {
  type: 'cta';
  heading: string;
  subtext?: string;
  buttonLabel: string;
  buttonUrl?: string;    // when absent + lead form present, scrolls to embedded form
}
export interface CatalogGallerySection {
  type: 'gallery';
  heading?: string;
  imageR2Keys: string[];
}
export type CatalogSection =
  | CatalogHeroSection | CatalogTextSection | CatalogProductsSection
  | CatalogPricingTableSection | CatalogCtaSection | CatalogGallerySection
  | { type: string; [k: string]: unknown }; // forward-compat: unknown types skipped

export interface CatalogTemplateContent {
  sections: CatalogSection[];
}

export interface CatalogTenantBranding {
  logoR2Key: string | null;
  primaryColor: string | null;
  tenantName: string;
  customDomain: string | null;
}

export interface CatalogShareUtmParams {
  utmSource: string | null;
  utmMedium: string | null;
  utmCampaign: string | null;
}

export interface CatalogPublicResponse {
  template: { content: CatalogTemplateContent };
  share: {
    id: string;                       // catalog_shares.id (UUID) -> catalogShareId
    settings: { lead_form_id?: string | null };
    utmParams: CatalogShareUtmParams;
  };
  tenantBranding: CatalogTenantBranding;
}
```
**Acceptance:**
- [ ] `RATE_LIMITER_CATALOG` resolves in `apps/zync-api` at runtime (type-checked in `Env`).
- [ ] `CatalogPublicResponse` and section types import cleanly from `@zync/types`.
- [ ] No new DB table or column is declared by this task.

### Task 2: `GET /api/catalog/:token/public` proxy endpoint (single `catalog_view` emitter)
**Blocks:** 3, 7  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/routes/catalog-public.ts`
- Modify: `apps/zync-api/src/index.ts`
**Steps:**
- [ ] Register the route on the Hono app: `app.get('/api/catalog/:token/public', catalogPublicHandler)` (no auth middleware).
- [ ] Apply rate limiting first: derive client IP from `cf-connecting-ip`; call `RATE_LIMITER_CATALOG.limit({ key: \`${token}:${ip}\` })`; on `success === false` return `429` with `{ error: 'rate_limited' }`.
- [ ] Resolve `catalog_shares` by `public_token = :token` using `@zync/db` (`createDb`). Select `id, tenant_id, template_id, settings, utm_source, utm_medium, utm_campaign, is_active`.
- [ ] If no row, or `is_active = false`, return `404` `{ error: 'catalog_not_found' }` (the page renders the inactive state from this).
- [ ] Load `catalog_templates` by `id = share.template_id AND tenant_id = share.tenant_id`; select `content`.
- [ ] Load tenant branding for `share.tenant_id`: `tenantName` from `tenants`, plus `logoR2Key`, `primaryColor`, `customDomain` from tenant branding source (same source proposal-view consumes). Resolve nothing client-secret.
- [ ] Emit the `catalog_view` Analytics Engine event exactly once: `ANALYTICS_ENGINE.writeDataPoint({ blobs: [tenantId, catalogShareId, utmSource ?? '', utmMedium ?? '', utmCampaign ?? ''], indexes: [tenantId] })` with logical payload `{ tenantId, catalogShareId, utmSource, utmMedium, utmCampaign }`. Do NOT increment/emit anywhere else in this request.
- [ ] Optionally increment `catalog_shares.view_count` (spec 23 behaviour) in the same handler; guard so it is not double-counted by the delegating `GET /c/:token` route.
- [ ] Return `200` `CatalogPublicResponse` JSON. Set `Cache-Control: no-store` (view event must fire each load).
- [ ] Apply security headers consistent with public endpoints: strict `Content-Type: application/json`, no reflected token in error bodies beyond the generic message.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/routes/catalog-public.ts
import type { Context } from 'hono';
import type { Env } from '../env';
import type { CatalogPublicResponse } from '@zync/types';

export async function catalogPublicHandler(
  c: Context<{ Bindings: Env }>
): Promise<Response>; // 200 CatalogPublicResponse | 404 catalog_not_found | 429 rate_limited
```
**Acceptance:**
- [ ] Valid active token returns `200` with `template`, `share`, `tenantBranding`.
- [ ] Invalid / deleted / `is_active=false` token returns `404 catalog_not_found`.
- [ ] Exceeding 30 req/min for the same `{token}:{ip}` returns `429 rate_limited`.
- [ ] Exactly one `catalog_view` AE event is written per successful (`200`) load and none on `404`/`429`.
- [ ] Endpoint requires no authentication.

### Task 3: Section renderer components (`hero`, `text`, `products`, `pricing_table`, `cta`, `gallery`)
**Blocks:** 7  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-www/src/components/catalog/sections/HeroSection.tsx`
- Create: `apps/zync-www/src/components/catalog/sections/TextSection.tsx`
- Create: `apps/zync-www/src/components/catalog/sections/ProductsSection.tsx`
- Create: `apps/zync-www/src/components/catalog/sections/PricingTableSection.tsx`
- Create: `apps/zync-www/src/components/catalog/sections/CtaSection.tsx`
- Create: `apps/zync-www/src/components/catalog/sections/GallerySection.tsx`
- Create: `apps/zync-www/src/components/catalog/SectionRenderer.tsx`
- Create: `apps/zync-www/src/lib/r2-url.ts`
**Steps:**
- [ ] `r2-url.ts`: `r2PublicUrl(key: string | null | undefined): string | null` — maps an R2 object key to its public bucket URL (read base from `PUBLIC_R2_BASE_URL` env). Returns `null` for empty keys.
- [ ] `HeroSection`: full-width banner; `title` (h1/h2 depending on position), optional `subtitle`, optional background image via `r2PublicUrl(backgroundImageR2Key)`. Any parallax/transition gated behind `prefers-reduced-motion: no-preference`.
- [ ] `TextSection`: render `markdown` via a safe markdown renderer (sanitize HTML — no raw HTML injection); optional `heading`.
- [ ] `ProductsSection`: responsive grid (mobile-first) of product cards using `@zync/ui` `Card`; each card shows image (`loading="lazy"`), name, description, formatted price (`priceAmount` + `currency` via `Intl.NumberFormat`, else `price` string). Empty `products` → render nothing.
- [ ] `PricingTableSection`: comparison `<table>` with tier columns and feature rows; boolean values render check/cross with accessible text (`aria-label`/visually-hidden "Included"/"Not included"), string values render literally. Proper `<th scope>` headers.
- [ ] `CtaSection`: heading, subtext, button. If `buttonUrl` set → external link (`rel="noopener noreferrer"`); else button scrolls to the embedded lead form anchor (`#lead-form`). When no form and no URL, render as plain contact text.
- [ ] `GallerySection`: image grid from `imageR2Keys` (each `loading="lazy"`, descriptive `alt`).
- [ ] `SectionRenderer`: switch over `section.type`; render the matching component; **silently skip** any unknown `type` (forward-compat, never throw). Use logical CSS so RTL is correct.
**Schema / Interfaces:**
```tsx
// apps/zync-www/src/components/catalog/SectionRenderer.tsx
import type { CatalogSection } from '@zync/types';
export function SectionRenderer(props: { section: CatalogSection; hasLeadForm: boolean }): JSX.Element | null;
```
**Acceptance:**
- [ ] Each of the six known section types renders per the layout in the spec.
- [ ] An unknown section `type` renders nothing and throws no error.
- [ ] All product/gallery images use `loading="lazy"`; pricing-table check/cross have accessible labels.
- [ ] Markdown is sanitized (no script/raw-HTML execution from template content).
- [ ] Layout is RTL-correct via logical CSS and responsive mobile-first.

### Task 4: Embedded lead form component
**Blocks:** 7  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-www/src/components/catalog/EmbeddedLeadForm.tsx`
- Create: `apps/zync-www/src/lib/lead-form.ts`
**Steps:**
- [ ] `lead-form.ts`: `fetchLeadFormDefinition(formId: string): Promise<LeadFormDefinition>` — resolve the lead form definition for inline rendering (same field schema the standalone `/f/{slug}` page consumes). If the catalog page receives the slug directly in the descriptor, prefer that; otherwise fetch by id.
- [ ] `EmbeddedLeadForm`: render the form fields using `@zync/ui` `Form`/`FormField`/`FormLabel`/`Input`/`Textarea`/`Button`. Anchor wrapper `id="lead-form"` with heading "Interested? Get in touch".
- [ ] Include a hidden `catalogShareId` field set from the `share.id` prop, plus hidden UTM fields (`utm_source`, `utm_medium`, `utm_campaign`) from `share.utmParams`.
- [ ] On submit: `POST /api/forms/{slug}/submit` (spec 22 handler) with form values + `catalogShareId` + UTM. Do not emit `lead_captured` here — the submit handler owns it.
- [ ] Show success state ("Thanks — we'll be in touch") and inline validation errors; respect `prefers-reduced-motion` for any reveal animation.
- [ ] When the catalog has no `lead_form_id`, this component is not rendered (handled by parent).
**Schema / Interfaces:**
```tsx
// apps/zync-www/src/components/catalog/EmbeddedLeadForm.tsx
export interface EmbeddedLeadFormProps {
  formIdOrSlug: string;
  catalogShareId: string;
  utmParams: { utmSource: string | null; utmMedium: string | null; utmCampaign: string | null };
}
export function EmbeddedLeadForm(props: EmbeddedLeadFormProps): JSX.Element;
```
**Acceptance:**
- [ ] Form renders inline within the catalog layout, anchored at `#lead-form`.
- [ ] Submission POSTs to `/api/forms/{slug}/submit` with `catalogShareId` + UTM fields present.
- [ ] No `lead_captured` event is emitted client-side (server handler owns it).
- [ ] Success and error states are accessible (`role="alert"` on errors, focus moved to message).

### Task 5: White-label branding shell + "Powered by Zync" footer logic
**Blocks:** 7  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-www/src/components/catalog/CatalogBrandingShell.tsx`
- Create: `apps/zync-www/src/lib/branding.ts`
**Steps:**
- [ ] `branding.ts`: `resolvePrimaryColorVar(primaryColor: string | null): Record<string,string>` — returns a style object setting a CSS custom property (e.g. `--catalog-primary`) when a valid color is present; otherwise falls back to the design-system token default (no hardcoded hex in components).
- [ ] `branding.ts`: `shouldHidePoweredBy(branding: CatalogTenantBranding, tenantTier: TenantTier): boolean` — true only when `customDomain` is set AND tier is Enterprise (`enterprise`/`white_label`).
- [ ] `CatalogBrandingShell`: header with tenant logo via `r2PublicUrl(logoR2Key)` (fallback to tenant name text if no logo); apply primary-color CSS var to the wrapper; footer renders "Powered by Zync" unless `shouldHidePoweredBy` is true.
- [ ] Ensure logo `alt` = tenant name; header is a `<header>`, footer a `<footer>` for landmark a11y.
**Schema / Interfaces:**
```tsx
// apps/zync-www/src/components/catalog/CatalogBrandingShell.tsx
import type { CatalogTenantBranding } from '@zync/types';
export interface CatalogBrandingShellProps {
  branding: CatalogTenantBranding;
  hidePoweredBy: boolean;
  children: React.ReactNode;
}
export function CatalogBrandingShell(props: CatalogBrandingShellProps): JSX.Element;
```
**Acceptance:**
- [ ] Tenant logo + primary color applied from branding payload; no hardcoded colors.
- [ ] "Powered by Zync" footer hidden only for Enterprise tenants with a custom domain; shown otherwise.
- [ ] `<header>`/`<footer>` landmarks present; logo has descriptive `alt`.

### Task 6: Not-found / inactive state component
**Blocks:** 7  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-www/src/components/catalog/CatalogInactive.tsx`
**Steps:**
- [ ] Render the inactive state: tenant logo if resolvable, message "This link is no longer active." and "Contact the sender for more information." using `@zync/ui` `EmptyState`.
- [ ] Accept optional partial branding (logo may resolve even when share is gone); never leak token or internal error detail.
- [ ] Set page status semantics so it is announced as a non-error informational state (no scary error styling).
**Schema / Interfaces:**
```tsx
// apps/zync-www/src/components/catalog/CatalogInactive.tsx
export interface CatalogInactiveProps { logoUrl?: string | null; tenantName?: string | null; }
export function CatalogInactive(props: CatalogInactiveProps): JSX.Element;
```
**Acceptance:**
- [ ] Renders for invalid token, deleted share, and `is_active=false`.
- [ ] Shows the exact spec copy and no token/internal detail.

### Task 7: `CatalogPage` React island (assembles sections + branding + form)
**Blocks:** 8  ·  **Blocked by:** 3, 4, 5, 6
**Files:**
- Create: `apps/zync-www/src/components/catalog/CatalogPage.tsx`
**Steps:**
- [ ] Compose `CatalogBrandingShell` → ordered `SectionRenderer` per `template.content.sections` → `EmbeddedLeadForm` (only when `share.settings.lead_form_id` is set) anchored at `#lead-form`.
- [ ] Pass `hasLeadForm` into `SectionRenderer` so `cta` buttons without a URL scroll to `#lead-form`.
- [ ] Wrap in `useDirection`/locale provider so RTL/Hebrew layout is correct; honor `prefers-reduced-motion` globally.
- [ ] This is the only interactive island (lead-form submission); section rendering itself is otherwise static.
**Schema / Interfaces:**
```tsx
// apps/zync-www/src/components/catalog/CatalogPage.tsx
import type { CatalogPublicResponse } from '@zync/types';
export interface CatalogPageProps {
  data: CatalogPublicResponse;
  hidePoweredBy: boolean;
  locale: string;
}
export function CatalogPage(props: CatalogPageProps): JSX.Element;
```
**Acceptance:**
- [ ] Sections render in template order; lead form appears only when `lead_form_id` set.
- [ ] CTA-without-URL scrolls to the embedded form.
- [ ] RTL and reduced-motion both respected.

### Task 8: Astro SSR page `c/[token].astro`
**Blocks:** 9  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-www/src/pages/c/[token].astro`
- Modify: `apps/zync-www/astro.config.mjs` (confirm `output: 'hybrid'`)
**Steps:**
- [ ] Confirm `astro.config.mjs` uses `output: 'hybrid'` (binding change owned by spec 22); this page exports nothing forcing static prerender (dynamic token).
- [ ] On request: read `token` from `Astro.params`; server-side `fetch` `GET /api/catalog/:token/public` (via `ZYNC_API_URL` or service binding), forwarding `cf-connecting-ip` so rate limiting/IP keys correctly.
- [ ] On `404`/`429`/network error: render `<CatalogInactive />` (attempt a best-effort logo only if branding is available; otherwise no logo). Set HTTP status `404` for not-found, `429` for rate-limited.
- [ ] On `200`: compute `hidePoweredBy` via `shouldHidePoweredBy(branding, tenantTier)` (tier resolved from the API payload if present, else treat as non-Enterprise → show footer), determine `locale` from request, and hydrate `<CatalogPage client:load data={...} hidePoweredBy={...} locale={...} />`.
- [ ] Set CSP headers (default-src self; img-src self + R2 public host; no inline scripts beyond Astro/island bootstrap with nonce) and `X-Content-Type-Options: nosniff`. Do not emit any analytics event from this page (server handler is the sole emitter).
- [ ] Set `<html dir>` from locale direction; `<title>` = tenant/catalog name.
**Acceptance:**
- [ ] `zync.is/c/{validToken}` renders the branded catalog with all sections.
- [ ] `zync.is/c/{badToken}` and inactive share render the inactive state with `404`.
- [ ] Page emits no `catalog_view` event itself (only the API handler does).
- [ ] `output: 'hybrid'` confirmed; CSP + `nosniff` headers present; `dir` reflects locale.

### Task 9: Tests (API handler + renderer + page integration)
**Blocks:** —  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-api/src/routes/catalog-public.test.ts`
- Create: `apps/zync-www/src/components/catalog/SectionRenderer.test.tsx`
- Create: `apps/zync-www/src/pages/c/__tests__/token-page.test.ts`
**Steps:**
- [ ] API handler tests: active token → `200` shape + exactly one `catalog_view` AE write; missing/inactive token → `404` + zero AE writes; rate-limit exceeded → `429` + zero AE writes; no-auth required.
- [ ] Renderer tests: each known section renders; unknown section type yields no output and no throw; pricing-table booleans expose accessible labels; product/gallery images carry `loading="lazy"`.
- [ ] Branding tests: `shouldHidePoweredBy` true only for Enterprise + custom domain; false in all other combinations.
- [ ] Page integration: `404` from API → inactive state with `404` status; lead form rendered only when `lead_form_id` present and posts to `/api/forms/{slug}/submit` with `catalogShareId` + UTM.
**Acceptance:**
- [ ] All tests pass under the workspace test runner.
- [ ] `catalog_view` single-emission and `lead_captured` non-emission (client) are both asserted.
