# Proposal Editor

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 130  
**Tier:** All tiers  
**Depends on:** `marketing-catalogs-campaigns`, `customers-module`, `product-service-library`, `foundation-auth-rbac`  
**Referenced by:** `marketing-catalogs-campaigns`, `public-proposal-view`, `proposal-to-contract`, `proposal-to-invoice-direct`, `proposal-pdf-export`, `proposals-list`

---

## Overview

Spec 23 (`marketing-catalogs-campaigns`) defines the `proposals` table with `content JSONB` and the send/accept flow, but has no UI for building proposal content. Spec 51 (`public-proposal-view`) renders the accepted result. This spec defines the proposal editor: the create/edit UI that populates `proposals.content`.

---

## Routes

```
/proposals/new         → create new proposal (blank or from template)
/proposals/:id/edit    → edit draft proposal
/proposals/:id         → view/manage existing proposal (redirect to edit if DRAFT)
```

`/proposals/:id` is the **single canonical proposal-detail route**, owned by this spec. Other specs that act on a proposal after it leaves DRAFT (`proposal-to-contract` spec 78, `proposal-to-invoice-direct` spec 107, `proposal-pdf-export` spec 159) attach their actions to this route — they do **not** define a parallel `/marketing/proposals/:id` surface.

---

## Detail View (non-DRAFT state)

When `/proposals/:id` is opened for a non-DRAFT proposal (SENT / VIEWED / ACCEPTED / REJECTED / EXPIRED), it renders a read-only detail view (the editor is frozen post-send per the snapshot rule) with a status header, the rendered proposal preview, and an action bar. This is the screen the funnel hand-off specs depend on.

```
┌──────────────────────────────────────────────────────────────┐
│  ← Proposals                                                 │
│  Website Redesign — Acme Corp                                │
│  Status: ✓ ACCEPTED — 2026-05-28 by Dana Katz                │
│  Value: ₪18,000   ·   Valid until 2026-06-30                 │
│                                                              │
│  [Export PDF ↓]   [Create Contract →]   [Create Invoice →]   │
│                                                              │
│  [Details]  [Timeline]  [Contract]                          │
│  ── rendered proposal preview ──────────────────────────     │
└──────────────────────────────────────────────────────────────┘
```

**Action bar (state-dependent):**

| Action | Visible when | Behaviour | Owner |
|--------|--------------|-----------|-------|
| `[Export PDF ↓]` | any non-DRAFT state | `GET /api/proposals/:id/pdf` → download | spec 159 `proposal-pdf-export` |
| `[Mark accepted]` | SENT / VIEWED | staff accepts on behalf of customer (confirmation); status → ACCEPTED | spec 156 `proposals-list` quick action, surfaced here too |
| `[Create Contract →]` | ACCEPTED, no linked contract | → `/contracts/new?proposal_id={id}` | spec 78 `proposal-to-contract` |
| `[Create Invoice →]` | ACCEPTED, no `invoices.proposal_id` | → invoice-from-proposal modal | spec 107 `proposal-to-invoice-direct` |
| `[View Contract →]` | `proposals.contract_id` set | → linked contract | spec 78 |
| `[View Invoice →]` | `invoices.proposal_id` set | → linked invoice | spec 107 |

The `[Contract]` tab and the linked-invoice banner are defined by specs 78 / 107 respectively and render within this detail view.

---

## Proposal Editor Layout

Split-panel editor (same pattern as spec 128 form builder):

```
┌────────────────────────────────────────┬──────────────────────┐
│  Proposal: Website Redesign            │  Preview             │
│                                        │                      │
│  Customer: [Acme Corp ▾]               │  ╔══════════════════╗│
│  Subject:  [Website Redesign Proposal] │  ║  Acme Corp       ║│
│  Valid until: [2026-06-30__]           │  ║                  ║│
│                                        │  ║  Website Redesign║│
│  ── Sections ──────────────────────    │  ║  Proposal        ║│
│                                        │  ║                  ║│
│  [⠿] Introduction        [Edit] [✕]   │  ║  Dear Acme Corp, ║│
│  [⠿] Scope of Work       [Edit] [✕]   │  ║  ...             ║│
│  [⠿] Line Items          [Edit] [✕]   │  ║                  ║│
│  [⠿] Terms & Conditions  [Edit] [✕]   │  ║  Line Items      ║│
│  [⠿] Signature           [Edit] [✕]   │  ║  Design   ₪2,000 ║│
│                                        │  ║  Dev      ₪8,000 ║│
│  [+ Add section ▾]                     │  ║  Total   ₪10,000 ║│
│    ○ Text block                        │  ║                  ║│
│    ○ Line items table                  │  ╚══════════════════╝│
│    ○ Image                             │                      │
│    ○ Divider                           │                      │
│    ○ Testimonials                      │                      │
│    ○ Team members                      │                      │
│                                        │                      │
│  [Save draft]      [Preview] [Send →]  │                      │
└────────────────────────────────────────┴──────────────────────┘
```

