# Lead → Customer Conversion UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-lead-to-customer-conversion.md  ·  **Slug:** lead-to-customer-conversion  ·  **Wave:** 8
**Depends on:** customers-module, foundation-auth-rbac, marketing-leads-pipeline, projects-module

## Goal
Deliver the UI flow that converts a won lead into a customer (and, optionally, a project) in a single modal. The base `POST /api/leads/:id/convert` endpoint and the `leads` / `lead_activities` tables are owned upstream by `marketing-leads-pipeline`; this spec owns the conversion modal, the trigger CTAs in the lead detail panel, the post-conversion state changes (CTA swap, kanban "Converted" badge, activity entries), and a server-side delta that adds the email-conflict ("link to existing customer") path that the base endpoint does not cover. It introduces **no new tables**.

## Architecture
The conversion modal is a new component (`ConvertLeadModal`) in the `zync-app` marketing feature, opened from the upstream lead detail panel (`marketing-leads-pipeline` `§ Lead Detail Side-Panel`). It pre-fills customer fields from the lead (`leads.name`, `leads.email`, `leads.phone`, `leads.company`) and submits to the existing `POST /api/leads/:id/convert` route. A `useConvertLead` TanStack Query mutation hook wraps the call and, on success, invalidates the leads list / detail queries so the kanban card and panel re-render.

Server-side, the convert route already (per `marketing-leads-pipeline`) creates a customer, sets `leads.customer_id` + `stage='WON'`, optionally creates a project, writes a `lead_activities` row with `type='converted'`, and emits the `lead.converted` outbound webhook. This plan **extends** that route to:
- Accept the concrete request body from spec 75 (`{ customer: {...}, project?: {...} }`).
- Detect an existing customer with the same email and return `409 { existingCustomerId }` instead of creating a duplicate.
- Honor a `linkExistingCustomerId` flag that links the lead to that existing customer (sets `leads.customer_id`) without creating a new customer record.

Upstream tables/exports consumed (by exact name): `leads`, `lead_activities` (marketing-leads-pipeline); `customers`, `createCustomer`, `createCustomerSchema`, `serializeCustomer`, `Customer`, `CustomerObject` (customers-module / locked sheet); `projects` table + `POST /api/projects` (projects-module); `tenantQuery`, `createDb`, `Db`, `authMiddleware`, `requirePermission`, `buildPaginated` (foundation). UI primitives: `Dialog`, `Form`, `FormField`, `FormLabel`, `FormError`, `Input`, `Select`, `Checkbox`, `Button`, `Badge`, `toast` (foundation-design-system / locked sheet).

## Tech Stack
- **App:** `apps/zync-app` (Vite + React, Hono API routes under the same worker).
- **API:** Hono route handler extending `apps/zync-app/src/api/routes/leads.ts` (owned by marketing-leads-pipeline); Drizzle ORM against Neon Postgres via Hyperdrive.
- **Validation:** Zod (`require-zod-validation-in-routes`).
- **Data fetching:** TanStack Query (mutation + invalidation).
- **UI:** `@zync/ui` primitives; Tailwind preset tokens only (no hardcoded colors/spacing/radius).
- **Bindings:** Hyperdrive (`DB`) for Postgres; no new bindings.
- **i18n:** `@zync/config` translations + `useDirection` for RTL.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 8a | 1 (server convert-endpoint delta), 2 (zod request schema) | `apps/zync-app/src/api/routes/leads.ts`, `apps/zync-app/src/api/schemas/lead-convert.ts` | Task 2 before Task 1 |
| 8b | 3 (convert mutation hook), 4 (ConvertLeadModal component) | `apps/zync-app/src/features/marketing/hooks/use-convert-lead.ts`, `.../ConvertLeadModal.tsx` | Task 3 before Task 4 |
| 8c | 5 (lead-detail CTA wiring), 6 (kanban Converted badge + post-conversion state) | `.../LeadDetailPanel.tsx`, `.../LeadCard.tsx` (upstream, modified) | Parallel after 8b |
| 8d | 7 (i18n strings), 8 (a11y + reduced-motion), 9 (tests) | translations, modal, test files | Parallel after 8c |

## Tasks

