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

**Date:** 2026-05-31  
**Status:** Draft  
**Tier:** All tiers (proposal send available on all tiers; recipient has no account)  
**Depends on:** `marketing-catalogs-campaigns`, `foundation-auth-rbac`, `system-communications-notifications`, `white-label-api`  
**Referenced by:** `marketing-catalogs-campaigns`, `tenant-portals`

---

## Overview

Public, unauthenticated page where a proposal recipient (lead or customer) views a proposal sent by a tenant. Recipient can accept or decline without a Zync account. Served from `zync-www` (Astro + React island). URL: `zync.is/p/{publicToken}`.

Tenant portal authenticated view (`/portal/{tenantSlug}/proposals`) renders the same content via the same API but inside the portal shell.

---

## Data Model

No new tables. Uses `proposals` and `proposal_view_events` from spec 23. One new API endpoint added to zync-api.

### ProposalContent JSONB shape (`proposals.content`)

Defined here as the canonical TypeScript type — spec 23's template builder must produce content matching this shape.

```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...")
}
```

Currency is ISO 4217 (ILS, USD, EUR). All amounts in base currency units (not subunits).

---

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

Astro page at `apps/zync-www/src/pages/p/[token].astro`. Output: `hybrid` (SSR — token is dynamic). Fetches proposal from API server-side; passes to React island for interactivity.

### White-label branding

Tenant branding applied via `GET /api/proposals/{token}/public` response field `tenantBranding`:
- `logoR2Key` → public R2 URL (no signed URL — page is unauthenticated)
- `primaryColor` → CSS custom property override for accept button + accents
- `tenantName` → shown in header + email notifications
- `customDomain` → if set + tenant is Enterprise, "Powered by Zync" footer hidden

### Page layout

```
���────────────────────────────────────────────────────┐
│  [Tenant logo]                    Powered by Zync  │
├────────────────────────────────────────────────────┤
│                                                    │
│  [Hero section — title, subtitle, image]           │
│                                                    │
│  [senderNote — bordered callout if present]        │
│                                                    │
│  [Text sections — rendered Markdown]               │
│                                                    │
│  [Items table]                                     │
│  ┌──────────────┬──────┬──────────┬───────────┐   │
│  │ Item         │ Qty  │ Unit     │ Subtotal  │   │
│  └──────────────┴──────┴──────────┴───────────┘   │
│                                                    │
│  [Pricing summary — subtotal, discount, VAT, total]│
│                                                    │
│  [CTA section — note text if set]                  │
│                                                    │
│  [Accept button]      [Decline button]             │
│                                                    │
│  Expires: {date}  ·  {view_count} view(s)          │
└────────────────────────────────────────────────────┘
```

- RTL-aware layout (same logical CSS conventions as zync-www)
- Fully responsive (mobile-first)
- Accept/Decline buttons match `primaryColor`; Decline is text link (not filled button)

### Page States

| State | Condition | UI |
|-------|-----------|-----|
| **Loading** | Awaiting API response | Skeleton loader for content sections |
| **Active** | `status` is `SENT` or `VIEWED` and `expires_at` is null or future | Full content + Accept + Decline CTAs |
| **Accepted** | `status === 'ACCEPTED'` | Content visible (read-only); green banner "Proposal accepted on {acceptedAt}" |
| **Declined** | `status === 'REJECTED'` | Content visible (read-only); muted banner "You declined this proposal" |
| **Expired** | `expires_at < now()` and `status !== 'ACCEPTED'` | Content visible (read-only); amber banner "This proposal expired on {date}" |
| **Not found** | 404 from API | "This link is invalid or has been removed." — no tenant branding (token invalid → no tenant context) |

Expired proposals: CTAs hidden. Previously accepted/rejected proposals still show content but CTAs hidden.

### Accept flow

1. Recipient clicks "Accept"
2. Optional: modal asking for recipient's name if `proposals.content.recipientName` is null
3. `POST /api/proposals/{token}/accept` with `{ name? }`
4. On success: page transitions to **Accepted** state without reload
5. Tenant staff receives in-app notification (`proposal.accepted` type) + email via Resend ("[name] accepted proposal: [name]")
6. `proposals.status` → `ACCEPTED`, `proposals.accepted_at` = now(), `proposals.accepted_by_name` = `{name}` (from the modal, or `content.recipientName` when present)
7. Outbound webhook: `proposal.accepted` event (spec 27)
8. Funnel step 3 AE event: `proposal_accepted` with `{ tenantId, proposalId, leadId?, catalogShareId?, utmSource, utmMedium, utmCampaign }`. `catalogShareId` is always null for proposals sent directly (not via catalog share link); funnel correlation uses UTM fields in that case.

**Acceptance is non-binding acknowledgment only.** No automatic charge, contract, or invoice creation. Staff follows up manually.

### Decline flow

1. Recipient clicks "Decline"
2. Modal: "Let us know why (optional)" — single textarea
3. `POST /api/proposals/{token}/reject` with `{ reason? }`
4. On success: page transitions to **Declined** state
5. Tenant staff receives in-app notification + email ("Proposal declined by [name]")

