# Public Proposal View (`/p/{token}`) — Implementation Plan

**Spec:** docs/specs/2026-05-31-public-proposal-view.md  ·  **Slug:** public-proposal-view  ·  **Wave:** 11
**Depends on:** foundation-auth-rbac, marketing-catalogs-campaigns, system-communications-notifications, white-label-api

## Goal
Deliver a public, unauthenticated page at `zync.is/p/{token}` where a proposal recipient (lead or customer, no Zync account) views a tenant's proposal and accepts or declines it. The page is served from `zync-www` (Astro SSR + React island) with tenant white-label branding applied server-side. This spec adds three public API endpoints (view/accept/reject) to `zync-api`, the canonical `ProposalContent` JSONB type in `@zync/types`, a single `ALTER TABLE` adding `proposals.locale`, a new rate-limiter binding, three notification types, and renders the same content inside the authenticated tenant portal.

## Architecture
- **Data:** No new tables. Consumes upstream `proposals` and `proposal_view_events` (defined in `marketing-catalogs-campaigns`, spec 23). Adds one column: `proposals.locale`. Branding is read from `tenants.settings` JSONB (logo R2 key, primary color, tenant name, default locale) and `tenant_domains` (custom domain, from `white-label-api`).
- **API (zync-api / Hono):** Three public routes with no auth middleware, rate-limited by `RATE_LIMITER_PROPOSAL`. Token uniquely identifies a proposal across all tenants. The `GET .../public` route has side effects (view-event logging, counters, first-view webhook + notification + Analytics Engine event, status `SENT`→`VIEWED`), all wrapped in a DB transaction. Accept/Reject mutate status idempotently (409 on re-attempt) and fire notifications, emails, outbound webhooks, and Analytics Engine events.
- **Notifications/Webhooks:** Reuses `createNotification` / `deliverNotification` / `sendEmail` from `@zync/notifications` (system-communications-notifications) and the `webhook.deliver` queue + outbound event catalog from `white-label-api`. Registers in-app+email types `proposal.viewed`, `proposal.accepted`, `proposal.rejected`; adds `proposal.rejected` to the outbound webhook catalog (`proposal.viewed`/`proposal.accepted` already present).
- **Frontend (zync-www / Astro):** SSR page `p/[token].astro` (`prerender = false`) fetches the proposal server-side, sets `<html dir lang>` from the proposal locale, and hands content to a React island that renders all six page states and the accept/decline modals.
- **Portal:** The same content renderer is reused inside the portal shell (`tenant-portals` owns the shell) for `/portal/{tenantSlug}/proposals` plus a proposal list for the authenticated customer.

## Tech Stack
- **Apps:** `apps/zync-api` (Hono on Cloudflare Workers), `apps/zync-www` (Astro + React island), `apps/zync-app` (portal content reuse — portal shell owned by tenant-portals).
- **Packages:** `@zync/types` (canonical `ProposalContent`), `@zync/db` (Drizzle, Neon Postgres via Hyperdrive), `@zync/notifications` (`createNotification`, `deliverNotification`, `sendEmail`), `@zync/ui` (`formatCurrency`, `formatDate`, `Skeleton`, `Button`, `Dialog`, `Textarea`).
- **Cloudflare bindings:** `RATE_LIMITER_PROPOSAL` (native RateLimiter), `STORAGE` (R2, public logo URL), `QUEUE` (`webhook.deliver`), `ANALYTICS_ENGINE` (funnel AE events), `DB`/Hyperdrive.
- **Migration:** Drizzle migration adding `proposals.locale`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — contracts | 1, 2, 3 | `@zync/types`, Drizzle migration, wrangler.toml, notification + webhook registries | Yes (independent) |
| B — API | 4, 5, 6 | zync-api routes (public GET, accept, reject) | After A; 4 before 5/6 share helpers — sequential within file |
| C — frontend | 7, 8, 9 | zync-www Astro page + React island + content components | After B (needs API shapes); 7 before 8/9 |
| D — portal | 10 | app/portal proposal list + content reuse | After C (reuses content components) |

## Tasks