### Task 1: Extend `POST /api/leads/:id/convert` with concrete body + email-conflict path
**Blocks:** 3  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-app/src/api/routes/leads.ts`
**Steps:**
- [ ] Replace the convert handler's body parse with the zod schema from Task 2 (`convertLeadSchema`), rejecting invalid input with 422.
- [ ] Gate the route with `requirePermission('marketing:write')` AND `requirePermission('customers:write')` (both required per marketing-leads-pipeline permissions table).
- [ ] Load the lead via `tenantQuery` scoped to the request tenant; 404 if not found; if `leads.customer_id` is already set, return `409 { error: 'already_converted', customerId: <existing> }`.
- [ ] If `body.linkExistingCustomerId` is provided: verify that customer exists in-tenant, then in a single transaction set `leads.customer_id = linkExistingCustomerId`, set `stage='WON'` if not already, insert a `lead_activities` row (`type='converted'`, `user_id` = current user, `metadata = { customerId, linked: true }`), write the audit-log entry **inside the same transaction** (`require-audit-in-transaction`), and return `{ customerId: linkExistingCustomerId }`. Do NOT create a new customer.
- [ ] Otherwise (normal convert): begin a transaction. Check for an existing in-tenant customer whose `email` equals `body.customer.email` (case-insensitive, only when email non-empty); if found, ROLLBACK and return `409 { error: 'email_conflict', existingCustomerId }`.
- [ ] In the transaction, create the customer via `createCustomer` using `createCustomerSchema`-validated fields mapped from `body.customer` (`name`, `email`, `phone`, `company`).
- [ ] Set `leads.customer_id = newCustomer.id`, and `leads.stage = 'WON'` if not already WON, in the same transaction.
- [ ] If `body.project` present: create a project with `customer_id = newCustomer.id`, `name = body.project.name`, `billing_type = body.project.billingType` (map camelCase→snake_case), `start_date = body.project.startDate`, `status = 'active'`, scoped to tenant (reuse the projects-module create path / `POST /api/projects` logic).
- [ ] Insert `lead_activities` (`type='converted'`, `user_id` = current user, `metadata = { customerId: newCustomer.id, projectId? }`).
- [ ] Write the audit-log entry inside the transaction, then COMMIT.
- [ ] After commit, emit the `lead.converted` outbound webhook (`{ leadId, customerId, projectId, tenantId }`).
- [ ] Return `{ customerId: newCustomer.id, projectId? }`.
**Schema / Interfaces:**
```ts
// Response shapes
type ConvertSuccess = { customerId: string; projectId?: string };
type ConvertEmailConflict = { error: 'email_conflict'; existingCustomerId: string };
type ConvertAlreadyConverted = { error: 'already_converted'; customerId: string };
// Upstream tables referenced (NOT created here):
//   leads(id UUID PK, tenant_id UUID, name TEXT, email TEXT, phone TEXT, company TEXT,
//         stage TEXT CHECK (stage IN ('NEW','CONTACTED','QUALIFIED','PROPOSAL','WON','LOST')),
//         customer_id UUID REFERENCES customers(id), ...)            -- marketing-leads-pipeline
//   lead_activities(id UUID PK, tenant_id UUID, lead_id UUID REFERENCES leads(id),
//         user_id UUID REFERENCES users(id),
//         type TEXT CHECK (type IN ('note','email_sent','call_logged','stage_changed',
//             'form_submitted','webhook_received','converted','contract_linked')),
//         content TEXT, metadata JSONB, created_at TIMESTAMPTZ DEFAULT now())  -- marketing-leads-pipeline
//   customers(id UUID PK, tenant_id UUID, name TEXT NOT NULL, company TEXT, email TEXT,
//         phone TEXT, ...)                                            -- customers-module
//   projects(id UUID PK, tenant_id UUID, customer_id UUID, name TEXT NOT NULL,
//         billing_type TEXT CHECK (billing_type IN ('fixed','hourly','retainer')),
//         status TEXT, start_date DATE, ...)                          -- projects-module
```
**Acceptance:**
- [ ] Converting a WON lead with no email conflict creates a customer, sets `leads.customer_id` + `stage='WON'`, writes a `converted` activity, and returns `{ customerId }`.
- [ ] Supplying `project` also creates a project with `customer_id` = new customer and returns `projectId`.
- [ ] A duplicate email returns `409 { error:'email_conflict', existingCustomerId }` and creates NO customer (transaction rolled back).
- [ ] Re-converting an already-converted lead returns `409 { error:'already_converted', customerId }`.
- [ ] `linkExistingCustomerId` links the lead to the existing customer without creating a duplicate.
- [ ] Audit log row exists for every successful conversion, written in the same transaction.
- [ ] Route rejects callers lacking either `marketing:write` or `customers:write` with 403.

### Task 2: Zod request schema for convert
**Blocks:** 1, 3  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/api/schemas/lead-convert.ts`
**Steps:**
- [ ] Define `convertLeadSchema` matching spec 75's concrete body, with optional `project` and optional `linkExistingCustomerId`.
- [ ] Validate `customer.name` required, `customer.email` optional email, `customer.phone`/`customer.company` optional strings.
- [ ] Validate `project.name` required, `project.billingType` enum `('fixed'|'hourly'|'retainer')`, `project.startDate` as ISO date string when project present.
- [ ] Export the inferred TS type `ConvertLeadInput`.
**Schema / Interfaces:**
```ts
import { z } from 'zod';

export const convertLeadSchema = z.object({
  customer: z.object({
    name: z.string().min(1),
    email: z.string().email().optional().or(z.literal('')),
    phone: z.string().optional(),
    company: z.string().optional(),
  }),
  project: z
    .object({
      name: z.string().min(1),
      billingType: z.enum(['fixed', 'hourly', 'retainer']),
      startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
    })
    .optional(),
  linkExistingCustomerId: z.string().uuid().optional(),
});

export type ConvertLeadInput = z.infer<typeof convertLeadSchema>;
```
**Acceptance:**
- [ ] Valid bodies (with and without `project`, with and without `linkExistingCustomerId`) parse successfully.
- [ ] Missing `customer.name` or invalid `billingType` fails validation.