Preview refreshes live on every edit. Preview renders the same React component as `/p/{token}` (spec 51).

---

## Section Types

### Text Block

Rich text editor (Tiptap, same instance as KB editor spec 101). Supports headings, lists, bold/italic. Merge tags: `{{customer_name}}`, `{{business_name}}`, `{{valid_until}}`, `{{total_amount}}`.

### Line Items Table

```
│  ── Line items ─────────────────────────────────────────    │
│                                                              │
│  Description           Qty   Unit     Amount   Tax          │
│  [Design mockups___]   [1_]  [₪2,000] [₪2,000] [17% ▾]    │
│  [Dev implementation]  [1_]  [₪8,000] [₪8,000] [17% ▾]    │
│  [+ Add line item]                                           │
│                                                              │
│  Subtotal    ₪10,000                                         │
│  VAT (17%)    ₪1,700                                         │
│  Total       ₪11,700                                         │
│                                                              │
│  ☑ Show per-line tax    ☑ Show subtotal row                  │
│  Discount: [0__] %                                           │
```

**[+ Add from library]** — opens product/service library picker (spec `product-service-library`). Adds item with library price pre-filled (editable).

### Image

Upload or URL. Alt text field. Alignment: left/center/right.

### Divider

Horizontal rule with optional label.

### Testimonials

Up to 3 text blocks with author name + company.

### Team Members

Cards for team members with name, role, photo. Pulls from `users` table; editable.

---

## Content JSONB Schema

```ts
// proposals.content shape
type ProposalContent = {
  sections: ProposalSection[];
  settings: {
    show_line_tax: boolean;
    show_subtotal: boolean;
    discount_pct: number;  // 0-100
    currency: string;      // ISO 4217, default from tenants.default_currency
  };
};

type ProposalSection =
  | { type: 'text';        id: string; html: string }
  | { type: 'line_items';  id: string; items: LineItem[] }
  | { type: 'image';       id: string; url: string; alt: string; align: 'left'|'center'|'right' }
  | { type: 'divider';     id: string; label?: string }
  | { type: 'testimonials';id: string; items: { quote: string; author: string; company: string }[] }
  | { type: 'team';        id: string; members: { user_id: string; role_label: string }[] };

type LineItem = {
  id: string;
  description: string;
  quantity: number;
  unit_price: number;
  tax_pct: number;      // 0 | 17 | other
  product_id?: string;  // null for freeform items
};
```

Computed `total_amount` = sum of line item amounts with tax, minus discount. Stored on `proposals.total_amount` on save.

---

## Send Modal

**[Send →]** → slide-in send modal:

```
┌──────────────────────────────────────────────────────────────┐
│  Send proposal to Acme Corp                                  │
│                                                              │
│  To:     dana@acme.com  [+ Add]                              │
│  Subject:[Website Redesign Proposal — Zync________________]  │
│  Message:[Hi Dana,                                           │
│           Please review the proposal for your website        │
│           redesign. Accept or request changes at:            │
│           {{proposal_link}}                                  │
│           Valid until 2026-06-30.                            │
│           {business_name}__________________________]         │
│                                                              │
│  Valid until: [2026-06-30]                                   │
│                                                              │
│  [Cancel]            [Send proposal]                         │
└──────────────────────────────────────────────────────────────┘
```

On send:
1. Snapshot `proposals.content` (freeze JSONB at send time)
2. Status → `SENT`
3. `proposals.public_token` already populated at row creation (random URL-safe token, spec 23 schema). Use existing value.
4. Send email via Resend with `{{proposal_link}}` = `zync.is/p/{public_token}`
5. Set `proposals.expires_at` = valid-until date (spec 96)

---

## Proposal Templates