### Task 1: Canonical `ProposalContent` types in `@zync/types`
**Blocks:** 4, 7, 8, 9, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/proposal-content.ts`
- Modify: `packages/types/src/index.ts`
**Steps:**
- [ ] Transcribe the `ProposalSection`, `LineItem`, and `ProposalContent` types verbatim (canonical home for these — spec 23's template builder consumes them from here).
- [ ] Document that currency is ISO 4217 (`ILS` | `USD` | `EUR`) and all amounts are base currency units (not subunits).
- [ ] Re-export all three from `packages/types/src/index.ts` so consumers import via `@zync/types`.
**Schema / Interfaces:**
```ts
export type ProposalSection =
  | { type: 'hero'; title: string; subtitle?: string; imageR2Key?: string }
  | { type: 'text'; heading?: string; body: string }           // Markdown
  | { type: 'items'; heading?: string; items: LineItem[] }
  | { type: 'pricing'; subtotal: number; discount?: { label: string; amount: number }; total: number; currency: string; vatRate?: number; vatAmount?: number }
  | { type: 'cta'; acceptLabel?: string; declineLabel?: string; note?: string }

export interface LineItem {
  name: string
  description?: string
  qty: number
  unitPrice: number
  currency: string
  subtotal: number
}

export interface ProposalContent {
  sections: ProposalSection[]
  recipientName?: string    // prefilled from lead/customer at send time
  senderNote?: string       // free-text intro from staff ("Hi Acme, please review...")
}
```
**Acceptance:**
- [ ] `import { ProposalContent, ProposalSection, LineItem } from '@zync/types'` typechecks.
- [ ] Types match the spec's shape exactly (5 section variants; `pricing` carries optional `vatRate`/`vatAmount`).

### Task 2: Migration — add `proposals.locale`
**Blocks:** 4, 7  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/drizzle/<timestamp>_proposals_add_locale.sql`
- Modify: `packages/db/src/schema/proposals.ts` (Drizzle table definition for `proposals`)
**Steps:**
- [ ] Add the `locale` column to the existing `proposals` table (do NOT recreate the table — it is owned upstream by `marketing-catalogs-campaigns`).
- [ ] Mirror the column in the Drizzle schema object for `proposals` so query helpers can select it.
- [ ] Default `'he'`; constrain to `('he', 'en')` so the public page can resolve `dir`/`lang` server-side.
**Schema / Interfaces:**
```sql
-- Migration: add per-proposal locale (drives public page dir/lang server-side)
ALTER TABLE proposals
  ADD COLUMN locale TEXT DEFAULT 'he' CHECK (locale IN ('he', 'en'));
```
Drizzle column:
```ts
locale: text('locale').default('he'), // CHECK (locale IN ('he','en')) enforced in SQL migration
```
**Acceptance:**
- [ ] Migration applies cleanly on Neon Postgres; existing rows backfill to `'he'`.
- [ ] Inserting `locale = 'fr'` is rejected by the CHECK constraint.