### Task 3: `useConvertLead` mutation hook
**Blocks:** 4  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-app/src/features/marketing/hooks/use-convert-lead.ts`
**Steps:**
- [ ] Implement a TanStack Query `useMutation` that POSTs `ConvertLeadInput` to `/api/leads/:id/convert`.
- [ ] Parse the success response `{ customerId, projectId? }`; on `409` parse `{ error, existingCustomerId | customerId }` and surface it to the caller (return a typed error rather than a generic throw) so the modal can render the "link instead" prompt.
- [ ] On success, invalidate query keys `['leads']` (list), `['lead', leadId]` (detail), and `['lead-activities', leadId]` so the panel/kanban refresh.
- [ ] Fire a success `toast` ("Lead converted to customer").
**Schema / Interfaces:**
```ts
type ConvertResult =
  | { ok: true; customerId: string; projectId?: string }
  | { ok: false; conflict: 'email_conflict'; existingCustomerId: string }
  | { ok: false; conflict: 'already_converted'; customerId: string };

export function useConvertLead(leadId: string): {
  mutateAsync: (input: ConvertLeadInput) => Promise<ConvertResult>;
  isPending: boolean;
};
```
**Acceptance:**
- [ ] Hook returns `{ ok: true, customerId }` on success and invalidates the three query keys.
- [ ] A 409 resolves to `{ ok: false, conflict, ... }` (not an unhandled throw).

### Task 4: `ConvertLeadModal` component
**Blocks:** 5  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/features/marketing/components/ConvertLeadModal.tsx`
**Steps:**
- [ ] Build a `Dialog`-based modal titled "Convert Lead to Customer" showing the lead summary line ("Lead: {company ?? name} (contacted: {primary contact})").
- [ ] Render the **Customer record** section with `FormField`s: Customer name (required), Email, Phone, Company — pre-filled from `leads.name`, `leads.email`, `leads.phone`, `leads.company`. Customer name defaults to `leads.company || leads.name`.
- [ ] Render a `Checkbox` "Create a project for this customer"; when checked, expand a **Project** section: Project name (required, default `"{customer name} — Project"`), Type (`Select`: Fixed price=`fixed` / Hourly=`hourly` / Retainer=`retainer`), Start date (date input, default today).
- [ ] Footer: `Cancel` and `Convert →` buttons. Disable `Convert →` while `isPending` and when required fields are empty.
- [ ] On submit, call `useConvertLead(...).mutateAsync` mapping form state to `ConvertLeadInput` (`billingType`, `startDate` camelCase).
- [ ] **Email-conflict path:** when the mutation resolves `{ ok:false, conflict:'email_conflict', existingCustomerId }`, replace the form body with the message "A customer with this email already exists. Link this lead to them instead?" plus a `Link to existing customer` confirm button. Confirm re-submits with `{ linkExistingCustomerId: existingCustomerId }` (no customer fields).
- [ ] **Non-WON warning:** if the lead's stage is not `WON`, show an inline warning "Converting will mark this lead as Won." above the footer (the convert server call advances the stage).
- [ ] On success, call `onConverted({ customerId, projectId? })` (provided by parent) and close the modal.
**Schema / Interfaces:**
```ts
export interface ConvertLeadModalProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  lead: { id: string; name: string; email?: string; phone?: string; company?: string; stage: string };
  primaryContactName?: string;
  onConverted: (result: { customerId: string; projectId?: string }) => void;
}
export function ConvertLeadModal(props: ConvertLeadModalProps): JSX.Element;
```
**Acceptance:**
- [ ] Opening the modal pre-fills all four customer fields from the lead.
- [ ] Checking "Create a project" reveals name/type/start-date fields; unchecking omits `project` from the payload.
- [ ] A 409 email conflict swaps the body to the "link instead" prompt and a confirm there links the lead.
- [ ] Non-WON leads show the "will mark as Won" warning.
- [ ] Successful convert closes the modal and calls `onConverted`.