### View event logging (server-side)

On every `GET /api/proposals/{token}/public`:
1. Insert `proposal_view_events` record (ip, user_agent, referrer, UTM from query string)
2. `proposals.view_count` += 1, `proposals.last_viewed_at` = now()
3. If `proposals.first_viewed_at` is null: set it + emit `proposal.viewed` outbound webhook + in-app notification to proposal creator
4. AE event: `proposal_view` with `{ tenantId, proposalId, leadId?, catalogShareId?, utmSource, utmMedium, utmCampaign }`
5. `proposals.status` → `VIEWED` if currently `SENT`

UTM params extracted from query string if present (campaign link tracking).

---

## Authenticated Portal View (`/portal/{tenantSlug}/proposals`)

Same content rendered inside the portal shell (portal auth session required). Spec 30 (tenant-portals) owns the portal shell. This spec owns the content rendering.

Portal proposal list: all proposals for the authenticated customer (`customer_id` match). Shows proposal name, status, sent date, expiry, action link to `/p/{token}`.

---

## API Endpoints

```
GET  /api/proposals/:token/public      → proposal content + branding + status (no auth)
                                          returns: {
                                            proposal: { name, content, status, expiresAt, viewCount,
                                                        acceptedAt, rejectedAt, sentAt },
                                            tenantBranding: { logoR2Key, primaryColor, tenantName, customDomain }
                                          }
                                          Side effects: logs view event (see above)

POST /api/proposals/:token/accept       → accept proposal (no auth)
                                          body: { name? }
                                          Returns 409 if already accepted/rejected/expired

POST /api/proposals/:token/reject       → decline proposal (no auth)
                                          body: { reason? }
                                          Returns 409 if already accepted/rejected/expired
```

All three endpoints are public (no auth middleware). Token uniquely identifies the proposal across all tenants.

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

---

## Notifications

| Trigger | Recipient | Channel | Message |
|---------|-----------|---------|---------|
| `first_viewed_at` set | Proposal creator | In-app + email | "[Recipient name] viewed proposal: [Proposal name]" |
| Accepted | Proposal creator | In-app + email | "[Recipient name] accepted proposal: [Proposal name]" |
| Declined | Proposal creator | In-app + email | "[Recipient name] declined proposal: [Proposal name]" |

In-app notification types added to the notification registry: `proposal.viewed`, `proposal.accepted`, `proposal.rejected`. These are in-app + email notification events — distinct from the outbound webhook catalog (spec 27) which also lists `proposal.accepted` as an outbound event. `proposal.rejected` is new to both registries.

---

## Foundation Deltas

**New rate limiter binding:** `RATE_LIMITER_PROPOSAL` — CF native RateLimiter, 10 req/min per IP per token. Add to wrangler.toml following pattern of `RATE_LIMITER_EXPENSE_UPLOAD`.

**New zync-www route:** `apps/zync-www/src/pages/p/[token].astro` — requires `output: 'hybrid'` in `astro.config.mjs` (same change needed by spec 22's form page).

---

## Locale & Direction

The proposal public page is viewed by the tenant's customers, not Zync users. The page locale must match the proposal's language, not the viewer's browser preference:

```ts
// apps/zync-www/src/pages/p/[token].astro
const { proposal, tenantBranding } = await fetchProposalPublic(token)
const locale = proposal.locale ?? tenantBranding.defaultLocale ?? 'he'
const dir = locale === 'he' ? 'rtl' : 'ltr'
```

The `<html>` element must set both `dir` and `lang` server-side (Astro SSR):

```astro
<html dir={dir} lang={locale}>
```

**Proposal locale field:** `proposals` table requires `locale TEXT DEFAULT 'he'`. At proposal creation time, locale defaults to tenant's locale setting but can be overridden per proposal (future: multi-language proposal support). Add column:

```sql
ALTER TABLE proposals ADD COLUMN locale TEXT DEFAULT 'he' CHECK (locale IN ('he', 'en'));
```

**Numbers and dates on the page:** use `formatCurrency` and `formatDate` from `@zync/ui` with the proposal locale. Hebrew proposals display `₪1,500` right-aligned; English proposals display `ILS 1,500` or `₪1,500` left-aligned.

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Hosted in zync-www (Astro) | Not app.zync.is | Public no-auth page; SEO/perf benefits; consistent with `/f/{slug}` and `/c/{token}` pattern |
| Server-side fetch for proposal content | Astro SSR with `export const prerender = false` | Token not in body — must be resolved server-side to apply correct tenant branding without flash |
| Accept/Reject are idempotent-safe (409 on re-attempt) | Return 409 with current status | Prevents double-accept from network retry; client shows correct state on conflict |
| Acceptance is non-binding | No downstream automation | Spec 23 decision; post-acceptance workflow is manual (staff converts to invoice/contract) |
| View events logged server-side | `GET /api/proposals/:token/public` side effect | Prevents JS-blocked clients from missing view events; simpler than a separate beacon endpoint |
| ProposalContent JSONB shape canonical here | Defined in this spec | Template builder (spec 23) and this renderer must share one type definition in `@zync/types` |