### Task 3: Foundation deltas — rate limiter binding + notification & webhook registries
**Blocks:** 4, 5, 6  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/wrangler.toml`
- Modify: notification type registry (e.g. `packages/notifications/src/types.ts` / notification-type enum consumed by `createNotification`)
- Modify: outbound webhook event catalog (`white-label-api` event list, e.g. `packages/notifications/src/webhook-events.ts`)
**Steps:**
- [ ] Add the `RATE_LIMITER_PROPOSAL` binding to `wrangler.toml` following the existing `RATE_LIMITER_EXPENSE_UPLOAD` pattern, configured for 10 requests/min (per-IP-per-token key composed in the route, Task 4).
- [ ] Add in-app+email notification types `proposal.viewed`, `proposal.accepted`, `proposal.rejected` to the notification registry consumed by `createNotification`/`deliverNotification`.
- [ ] Add `proposal.rejected` to the outbound webhook event catalog (CRM group already lists `proposal.viewed`, `proposal.accepted` per white-label-api; `proposal.rejected` is new to the outbound catalog).
- [ ] Export the binding type on the `Env` interface so routes can reference `c.env.RATE_LIMITER_PROPOSAL`.
**Schema / Interfaces:**
```toml
# apps/zync-api/wrangler.toml — pattern of RATE_LIMITER_EXPENSE_UPLOAD
[[unsafe.bindings]]
name = "RATE_LIMITER_PROPOSAL"
type = "ratelimit"
namespace_id = "<assigned>"
simple = { limit = 10, period = 60 }   # 10 req / 60s; key = `${token}:${ip}`
```
```ts
// Env addition
RATE_LIMITER_PROPOSAL: RateLimit
// notification types added: 'proposal.viewed' | 'proposal.accepted' | 'proposal.rejected'
// webhook catalog added: 'proposal.rejected'
```
**Acceptance:**
- [ ] `c.env.RATE_LIMITER_PROPOSAL.limit({ key })` is callable in route code.
- [ ] All three notification types are registered and resolvable by `createNotification`.
- [ ] `proposal.rejected` appears in the subscribable outbound webhook event list.

### Task 4: `GET /api/proposals/:token/public` (public, view-logging side effects)
**Blocks:** 5, 6, 7  ·  **Blocked by:** 1, 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/proposals-public.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router; ensure NO `authMiddleware` on this path)
**Steps:**
- [ ] Mount the route with no auth middleware. Apply `RATE_LIMITER_PROPOSAL.limit({ key: `${token}:${clientIp}` })`; on `success === false` return `429`.
- [ ] Resolve the proposal by `public_token` (unique across tenants). If none, return `404` with an empty body — the page renders "not found" with no tenant branding.
- [ ] Read tenant branding: `logoR2Key`, `primaryColor`, `tenantName`, `defaultLocale` from `tenants.settings` JSONB; `customDomain` = active `tenant_domains.domain` for the tenant (status `ACTIVE`), else null. Convert `logoR2Key` to a PUBLIC R2 URL (page is unauthenticated — no signed URL).
- [ ] Extract UTM params (`utm_source`, `utm_medium`, `utm_campaign`) from the query string if present.
- [ ] In a single DB transaction: insert a `proposal_view_events` row (`ip`, `user_agent`, `referrer`, UTM); increment `proposals.view_count`; set `proposals.last_viewed_at = now()`; if `proposals.status = 'SENT'` set it to `'VIEWED'`; if `proposals.first_viewed_at IS NULL` set it to `now()` and mark a `firstView` flag for post-commit side effects.
- [ ] After commit, if `firstView`: emit outbound webhook `proposal.viewed` (enqueue `webhook.deliver`); create in-app + email notification to the proposal creator (`created_by`) via `createNotification`/`deliverNotification` and `sendEmail` with the proposal `locale`.
- [ ] Always write Analytics Engine event `proposal_view` with `{ tenantId, proposalId, leadId?, catalogShareId: null, utmSource, utmMedium, utmCampaign }`.
- [ ] Respond with the proposal (`name`, `content`, `status`, `expiresAt`, `viewCount`, `acceptedAt`, `rejectedAt`, `sentAt`, `locale`) and `tenantBranding` (`logoR2Key` as public URL, `primaryColor`, `tenantName`, `customDomain`, `defaultLocale`).
**Schema / Interfaces:**
```ts
// GET /api/proposals/:token/public  (no auth)
// 200 →
interface ProposalPublicResponse {
  proposal: {
    name: string
    content: ProposalContent
    status: 'SENT' | 'VIEWED' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED'
    expiresAt: string | null
    viewCount: number
    acceptedAt: string | null
    rejectedAt: string | null
    sentAt: string | null
    locale: 'he' | 'en'
  }
  tenantBranding: {
    logoR2Key: string | null       // serialized as a public R2 URL
    primaryColor: string | null
    tenantName: string
    customDomain: string | null
    defaultLocale: 'he' | 'en'
  }
}
// 404 → empty body (invalid/removed token, no tenant context)
// 429 → rate limited
// AE: proposal_view { tenantId, proposalId, leadId?, catalogShareId: null, utmSource, utmMedium, utmCampaign }
```
**Acceptance:**
- [ ] Each GET inserts exactly one `proposal_view_events` row and increments `view_count` by 1.
- [ ] First view sets `first_viewed_at`, fires `proposal.viewed` webhook + creator notification, exactly once across repeated views.
- [ ] `SENT` transitions to `VIEWED`; `ACCEPTED`/`REJECTED` statuses are not regressed.
- [ ] Invalid token returns 404 with no branding; valid token returns branding with a public (unsigned) logo URL.
- [ ] 11th request within 60s for the same `${token}:${ip}` returns 429.

### Task 5: `POST /api/proposals/:token/accept` (public, idempotent-safe)
**Blocks:** 7  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-api/src/routes/proposals-public.ts`
**Steps:**
- [ ] No auth; apply `RATE_LIMITER_PROPOSAL` with the same `${token}:${ip}` key.
- [ ] Validate body with Zod: `{ name?: string }`.
- [ ] Resolve by `public_token`; 404 if missing.
- [ ] If `status` is `ACCEPTED` or `REJECTED`, or `expires_at < now()`: return `409` with the current status (prevents double-accept from a network retry).
- [ ] In a transaction: set `status = 'ACCEPTED'`, `accepted_at = now()`, `accepted_by_name = name ?? content.recipientName` (NULL only if neither present).
- [ ] After commit: create in-app + email notification to the creator (`proposal.accepted`, "[name] accepted proposal: [proposal name]") via `createNotification`/`sendEmail` with the proposal `locale`; enqueue outbound webhook `proposal.accepted`; write AE event `proposal_accepted` with `{ tenantId, proposalId, leadId?, catalogShareId: null, utmSource, utmMedium, utmCampaign }`.
- [ ] Acceptance is a non-binding acknowledgment — NO automatic charge, contract, or invoice creation.
- [ ] Return the updated status so the island can transition to the Accepted state without reload.
**Schema / Interfaces:**
```ts
// POST /api/proposals/:token/accept  (no auth)
// body: { name?: string }
// 200 → { status: 'ACCEPTED', acceptedAt: string, acceptedByName: string | null }
// 404 → token not found
// 409 → { status }  (already accepted/rejected, or expired)
// AE: proposal_accepted { tenantId, proposalId, leadId?, catalogShareId: null, utmSource, utmMedium, utmCampaign }
```
**Acceptance:**
- [ ] First accept sets `status`, `accepted_at`, `accepted_by_name`; fires notification + email + `proposal.accepted` webhook + AE event once.
- [ ] Second accept (or accept of a rejected/expired proposal) returns 409 with current status and no side effects.
- [ ] When `name` omitted and `content.recipientName` present, `accepted_by_name` is set from `recipientName`.
- [ ] No invoice/contract/charge is created on accept.