### Task 5: Wire CTAs into the lead detail panel
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-app/src/features/marketing/components/LeadDetailPanel.tsx` (owned by marketing-leads-pipeline)
**Steps:**
- [ ] When `leads.stage === 'WON'` and `leads.customer_id` is null: render primary CTA `Convert to Customer` that opens `ConvertLeadModal`.
- [ ] When stage is NOT WON and `customer_id` null: render secondary link "Convert (mark as Won first)" with a tooltip; clicking it opens the modal (server convert advances stage to WON — the modal shows the warning).
- [ ] When `leads.customer_id` is set: replace the CTA with a `View Customer →` link to `/customers/{customer_id}` (no modal).
- [ ] Pass `onConverted` that navigates/refreshes so the panel reflects the converted state, and append an activity-feed entry "Converted to customer: {name}" (link to `/customers/{customerId}`); if a project was created also show "Project created: {projectName}" linking to `/projects/{projectId}` (data comes from the refreshed `lead_activities` feed).
**Acceptance:**
- [ ] WON + unconverted lead shows the primary "Convert to Customer" CTA.
- [ ] Non-WON unconverted lead shows the secondary "Convert (mark as Won first)" link with tooltip.
- [ ] Converted lead shows "View Customer →" linking to the correct customer.
- [ ] Activity feed shows the conversion (and project, if created) entries with working links.

### Task 6: Kanban "Converted" badge + post-conversion card state
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-app/src/features/marketing/components/LeadCard.tsx` (owned by marketing-leads-pipeline)
**Steps:**
- [ ] When a lead in the WON column has `customer_id` set, render a `Badge` "✓ Converted" on the card.
- [ ] Ensure the badge uses design-system token colors (no hardcoded color) and is announced to assistive tech via accessible text ("Converted").
- [ ] Confirm the card re-renders after conversion via the `['leads']` query invalidation from `useConvertLead`.
**Acceptance:**
- [ ] A converted WON lead card shows the "✓ Converted" badge.
- [ ] Non-converted WON leads do not show the badge.

### Task 7: i18n strings (English + Hebrew)
**Blocks:** —  ·  **Blocked by:** 4, 5, 6
**Files:**
- Modify: `packages/config/src/translations/en.ts`
- Modify: `packages/config/src/translations/he.ts`
**Steps:**
- [ ] Add keys for: modal title, section headers, field labels (Customer name, Email, Phone, Company, Project name, Type, Start date), checkbox label, Convert/Cancel buttons, email-conflict message + link button, non-WON warning, "Converted" badge, "View Customer →", activity strings ("Converted to customer: {name}", "Project created: {name}").
- [ ] Provide Hebrew translations for every key.
- [ ] Reference all UI copy through `translations` (no hardcoded literals in components).
**Acceptance:**
- [ ] All modal/CTA/badge copy resolves through the translation layer in both `en` and `he`.

### Task 8: Accessibility, RTL, and reduced-motion
**Blocks:** —  ·  **Blocked by:** 4, 5, 6
**Files:**
- Modify: `apps/zync-app/src/features/marketing/components/ConvertLeadModal.tsx`
**Steps:**
- [ ] Ensure the `Dialog` exposes `role="dialog"` + `aria-modal="true"`, traps focus while open, returns focus to the trigger on close, and closes on Escape.
- [ ] Associate every input with its `FormLabel` (`htmlFor`/`id`); mark required fields with `aria-required`; expose validation errors via `FormError` with `aria-describedby`.
- [ ] Verify layout mirrors correctly under RTL using `useDirection` (logical spacing/tokens, no left/right hardcoding).
- [ ] Respect `prefers-reduced-motion`: disable the modal open/close transition when set.
**Acceptance:**
- [ ] Keyboard-only users can open, fill, submit, and dismiss the modal; focus is trapped and restored.
- [ ] Modal renders correctly mirrored in Hebrew/RTL.
- [ ] No modal animation plays when `prefers-reduced-motion: reduce` is active.

### Task 9: Tests
**Blocks:** —  ·  **Blocked by:** 1, 3, 4
**Files:**
- Create: `apps/zync-app/src/api/routes/__tests__/lead-convert.test.ts`
- Create: `apps/zync-app/src/features/marketing/components/__tests__/ConvertLeadModal.test.tsx`
**Steps:**
- [ ] API: assert successful convert (customer + activity + `customer_id`/`stage` set), project creation path, `email_conflict` 409 (no customer created), `already_converted` 409, `linkExistingCustomerId` link path, and permission rejection (403 without `marketing:write`/`customers:write`).
- [ ] Component: assert pre-fill from lead, project-section expand/collapse, conflict→link prompt swap, and non-WON warning render.
**Acceptance:**
- [ ] All API and component tests pass under the workspace test runner.
