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

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 58  
**Tier:** All tiers (public, no account needed)  
**Depends on:** `marketing-catalogs-campaigns`, `white-label-api`, `foundation-design-system`  
**Referenced by:** `marketing-catalogs-campaigns`

---

## Overview

Public, unauthenticated product/service catalog page. Spec 23 (`marketing-catalogs-campaigns`) owns the `catalog_shares` + `catalog_templates` data model, the `GET /c/:token` API handler (on zync-api), and the AE `catalog_view` event. This spec owns the zync-www page implementation: the Astro host, section rendering, white-label branding, and embedded lead form integration.

URL: `zync.is/c/{token}`

---

## Page: `/c/{token}` (zync-www)

Astro page at `apps/zync-www/src/pages/c/[token].astro`. Output: `hybrid` (SSR — token is dynamic).

Server-side on load:
1. Resolve `catalog_shares` + `catalog_templates` from token via `GET /api/catalog/{token}/public`
2. Apply tenant branding
3. `catalog_view` AE event emitted server-side by `/api/catalog/{token}/public` handler (step 1) — not emitted again here
4. Pass data to React island for interactivity (lead form submission)

> **Single emitter note:** Spec 23 owns the `catalog_view` AE event and registered `GET /c/:token` as an API handler. Spec 58's `/api/catalog/{token}/public` is that handler (renamed for REST clarity). `catalog_view` is emitted exactly once per page load — inside that handler. Spec 23's direct `GET /c/:token` route should delegate here and not emit independently to avoid double-counting the funnel top step.

### White-label branding

Same pattern as spec 51 (proposal view): `tenantBranding` in API response. `logoR2Key` → public R2 URL, `primaryColor` → CSS custom property, `customDomain` + Enterprise → hides "Powered by Zync" footer.

---

## Catalog Template Rendering

Catalog template JSONB (`catalog_templates.content`) is defined in spec 23's template builder. The renderer handles these section types:

| Section type | Rendering |
|---|---|
| `hero` | Full-width banner with title, subtitle, background image (R2 public URL) |
| `text` | Markdown-rendered text block with optional heading |
| `products` | Grid of product/service cards: name, description, price (+ currency), image (R2) |
| `pricing_table` | Comparison table with tier columns and feature rows (checkmarks/crosses) |
| `cta` | Call-to-action block: heading, sub-text, button label (scrolls to lead form or links to contact) |
| `gallery` | Image grid (R2 public URLs) |

Unknown section types are silently skipped (forward-compatible).

---

## Page Layout

```
┌──────────────────────────────────────────────────────────┐
│  [Tenant logo]                      Powered by Zync      │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  [Hero section]                                          │
│                                                          │
│  [Text section(s)]                                       │
│                                                          │
│  [Products grid]                                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐               │
│  │ Product  │  │ Product  │  │ Product  │               │
│  │ [image]  │  │ [image]  │  │ [image]  │               │
│  │ Name     │  │ Name     │  │ Name     │               │
│  │ ₪999/mo  │  │ ₪1,499   │  │ Custom   │               │
│  └──────────┘  └──────────┘  └──────────┘               │
│                                                          │
│  [Pricing table (if present)]                            │
│                                                          │
│  [CTA section]                                           │
│                                                          │
│  ── Interested? Get in touch ─────────────────────────── │
│  [Embedded lead form — if configured]                    │
│                                                          │
└──────────────────────────────────────────────────────────┘
```

RTL-aware (same logical CSS). Fully responsive (mobile-first). Product card images lazy-loaded.

---

## Embedded Lead Form

`catalog_shares.settings.lead_form_id` (nullable): if set, the referenced lead form is rendered inline at the bottom of the catalog page. Rendering is identical to the standalone form page (`/f/{slug}`, spec 22), but embedded within the catalog layout.

Form submission POSTs to `POST /api/forms/{slug}/submit` (spec 22) with the `catalogShareId` hidden field, which:
- Creates a `lead_form_submissions` record
- Creates/updates the lead with `catalog_share_id` set
- Emits `lead_captured` AE event with `{ catalogShareId, utmSource, utmMedium, utmCampaign }` from the share link

If no `lead_form_id`: CTA section button label used as contact text; no form rendered.

---

## Not Found / Inactive

```
[Tenant logo — if resolvable]

This link is no longer active.
Contact the sender for more information.
```

Shown when token is invalid, `catalog_shares` record deleted, or `catalog_shares.active = false`.

---

## API Endpoint (new — proxies spec 23 data)

```
GET  /api/catalog/:token/public    → catalog content + branding (no auth)
                                     returns: {
                                       template: { content: CatalogTemplateContent },
                                       share: { id, settings: { lead_form_id? }, utmParams },
                                       tenantBranding: { logoR2Key, primaryColor, tenantName, customDomain }
                                     }
                                     Side effect: emits catalog_view AE event (server-side)
```

Rate limiting: 30 req/min per IP per token (CF native RateLimiter binding `RATE_LIMITER_CATALOG`).

---

## Foundation Deltas

**New rate limiter binding:** `RATE_LIMITER_CATALOG` — CF native RateLimiter, 30 req/min per IP per token.

**New zync-www route:** `apps/zync-www/src/pages/c/[token].astro` — requires `output: 'hybrid'` (change owned by spec 22).

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| AE event server-side on GET | Not a client-side beacon | Consistent with proposal view; JS-blocked clients (bots, scrapers) should not inflate view counts |
| Lead form embedded (not redirect) | Same-page inline form | Reduces friction; catalog → lead in one page load; no redirect abandonment |
| Section type forward-compat | Unknown types silently skipped | Template builder may add new types; renderer must not crash on unknown input |
| Separate `RATE_LIMITER_CATALOG` binding | Not shared with form rate limiter | Different limits — catalog views expected to be higher volume than form submissions |