### Task 6: `POST /api/proposals/:token/reject` (public, idempotent-safe)
**Blocks:** 7  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-api/src/routes/proposals-public.ts`
**Steps:**
- [ ] No auth; apply `RATE_LIMITER_PROPOSAL` with the `${token}:${ip}` key.
- [ ] Validate body with Zod: `{ reason?: string }`.
- [ ] Resolve by `public_token`; 404 if missing.
- [ ] If `status` is `ACCEPTED` or `REJECTED`, or `expires_at < now()`: return `409` with current status.
- [ ] In a transaction: set `status = 'REJECTED'`, `rejected_at = now()`.
- [ ] After commit: create in-app + email notification to the creator (`proposal.rejected`, "[recipient name] declined proposal: [proposal name]") via `createNotification`/`sendEmail` with the proposal `locale`; enqueue outbound webhook `proposal.rejected` (registered in Task 3 — for parity with the in-app registry).
- [ ] Return updated status so the island transitions to the Declined state.
**Schema / Interfaces:**
```ts
// POST /api/proposals/:token/reject  (no auth)
// body: { reason?: string }
// 200 → { status: 'REJECTED', rejectedAt: string }
// 404 → token not found
// 409 → { status }  (already accepted/rejected, or expired)
// notifications: proposal.rejected (in-app + email); outbound webhook: proposal.rejected
```
**Acceptance:**
- [ ] First reject sets `status` + `rejected_at`; fires notification + email + `proposal.rejected` webhook once.
- [ ] Re-reject or reject of accepted/expired proposal returns 409 with no side effects.
- [ ] No `proposal_declined` AE event is emitted (spec names only `proposal_view` and `proposal_accepted`).

### Task 7: Astro SSR page `p/[token].astro` + config
**Blocks:** 8  ·  **Blocked by:** 1, 2, 4, 5, 6
**Files:**
- Create: `apps/zync-www/src/pages/p/[token].astro`
- Create: `apps/zync-www/src/lib/fetchProposalPublic.ts`
- Modify: `apps/zync-www/astro.config.mjs`
**Steps:**
- [ ] Set `output: 'hybrid'` in `astro.config.mjs` (shared change with the spec 22 form page) and `export const prerender = false` on the page.
- [ ] Server-side, call `fetchProposalPublic(token)` → `GET /api/proposals/:token/public`, forwarding the inbound query string (UTM) so the API logs the view with UTM context. On 404 render the "not found" view (no branding).
- [ ] Resolve `const locale = proposal.locale ?? tenantBranding.defaultLocale ?? 'he'` and `const dir = locale === 'he' ? 'rtl' : 'ltr'`. Set `<html dir={dir} lang={locale}>` server-side.
- [ ] Inject `tenantBranding.primaryColor` as a CSS custom property (e.g. `--proposal-accent`) on a wrapper for the accept button + accents.
- [ ] Render the tenant logo from the public R2 URL; render "Powered by Zync" footer UNLESS `tenantBranding.customDomain` is set AND tenant is Enterprise (signalled by the API response — hide footer when `customDomain` present).
- [ ] Pass `proposal`, `tenantBranding`, `token`, and `locale` to the React island (Task 8) with `client:load` (interactivity required for accept/decline).
**Schema / Interfaces:**
```astro
---
const { token } = Astro.params
const res = await fetchProposalPublic(token, Astro.url.search)
const locale = res?.proposal.locale ?? res?.tenantBranding.defaultLocale ?? 'he'
const dir = locale === 'he' ? 'rtl' : 'ltr'
---
<html dir={dir} lang={locale}>
```
**Acceptance:**
- [ ] Page is SSR (not prerendered); the proposal is fetched server-side with no content flash.
- [ ] Hebrew proposals render `dir="rtl" lang="he"`; English render `dir="ltr" lang="en"` — driven by the proposal locale, not the viewer's browser.
- [ ] Custom-domain Enterprise tenants do not show the "Powered by Zync" footer.
- [ ] Invalid token renders the not-found view with no tenant branding.

### Task 8: React island — page states + accept/decline modals
**Blocks:** 9, 10  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-www/src/islands/ProposalView.tsx`
- Create: `apps/zync-www/src/islands/AcceptModal.tsx`
- Create: `apps/zync-www/src/islands/DeclineModal.tsx`
**Steps:**
- [ ] Implement the six page states: **Loading** (skeleton loaders for content sections), **Active** (full content + Accept + Decline CTAs when `status` is `SENT`/`VIEWED` and not expired), **Accepted** (read-only + green banner "Proposal accepted on {acceptedAt}"), **Declined** (read-only + muted banner "You declined this proposal"), **Expired** (read-only + amber banner "This proposal expired on {date}" when `expires_at < now()` and not accepted), **Not found** (handled in Astro; island receives null).
- [ ] Hide CTAs for Expired / Accepted / Declined states.
- [ ] Accept flow: clicking Accept opens `AcceptModal` ONLY when `content.recipientName` is null (asks for name); otherwise post directly. `POST /api/proposals/:token/accept` with `{ name? }`; on success transition to Accepted without reload; on 409 set state from the returned status.
- [ ] Decline flow: clicking Decline opens `DeclineModal` ("Let us know why (optional)" — single textarea); `POST /api/proposals/:token/reject` with `{ reason? }`; on success transition to Declined; on 409 set state from returned status.
- [ ] Accept button styled with `--proposal-accent` (primaryColor); Decline rendered as a text link, not a filled button.
- [ ] a11y: wrap each state banner in `role="status"` / `aria-live="polite"` so the post-accept/decline transition is announced without reload; modals are accessible dialogs (focus trap, `aria-modal`, labelled title, ESC to close) — use `Dialog`/`Textarea` from `@zync/ui`.
- [ ] Respect `prefers-reduced-motion`: disable the state-transition animations when the user prefers reduced motion.
**Schema / Interfaces:**
```ts
type ProposalState = 'loading' | 'active' | 'accepted' | 'declined' | 'expired' | 'not-found'
interface ProposalViewProps {
  proposal: ProposalPublicResponse['proposal'] | null
  tenantBranding: ProposalPublicResponse['tenantBranding'] | null
  token: string
  locale: 'he' | 'en'
}
```
**Acceptance:**
- [ ] All six states render the spec's UI (banners, skeleton, CTA visibility) correctly.
- [ ] Accept/decline transition the page without a reload; a 409 syncs the UI to the server status.
- [ ] Accept modal appears only when `recipientName` is null.
- [ ] Banners announce via `aria-live`; modals trap focus and are dismissible; animations are suppressed under `prefers-reduced-motion`.