**[+ New proposal]** → modal: "Start from scratch" or pick a template:

```
┌──────────────────────────────────────────────────────────────┐
│  New proposal                                                │
│                                                              │
│  ○ Start from scratch                                        │
│  ○ Web design template                                       │
│  ○ Software development template                             │
│  ○ Consulting retainer template                              │
└──────────────────────────────────────────────────────────────┘
```

Templates are pre-defined `content JSONB` snapshots stored in the `proposal_templates` table (DDL defined once in the **Schema Delta** section below). A row with `tenant_id IS NULL` is a system template; a non-null `tenant_id` is a tenant-custom template.

System templates: web design, software, consulting retainer, brand identity. Tenants can save any proposal as a template.

---

## Schema Delta

```sql
ALTER TABLE proposals ADD COLUMN total_amount NUMERIC(10,2);
-- Computed from content line items on save; denormalized for list display.

CREATE TABLE proposal_templates (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  content JSONB NOT NULL,
  created_by UUID REFERENCES users(id),
  created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_proposal_templates_tenant ON proposal_templates(tenant_id);
```

---

## API

```
GET /api/proposals
    → list proposals (paginated)
      query: { status?, customer_id?, page? }
      Requires: marketing:read

POST /api/proposals
     → create proposal (DRAFT)
       body: { customer_id, subject, content, expires_at? }
       Requires: marketing:write

GET /api/proposals/:id
    → get proposal detail + content JSONB
      Requires: marketing:read

PATCH /api/proposals/:id
      → update proposal (DRAFT only; SENT proposals are read-only)
        body: { subject?, content?, expires_at? }
        Requires: marketing:write

POST /api/proposals/:id/send
     → freeze content snapshot, set SENT, send email
       body: { to: string[], subject?: string, message?: string }
       Requires: marketing:write

DELETE /api/proposals/:id
       → delete (DRAFT only)
         Requires: marketing:write

GET /api/proposal-templates
    → list system + tenant templates
      Requires: marketing:read

POST /api/proposal-templates
     → save proposal as template
       body: { proposal_id, name }
       Requires: marketing:write
```

---

## Editor Accessibility and RTL

### Accessibility

- Editor div: `role="textbox"` `aria-multiline="true"` `aria-label="Proposal text editor"`
- Toolbar: `role="toolbar"` `aria-label="Text formatting"`
- Toolbar buttons: `aria-pressed` for toggle states (Bold, Italic, Underline, etc.)
- Keyboard: full formatting via `⌘B`/`⌘I`/`⌘U`; shortcuts must not be overridden
- Focus: Tab enters editor; Escape exits to last focused element outside editor
- Image insertion trigger: `aria-label="Insert image"`; alt text is a required field in the insert modal

### RTL Configuration

```ts
import { Direction } from '@tiptap/extension-text-direction'

const extensions = [
  // ...other extensions
  Direction.configure({
    defaultDirection: locale === 'he-IL' ? 'rtl' : 'ltr',
    // Per-paragraph direction override via toolbar toggle (↔ icon)
  }),
]
// Direction persists as dir attribute on paragraph nodes in stored JSONB
```

---

## Content Security

Proposal `text` sections contain Tiptap JSONB created by staff but rendered on the public proposal view (`/p/{token}`, spec 51). Follow the canonical Tiptap security pattern from `kb-article-editor` (spec 101):

**Server-side:** validate node types against `ALLOWED_NODE_TYPES` before saving to `proposals.content` or `proposal_templates.content`.

**Client-side:** `text` section bodies must go through DOMPurify before `dangerouslySetInnerHTML` — same `renderContractHTML` helper pattern as spec 48. The proposal renderer (`ProposalPublicView`) and the editor live-preview both use this path.

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Section-based JSONB | Not freeform HTML | Sections enable structured rendering, mobile responsiveness, and PDF generation without parsing HTML; each section type has a known renderer |
| Snapshot on send | Not live content | Customer must see the same proposal they accepted; content changes after send would be misleading |
| `total_amount` denormalized | Not computed on read | List views need sortable total without parsing content JSONB per row |
| Same preview component as public view | Not separate preview | Single source of truth for rendering; spec 51's renderer is tested on real accepted proposals |
| Templates as JSONB snapshots | Not "template engine" | Simplest approach; templates are just pre-filled content; no template DSL needed |