### Task 9: Content renderer components (sections, items table, pricing, branding)
**Blocks:** 10  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-www/src/components/proposal/ProposalSections.tsx`
- Create: `apps/zync-www/src/components/proposal/ItemsTable.tsx`
- Create: `apps/zync-www/src/components/proposal/PricingSummary.tsx`
**Steps:**
- [ ] Render each `ProposalSection` variant: `hero` (title, subtitle, image from public R2 URL), `text` (rendered Markdown — sanitize output), `items` (table), `pricing` (summary), `cta` (note text; CTA buttons owned by the island).
- [ ] Render `senderNote` as a bordered callout above the text sections when present.
- [ ] `ItemsTable` uses real `<table>` semantics with a `<thead>` (Item, Qty, Unit, Subtotal) and `<tbody>`; columns are RTL-aware via logical CSS.
- [ ] `PricingSummary` shows subtotal, optional discount (`{ label, amount }`), optional VAT (`vatRate`, `vatAmount`), and total.
- [ ] Format all amounts with `formatCurrency` from `@zync/ui` using the proposal `locale` (Hebrew: `₪1,500` right-aligned; English: `ILS 1,500` / `₪1,500` left-aligned); format dates (expiry, accepted/expired banners) with `formatDate` and the proposal `locale`.
- [ ] Layout is mobile-first responsive and RTL-aware (same logical CSS conventions as zync-www).
- [ ] Footer line: `Expires: {date}  ·  {view_count} view(s)`.
**Schema / Interfaces:**
```ts
// consumes ProposalContent from @zync/types; formatCurrency / formatDate from @zync/ui
function PricingSummary(props: { section: Extract<ProposalSection, { type: 'pricing' }>; locale: 'he' | 'en' }): JSX.Element
function ItemsTable(props: { items: LineItem[]; locale: 'he' | 'en' }): JSX.Element
```
**Acceptance:**
- [ ] All five section variants render; Markdown text is sanitized.
- [ ] Items render as a semantic `<table>` with a header row.
- [ ] Currency/date formatting follows the proposal locale and direction.
- [ ] Layout reflows correctly on mobile and in RTL.

### Task 10: Authenticated portal proposal list + content reuse
**Blocks:** —  ·  **Blocked by:** 8, 9
**Files:**
- Create: `apps/zync-app/src/portal/proposals/ProposalListPage.tsx`
- Create: `apps/zync-app/src/portal/proposals/PortalProposalDetail.tsx`
**Steps:**
- [ ] Render the portal proposal LIST for the authenticated customer: all proposals where `customer_id` matches the portal session's customer. Columns: proposal name, status, sent date, expiry, action link to `/p/{token}`.
- [ ] Reuse the content renderer components from Task 9 (and the Active/Accepted/Declined/Expired display logic) inside the portal shell. The portal shell, auth session, and routing for `/portal/{tenantSlug}/proposals` are owned by `tenant-portals`; this spec provides only the content rendering and the list.
- [ ] Use the same `GET /api/proposals/:token/public` (or portal-authenticated equivalent provided by tenant-portals) to load detail content; do not duplicate the renderer.
**Schema / Interfaces:**
```ts
interface PortalProposalListItem {
  id: string
  name: string
  status: 'SENT' | 'VIEWED' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED'
  sentAt: string | null
  expiresAt: string | null
  publicToken: string  // action link → /p/{token}
}
```
**Acceptance:**
- [ ] The list shows only proposals for the authenticated customer (`customer_id` match).
- [ ] Each row links to `/p/{token}`; status/sent/expiry render correctly.
- [ ] The detail view reuses the Task 9 content components (no duplicated renderer).
