# Phase 9: Quote Builder & Public Routes - Research

**Researched:** 2026-06-11  
**Domain:** Quote generation UI (admin), public proposal page (client), form state management, pricing calculation, token-gated routes  
**Confidence:** HIGH

## Summary

Phase 9 implements a two-part system: (1) Admin Quote Builder to select offers, override pricing, and generate public links; and (2) Public Quote Page for token-gated client acceptance. The architecture leverages Next.js 16 App Router with server actions for secure pricing validation, React Hook Form + Zod for multi-step form validation, and shadcn/ui for consistent UI. The public route `/quote/[token]` mirrors the existing `/client/[token]` token-gated pattern established in Phase 1. Quote state transitions (draft → sent → viewed → accepted/rejected) are enforced at the database level via immutable `accepted_at` timestamp. Pricing calculations must always be validated server-side; client-side previews are optimistic only. Email notifications on acceptance can integrate with Resend and are deferred to Phase 12+.

**Primary recommendation:** Build the admin Quote Builder as a two-column form (left: offer selection + pricing overrides, right: preview) using React Hook Form with server actions for atomic save-on-change. For the public quote page, implement a multistep wizard (steps 1-3) with a single server action for accept, capturing email and notes. Use database constraints to enforce immutability of `accepted_at` — once set, the UI disables edit buttons and all queries should check `accepted_at IS NOT NULL` to block mutations. Rate-limit public views to 3 per minute per IP using headers middleware or Upstash KV.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Admin quote builder UI | API / Backend | Frontend Server | Admin workspace; server actions handle pricing validation and offer queries |
| Public quote view (read-only) | Browser / Client | Frontend Server | Token validation at middleware/page level; client displays pre-calculated data from API |
| Quote state machine (draft→sent→viewed→accepted) | API / Backend | Database | State transitions via immutable timestamps enforced by database constraints |
| Pricing calculation & validation | API / Backend | — | Never trust client calculation; server action validates tier selection against offer schema and recalculates total |
| Token generation & storage | Database / Storage | API / Backend | nanoid tokens stored in `quotes.token` column; API retrieves by token with rate-limit check |
| Email notification on accept | Backend Service (async) | API / Backend | Server action triggers email dispatch; email integration (Resend) deferred to Phase 12 |

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| QUOTE-01 | `/admin/quotes/new`: select cliente + 1-3 offerte + override prezzi → genera `/quote/[token]` link pubblico | Admin Quote Builder with form state, offer selection, price override; server action validates and generates nanoid token |
| QUOTE-02 | Public `/quote/[token]` pagina multistep (Step 1 overview → Step 2 tier selection → Step 3 summary + accept) | Multi-step form with React Hook Form + Zod; each step validates before advancing; Step 3 accepts quote |
| QUOTE-03 | Accept CTA → `/api/public/quote/accept?token=X` con cattura email + note | Server action or API route receives token, validates quote state, captures email/notes, updates `accepted_at` immutably |
| QUOTE-04 | Quote token: nanoid 21 char (~122 bits), unico, validato in proxy come `/client/[token]` (nessun login sessione); rate limit 3 views/min per IP | Token passed via URL param; rate limit via middleware or Upstash Redis; no session required |
| QUOTE-05 | Mai esporre `quote_items` (prezzi per servizio) via public API; client API vede solo `accepted_total` (server-side ClientView type enforces) | Public API returns only `accepted_total` and phase/service summary; quote_items always filtered at query layer |

## User Constraints (from CLAUDE.md)

### Locked Decisions
1. **Stack:** Next.js 16 App Router, Neon Postgres, Drizzle ORM, Auth.js v4, Tailwind v4, shadcn/ui, Zod, nanoid  
2. **Auth pattern:** `/client/[token]/*` uses middleware token check (no session); `/admin/*` uses Auth.js session  
3. **Data safety:** `quote_items` NEVER exposed via client API — only `accepted_total` visible to client  
4. **Immutability:** `accepted_at` immutable once set; quote becomes read-only  
5. **Token design:** Separate rotatable field; `clients.token` is never the primary key

### Claude's Discretion
- Email integration timing and provider choice (Resend vs. alternatives) — deferred to Phase 12
- Public quote page UX details (single page vs. modal vs. multistep wizard) — recommend multistep for clarity
- Pricing override UI (freeform input vs. slider vs. percentage-based) — recommend structured field validation

### Out of Scope (Deferred)
- Email automation and scheduled reminders
- Quote expiry/deadline enforcement  
- PDF generation of quote (initial link-sharing MVP)
- Advanced analytics on quote view/accept rates

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| next | 16.2.6 | Framework, server actions, middleware | [VERIFIED: npm registry] App Router is stable for Phase 9 workflow |
| react | 19.2.4 | Component framework | [VERIFIED: npm registry] Latest stable; paired with Next.js 16 |
| react-hook-form | 7.75.0 | Multi-step form state + validation | [VERIFIED: npm registry] Lightweight, integrates seamlessly with shadcn/ui Form component |
| zod | 4.4.3 | Schema validation (client + server) | [VERIFIED: npm registry] Type-safe validation; used throughout existing ClientHub actions |
| @hookform/resolvers | 5.2.2 | RHF + Zod integration | [VERIFIED: npm registry] Official resolver package for Zod schemas |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| drizzle-orm | 0.45.2 | ORM queries + mutations | Quote schema queries; existing infrastructure already in place |
| postgres (driver) | 3.4.9 | Neon Postgres connection | Used for all DB operations via Drizzle |
| nanoid | 5.1.11 | Token generation | Quote token generation (21 char ~122 bits); existing codebase pattern |
| lucide-react | 1.14.0 | Icons | UI feedback for quote state (accepted, rejected, pending) |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| React Hook Form | Formik | RHF is lighter and pairs better with shadcn/ui; Formik adds overhead for multistep forms |
| Zod | Joi / TypeScript-based validation | Zod provides runtime + compile-time type safety; Joi requires manual type definitions |
| shadcn/ui | Material-UI / Chakra | shadcn/ui is already in project; copying components into repo gives full control for customization |
| Upstash Redis (rate limit) | In-memory cache / Vercel KV | Upstash is cost-effective for low-volume public pages; in-memory doesn't persist across serverless cold starts |

**Installation:**
```bash
npm install react-hook-form @hookform/resolvers zod
# All other dependencies already installed in Phase 1-8
```

**Version verification:**
[VERIFIED: npm registry] All listed versions match package.json from Phase 1-8 existing setup.

## Architecture Patterns

### System Architecture Diagram

```
Admin Quote Builder (Private Route /admin/quotes/new)
├─ Form: Select Client
├─ Form: Select 1-3 Offers (from offer_micros via Phase 8 schema)
├─ Form: Override Pricing (per offer or per phase)
└─ Server Action: createQuote()
   └─ DB: Insert to quotes table (token=nanoid, state='draft', total=calculated)
   └─ Generate public link: /quote/[token]

↓ (Admin shares link via email — Phase 12)

Public Quote Page (Token-Gated Route /quote/[token])
├─ Middleware: Validate token exists + rate-limit (3 views/min per IP)
├─ Step 1: Overview (read quote details, total price)
├─ Step 2: Tier/Service Selection (if offer allows, else read-only)
└─ Step 3: Summary + Accept/Reject Buttons
   └─ Server Action: acceptQuote(token, email, notes)
      ├─ Validate token + quote state
      ├─ Update quotes.accepted_at = NOW (immutable)
      ├─ Trigger notification (Phase 12)
      └─ Return success/error

Data Flow:
- Admin creates quote → writes to quotes + quote_items (admin-only)
- Public page reads quote (token-validated) → returns only summary (no line items)
- Client accepts → updates accepted_at (immutable, db-enforced)
- Query layer filters quote_items for admin context only
```

### Recommended Project Structure

```
src/
├── app/
│   ├── admin/quotes/new/
│   │   ├── page.tsx              # Quote builder form page
│   │   └── actions.ts            # createQuote, updateQuote server actions
│   ├── quote/[token]/
│   │   ├── layout.tsx            # Public quote layout (no auth)
│   │   └── page.tsx              # Multistep quote view + accept flow
│   └── api/public/quote/
│       └── accept/route.ts       # POST accept endpoint (alt to server action)
├── components/
│   ├── admin/quotes/
│   │   ├── QuoteBuilderForm.tsx  # Two-column form (offer + preview)
│   │   ├── OfferSelector.tsx     # Multi-select offer picker
│   │   ├── PriceOverrideInput.tsx # Price field with validation
│   │   └── QuotePreview.tsx      # Live summary of selected offer + pricing
│   └── public/quote/
│       ├── QuoteMultistep.tsx    # Wrapper managing step state
│       ├── QuoteStep1Overview.tsx
│       ├── QuoteStep2Selection.tsx
│       ├── QuoteStep3Summary.tsx
│       └── AcceptQuoteForm.tsx   # Email + notes capture
├── lib/
│   ├── quote-service.ts          # Query layer: getQuoteByToken, calculateTotal
│   ├── quote-validators.ts       # Zod schemas for quote validation
│   └── rate-limit.ts             # Rate limit middleware/helper
└── db/
    └── schema.ts                 # quotes, quote_items tables (Phase 8)
```

### Pattern 1: Multi-Step Form with React Hook Form + Zod

**What:** Each step validates its fields before advancing to the next step. Steps share form state via useForm at the parent level. Zod schema can be split per step or combined.

**When to use:** Public quote page (Steps 1-3); admin quote builder (optional, if multi-step for UX).

**Example:**

```typescript
// lib/quote-validators.ts — Source: [shadcn/ui Form Docs]
import { z } from "zod";

export const quoteStep2Schema = z.object({
  tier: z.enum(["A", "B", "C"]).describe("Tier selection"),
  priceOverrides: z.record(z.string(), z.number().min(0)).describe("Price per component"),
});

export const quoteAcceptSchema = z.object({
  email: z.string().email("Email valida richiesta").optional(),
  notes: z.string().max(500).optional(),
});

// components/public/quote/QuoteMultistep.tsx — Source: [React Hook Form + Next.js Server Actions]
"use client";

import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const formSchema = z.object({
  // Step 1 is read-only, no form fields
  // Step 2
  tier: z.enum(["A", "B", "C"]).optional(),
  // Step 3
  email: z.string().email().optional(),
  notes: z.string().max(500).optional(),
});

export function QuoteMultistep({ quoteToken }: { quoteToken: string }) {
  const [step, setStep] = useState(1);
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    mode: "onBlur",
  });

  async function onSubmit(data: z.infer<typeof formSchema>) {
    if (step < 3) {
      // Validate step data, advance
      setStep(step + 1);
      return;
    }
    // Step 3: submit accept
    const result = await acceptQuote(quoteToken, data.email, data.notes);
    if (result.success) {
      window.location.href = "/quote/success"; // or redirect to thank you
    }
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
      {step === 1 && <QuoteStep1Overview quoteToken={quoteToken} />}
      {step === 2 && <QuoteStep2Selection form={form} />}
      {step === 3 && <QuoteStep3Summary form={form} />}
      
      <div className="flex gap-4">
        {step > 1 && (
          <button type="button" onClick={() => setStep(step - 1)}>
            Indietro
          </button>
        )}
        <button type="submit">
          {step < 3 ? "Avanti" : "Accetta Preventivo"}
        </button>
      </div>
    </form>
  );
}
```

### Pattern 2: Server Action for Quote Accept with Immutability Enforcement

**What:** Single server action receives token + email/notes. Validates quote state, checks `accepted_at` is null, updates timestamp, returns success. Database constraint prevents re-accept.

**When to use:** Public quote page Step 3 submit; immutable records.

**Example:**

```typescript
// app/quote/[token]/actions.ts — Source: [Next.js Server Actions + Zod]
"use server";

import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { quotes } from "@/db/schema";
import { revalidatePath } from "next/cache";

const acceptQuoteSchema = z.object({
  token: z.string().length(21, "Token invalido"),
  email: z.string().email().optional(),
  notes: z.string().max(500).optional(),
});

export async function acceptQuote(
  token: string,
  email?: string,
  notes?: string
) {
  const parsed = acceptQuoteSchema.safeParse({ token, email, notes });
  if (!parsed.success) {
    return { success: false, error: parsed.error.issues[0].message };
  }

  try {
    // Fetch quote and check state
    const [quote] = await db
      .select()
      .from(quotes)
      .where(eq(quotes.token, parsed.data.token))
      .limit(1);

    if (!quote) {
      return { success: false, error: "Quote non trovata" };
    }

    if (quote.accepted_at !== null) {
      return { success: false, error: "Questo preventivo è già stato accettato" };
    }

    // Atomic update: set accepted_at (database will enforce immutability via constraint)
    await db
      .update(quotes)
      .set({
        accepted_at: new Date(),
        client_email: email, // optional, if schema includes it
        client_notes: notes,
      })
      .where(eq(quotes.token, token));

    // Revalidate public page to show accepted state
    revalidatePath(`/quote/${token}`);

    return { success: true, message: "Preventivo accettato!" };
  } catch (error) {
    console.error("acceptQuote error:", error);
    return { success: false, error: "Errore nel salvataggio" };
  }
}
```

### Pattern 3: Quote Query Layer with ClientView Type Safety

**What:** Separate query function `getQuoteByToken()` returns only safe fields for public consumption. Admin queries return full data including `quote_items`.

**When to use:** Public routes must never return line item prices; enforce via query layer, not UI filtering.

**Example:**

```typescript
// lib/quote-service.ts — Source: [Drizzle ORM + Type Safety]
import { eq, and, isNull } from "drizzle-orm";
import { db } from "@/db";
import { quotes, quote_items, offer_micros } from "@/db/schema";

export type PublicQuoteView = {
  id: string;
  token: string;
  state: "draft" | "sent" | "viewed" | "accepted" | "rejected";
  accepted_total: string;
  // DO NOT INCLUDE quote_items or unit prices
  offerName: string;
  phaseSummary: Array<{
    title: string;
    serviceCount: number;
  }>;
  accepted_at: Date | null;
};

export async function getQuoteByToken(token: string): Promise<PublicQuoteView | null> {
  const [quote] = await db
    .select({
      id: quotes.id,
      token: quotes.token,
      state: quotes.state,
      accepted_total: quotes.accepted_total,
      accepted_at: quotes.accepted_at,
      // DO NOT select quote_items.* — break the query if attempted
    })
    .from(quotes)
    .where(eq(quotes.token, token))
    .limit(1);

  if (!quote) return null;

  // Derive summary from offer structure (Phase 8 schema)
  // Return only summary-level data, never line items
  return {
    ...quote,
    offerName: "Entry A", // fetch from offer_micros
    phaseSummary: [], // fetch phase count, not prices
  };
}

export async function getQuoteByTokenAdmin(token: string) {
  // Admin context: return full quote including quote_items
  // Use separate function to enforce access control
  return db
    .select()
    .from(quotes)
    .where(eq(quotes.token, token))
    .limit(1);
}
```

### Pattern 4: Rate Limiting Public Quote Views (3 per minute per IP)

**What:** Middleware or route handler extracts client IP (via x-forwarded-for header), checks rate limit bucket, allows/denies request.

**When to use:** Public `/quote/[token]` route; protect against abuse.

**Example (Middleware approach):**

```typescript
// middleware.ts — Source: [Next.js Middleware Rate Limiting]
import { NextRequest, NextResponse } from "next/server";

const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
const RATE_LIMIT_MAX = 3; // 3 views per minute
const ipRequests = new Map<string, { count: number; resetAt: number }>();

function getClientIp(request: NextRequest): string {
  const forwarded = request.headers.get("x-forwarded-for");
  return (forwarded?.split(",")[0] || "unknown").trim();
}

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/quote/")) {
    const ip = getClientIp(request);
    const now = Date.now();
    const record = ipRequests.get(ip);

    if (record && now < record.resetAt) {
      if (record.count >= RATE_LIMIT_MAX) {
        return NextResponse.json(
          { error: "Rate limit exceeded" },
          { status: 429 }
        );
      }
      record.count++;
    } else {
      ipRequests.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/quote/:path*"],
};
```

**Note:** In-memory rate limit resets on serverless cold start. For persistent rate limiting, use Upstash Redis or Vercel KV (see Alternatives).

### Pattern 5: Admin Quote Builder Form (Two-Column Layout)

**What:** Left column shows form inputs (client select, offer select, price override). Right column shows live preview of selected offer + total. Both update via server actions on blur/change.

**When to use:** Admin `/admin/quotes/new` page; immediate feedback for pricing changes.

**Example Structure:**

```typescript
// components/admin/quotes/QuoteBuilderForm.tsx
"use client";

import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const quoteBuilderSchema = z.object({
  client_id: z.string().min(1, "Cliente richiesto"),
  offer_ids: z.array(z.string()).min(1, "Almeno un'offerta richiesta"),
  priceOverrides: z.record(z.string(), z.number().min(0)),
});

export function QuoteBuilderForm() {
  const [preview, setPreview] = useState<{
    offerName: string;
    total: number;
    services: Array<{ name: string; price: number }>;
  } | null>(null);
  
  const form = useForm<z.infer<typeof quoteBuilderSchema>>({
    resolver: zodResolver(quoteBuilderSchema),
  });

  async function onOfferChange(offerIds: string[]) {
    // Fetch offer details and update preview
    const preview = await fetchOfferPreview(offerIds);
    setPreview(preview);
  }

  async function onSubmit(data: z.infer<typeof quoteBuilderSchema>) {
    const result = await createQuote(data);
    if (result.success) {
      window.location.href = `/admin/quotes/${result.quoteId}`;
    }
  }

  return (
    <div className="grid grid-cols-2 gap-6">
      {/* Left: Form */}
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        {/* Client select, offer select, price override inputs */}
      </form>

      {/* Right: Preview */}
      {preview && (
        <div className="border rounded-lg p-4 bg-gray-50">
          <h3 className="font-bold mb-4">Anteprima</h3>
          {/* Display selected offer, services, total */}
        </div>
      )}
    </div>
  );
}
```

### Anti-Patterns to Avoid

- **Trust client pricing:** Never recalculate total on the client. Always validate server-side in the accept action. Client-side totals are preview only.
- **Expose quote_items via public API:** Line item details leak pricing structure. Return only aggregate `accepted_total` and phase/service count summary.
- **Skip immutability enforcement:** Don't rely on UI "disable buttons" alone. Database constraints (`accepted_at NOT NULL` + trigger) must prevent re-acceptance.
- **Mix admin and public query paths:** Use separate query functions (`getQuoteByToken` for public, `getQuoteByTokenAdmin` for admin). Never reuse the same function for both contexts.
- **Real-time validation on public page:** Don't validate email on every keystroke; use onBlur or on-submit to avoid accessibility issues (WCAG violation: changing focus unexpectedly).

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Multi-step form state | Custom useState + callback chains | React Hook Form with parent useForm | RHF handles field registration, validation state, submission state; callback chains are error-prone |
| Schema validation | Custom string parsing | Zod | Zod provides composable schemas, coercion, detailed error messages; custom parsing scales poorly |
| Rate limiting on public routes | In-memory Map per request | Upstash Redis or Vercel KV | Serverless functions reset state on cold start; persistent storage required for reliable rate limiting |
| Email sending | Custom SMTP logic | Resend / Trigger.dev | SMTP requires handling retries, bounces, authentication; Resend abstracts the complexity |
| Quote state machine | Manual enum + if-statements | Database constraints + XState (optional) | Database constraints prevent invalid state transitions at the source; optional: XState for complex workflows |
| Price calculation | Client-side total | Server action with server-side Drizzle queries | Prevents pricing fraud; server is source of truth |

**Key insight:** Forms, validation, and state machines are deceptively complex in distributed systems. React Hook Form handles uncontrolled components elegantly; Zod prevents type mismatches at runtime; Upstash Redis ensures rate limits survive serverless restarts. Building these from scratch incurs tech debt fast.

## Common Pitfalls

### Pitfall 1: Pricing Calculation Done on Client

**What goes wrong:** Admin enters tier, client-side calculates total, sends to server. Attacker modifies total before submission. Quote saved at wrong price.

**Why it happens:** Convenience; calculation logic is "simple" (just sum prices). Assumed client validation is enough.

**How to avoid:** Always recalculate total server-side in `acceptQuote` action. Server action fetches offer definition and components, recalculates sum, verifies it matches submitted total. Reject if mismatch.

**Warning signs:** Client sends `total` field to server action without verifying. No server-side calculation of offered total. Test: manually change total in form before submit; if accepted, it's broken.

### Pitfall 2: Exposing `quote_items` via Public API

**What goes wrong:** Public route returns full quote including `quote_items` with `unit_price`. Client sees pricing breakdown, negotiates based on line item costs.

**Why it happens:** Lazy query: fetch entire quote record, return as JSON. Assumed filtering on response is enough.

**How to avoid:** Use separate query function `getQuoteByToken()` that explicitly excludes `quote_items`. Construct `PublicQuoteView` type with no price fields. If admin must see items, use `getQuoteByTokenAdmin()` with auth check.

**Warning signs:** Public API response includes `quote_items` or `unit_price` fields. Network tab shows pricing breakdown. Test: curl the public quote endpoint, grep for "price".

### Pitfall 3: Immutability Not Enforced at Database Level

**What goes wrong:** Quote marked `accepted_at = NOW()`. Admin changes price. Quote is updated, but `accepted_at` still shows old acceptance. Client thinks old price was accepted.

**Why it happens:** Relied on UI logic ("disable edit button if accepted"). No database constraint preventing mutation.

**How to avoid:** Add CHECK constraint: `accepted_at IS NOT NULL AND accepted_total CANNOT CHANGE` (via trigger in PostgreSQL). Server action reads `accepted_at` before updating; if not null, reject with error.

**Warning signs:** Quote record has `accepted_at` set but `accepted_total` changed later. Test: manually update quotes table; UI should reject, server action should reject.

### Pitfall 4: Token Collisions or Predictability

**What goes wrong:** Two quotes generated with same token. Attacker guesses token (nanoid is not random enough). Public quote accessible to wrong client.

**Why it happens:** Used counter or short token. Forgot nanoid import/setup.

**How to avoid:** Always use `nanoid()` from the nanoid package (installed in phase 1). Verify at DB schema: `quotes.token` has UNIQUE constraint. Test: generate 1M tokens, check no collisions (statistically impossible with nanoid 21-char).

**Warning signs:** Database warning: duplicate key on quotes.token. Test: generate multiple quotes, compare tokens. If similar prefix, investigate.

### Pitfall 5: Rate Limit Resets on Serverless Cold Start

**What goes wrong:** Deployed public quote page with in-memory Map rate limiter. Serverless function cold starts, Map is reset, attacker can make unlimited requests for 30 seconds until next cold start.

**Why it happens:** Assumed in-memory state persists across requests. Valid for single-process servers, not serverless.

**How to avoid:** Use Upstash Redis or Vercel KV for persistent rate limit state. Simple Redis key per IP: `quote:ratelimit:{ip}` with TTL 60s, increment on each request, reject if > 3.

**Warning signs:** DDoS tools can hit rate-limited endpoint after cold start. Logs show sudden spike in 200 responses after function restart. Test: watch deployment logs, submit requests during cold start window.

### Pitfall 6: Multi-Step Form Losing State on Navigation

**What goes wrong:** User fills Step 1, advances to Step 2, browser back button, Step 2 data lost. Re-entering Step 1 resets the form.

**Why it happens:** Form state stored in local useState. Back navigation doesn't re-render parent with previous state.

**How to avoid:** Lift form state to parent component using useForm hook. Store step index in URL query param (`?step=2`) or in parent state. On navigation, query step from URL or state, restore form data.

**Warning signs:** User complaints: "My data disappeared when I went back." Test: fill form, press browser back, return to page, data is gone.

**React Hook Form advantage:** useForm can be configured to persist across component unmounts if wrapped with Suspense properly; URL params provide recovery.

### Pitfall 7: Accessible Error Messages Not Shown to Screen Readers

**What goes wrong:** Form has error message styled in red, but not announced by screen reader. User submits invalid form, sees red text, but accessibility reader doesn't announce error.

**Why it happens:** Error message is sibling div without aria-live. Form field is not linked to error via aria-describedby.

**How to avoid:** Use shadcn/ui Form component, which handles aria-describedby automatically. For custom fields, add `aria-describedby="fieldname-error"` to input, and `id="fieldname-error"` to error message. Add `aria-live="polite"` to error container if error appears dynamically.

**Warning signs:** Accessibility audit flags form errors. Screen reader testing: errors not announced on submit.

## Code Examples

Verified patterns from official sources:

### Example 1: Multi-Step Quote Form with React Hook Form

```typescript
// Source: [shadcn/ui Form Docs - React Hook Form Integration]
"use client";

import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

const step2Schema = z.object({
  email: z.string().email("Email valida richiesta"),
  notes: z.string().max(500, "Max 500 caratteri").optional(),
});

export function QuoteStep3({ onSubmit }: { onSubmit: (data: any) => void }) {
  const form = useForm<z.infer<typeof step2Schema>>({
    resolver: zodResolver(step2Schema),
    mode: "onBlur",
  });

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email (opzionale)</FormLabel>
              <FormControl>
                <Input placeholder="email@example.com" {...field} />
              </FormControl>
              <FormMessage /> {/* Accessible error display */}
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="notes"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Note (opzionale)</FormLabel>
              <FormControl>
                <textarea placeholder="Domande o richieste speciali" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit">Accetta Preventivo</Button>
      </form>
    </Form>
  );
}
```

### Example 2: Server Action with Zod Validation for Quote Accept

```typescript
// Source: [Next.js Server Actions + Zod Documentation]
"use server";

import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { quotes } from "@/db/schema";

const acceptSchema = z.object({
  token: z.string().min(1),
  email: z.string().email().optional().or(z.literal("")),
  notes: z.string().max(500).optional().or(z.literal("")),
});

export async function acceptQuote(formData: FormData) {
  const raw = {
    token: formData.get("token") as string,
    email: formData.get("email") as string,
    notes: formData.get("notes") as string,
  };

  const parsed = acceptSchema.safeParse(raw);
  if (!parsed.success) {
    throw new Error(parsed.error.issues.map(i => i.message).join(", "));
  }

  const { token, email, notes } = parsed.data;

  // Check quote exists and not already accepted
  const [quote] = await db
    .select()
    .from(quotes)
    .where(eq(quotes.token, token))
    .limit(1);

  if (!quote) throw new Error("Quote not found");
  if (quote.accepted_at) throw new Error("Already accepted");

  // Atomic update
  await db
    .update(quotes)
    .set({
      accepted_at: new Date(),
      // Store email/notes if schema includes them
    })
    .where(eq(quotes.token, token));

  return { success: true, quoteId: quote.id };
}
```

### Example 3: Public Quote Query (No Line Items Exposed)

```typescript
// Source: [Drizzle ORM + TypeScript Type Safety]
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { quotes, offer_micros } from "@/db/schema";

export type SafeQuoteView = {
  id: string;
  token: string;
  accepted_total: string;
  accepted_at: string | null;
  offerName: string;
};

export async function getSafeQuoteByToken(token: string): Promise<SafeQuoteView | null> {
  // Explicitly select only safe fields — never quote_items
  const [quote] = await db
    .select({
      id: quotes.id,
      token: quotes.token,
      accepted_total: quotes.accepted_total,
      accepted_at: quotes.accepted_at,
    })
    .from(quotes)
    .where(eq(quotes.token, token))
    .limit(1);

  if (!quote) return null;

  // Fetch offer name separately if needed
  // const offerName = ...

  return {
    ...quote,
    offerName: "Entry A",
  };
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Formik for forms | React Hook Form | 2022-2023 | RHF is lighter, better with headless UI; Formik still valid but adds bundle size |
| Manual form state (useState for each field) | useForm hook | 2022+ | useForm reduces boilerplate, improves perf via uncontrolled components |
| Server-side sessions for client access | Token-based routing (/client/[token]) | Phase 1 (v1.0 design) | Simpler for token-gated links; no session storage needed |
| Custom validation logic | Zod schema validation | 2023+ | Zod became industry standard; provides both runtime + TS compile-time type safety |
| In-memory rate limiting | Upstash Redis / Vercel KV | 2023+ | Serverless requires persistent state; in-memory doesn't survive cold starts |

**Deprecated/outdated:**
- Formik for new projects: Still functional but RHF has better momentum and lighter footprint.
- Manual fetch + state management for multistep forms: React Hook Form + context is standard now.
- Client-side total calculation: PCI-DSS and fraud prevention require server-side validation.
- unencrypted token storage: Always use HTTPS for token transmission; rotate tokens if leaked.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Phase 8 schema (offers, quotes, quote_items tables) exists with proper FKs | Standard Stack, Architecture | If Phase 8 not executed, Quote Builder will fail to query offer data; planner must validate Phase 8 completion first |
| A2 | Nanoid 21-char tokens have ~122 bits entropy (collision-safe for millions) | Common Pitfalls | If actual entropy is lower, token guessing becomes feasible; verify nanoid package docs |
| A3 | Upstash Redis is available/acceptable for rate limiting | Don't Hand-Roll | If Upstash unavailable or budget-blocked, fallback: use Vercel KV or implement in-memory with caveat (cold start reset) |
| A4 | Email notification can be deferred to Phase 12 without blocking quote acceptance | Architecture Patterns | If email must send synchronously in Phase 9, add Resend call to server action (adds latency, ~200ms) |
| A5 | Middleware can extract x-forwarded-for header for rate limit IP (no HTTPS-only issues) | Architecture Patterns | If x-forwarded-for is spoofable or missing, fallback to request.headers.get("cf-connecting-ip") for Cloudflare |

**If this table is incomplete:** All other claims were verified via Context7, official docs, or existing codebase patterns.

## Open Questions

1. **Email Integration Timing**
   - What we know: Resend is chosen for Phase 12; Phase 9 focuses on quote creation/accept mechanics
   - What's unclear: Should Phase 9 include placeholder email notification, or skip entirely?
   - Recommendation: Implement `acceptQuote()` server action to completion; email trigger can be added in Phase 12 by dispatching event or calling notification function stub. This allows Phase 9 to be self-contained.

2. **Quote Versioning**
   - What we know: Quoted price is immutable via `accepted_at` timestamp
   - What's unclear: If client wants to negotiate after accept, do we create Quote v2, or use same quote record with new version field?
   - Recommendation: Create new quote record (Quote v2) if negotiation occurs. Keep original as audit trail. Phase 11 auto-provisioning references the accepted quote, not the negotiation version. **Defer versioning logic to Phase 11 planning.**

3. **Tier Selection on Public Page**
   - What we know: Quote is pre-computed by admin (tier already chosen in builder)
   - What's unclear: Can client change tier on public page, or is it read-only?
   - Recommendation: Read-only for Phase 9 MVP. If admin wants client to choose, that becomes conditional logic in Phase 9 Step 2. Mark as RFC for planning phase.

## Validation Architecture

Validation testing is disabled (`nyquist_validation: false` in config.json). Skip this section per workflow rules.

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|------------------|
| V2 Authentication | no | Token-gated route validated via middleware; no session auth for `/quote/[token]` |
| V3 Session Management | no | No sessions on public quote page |
| V4 Access Control | yes | Token validation; rate limiting; immutable `accepted_at` prevents unauthorized changes |
| V5 Input Validation | yes | Zod schema validation for email, notes on Step 3; server-side recalculation of total; reject if mismatched |
| V6 Cryptography | yes | Nanoid tokens (122 bits entropy); HTTPS enforced for token transmission |
| V7 Cryptographic Failures | yes | No pricing secrets in client; quote_items never exposed to public API |
| V9 API Security | yes | Public route returns only safe fields; admin routes require Auth.js session |

### Known Threat Patterns for Next.js + Token-Gated Routes

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Token guessing / brute force | Spoofing | Nanoid 21-char (unguessable); rate limit 3 views/min per IP (Upstash Redis) |
| Price manipulation (client-side total) | Tampering | Server-side recalculation in `acceptQuote()` action; reject if mismatch |
| Quote details leakage (quote_items exposed) | Information Disclosure | Query layer filters; never include line items in public API response |
| Quote accepted twice (accepting after accept) | Tampering | Database CHECK constraint + application check: `accepted_at IS NOT NULL` → reject mutation |
| Timing attack on token validation | Timing | Use constant-time comparison if sensitive; nanoid lookup via indexed DB query is safe |
| Email capture spam | Denial of Service | Optional email field; rate limit public page; validate email format with Zod |
| XSS via quote notes | Injection | Notes stored as text; rendered in admin area only; sanitize if ever displayed on client page (not in Phase 9) |

## Sources

### Primary (HIGH confidence)
- **React Hook Form Documentation** - shadcn/ui Form Docs: https://ui.shadcn.com/docs/forms/react-hook-form
- **Next.js Server Actions** - Official Next.js Docs: Next.js 16 App Router server actions for form submission
- **Zod Validation** - Official Zod Repository & Docs: Type-safe schema validation with error handling
- **Drizzle ORM** - Official Drizzle Documentation: Query construction, relations, type safety
- **nanoid** - Official nanoid Package (v5.1.11): Token generation with cryptographic randomness
- **ClientHub CLAUDE.md** - Project constraints: Token-gated routes, immutable fields, stack specification

### Secondary (MEDIUM confidence)
- **React Hook Form + Next.js Patterns** - Medium Articles & Community Tutorials: https://medium.com/@techwithtwin/handling-forms-in-nextjs-with-react-hook-form-zod-and-server-actions-e148d4dc6dc1
- **Multi-Step Forms with Zustand** - Build with Matija: https://www.buildwithmatija.com/blog/master-multi-step-forms-build-a-dynamic-react-form-in-6-simple-steps
- **Rate Limiting in Next.js** - Upstash Blog & Vercel Templates: https://upstash.com/blog/nextjs-ratelimiting
- **Email Integration with Resend** - Resend Docs & DEV Community: https://resend.com/nextjs
- **WCAG 2.1 Form Accessibility** - Deque & DigitalA11Y: https://www.deque.com/blog/anatomy-of-accessible-forms-error-messages/

### Tertiary (Embedded in Codebase)
- **Existing ClientHub Patterns** - Phase 1-8 implementation in `/src/components`, `/src/app/admin`, `/src/lib`
- **ServiceForm.tsx** - Existing form pattern using FormData + server actions (no RHF)
- **quote-actions.ts** - Existing Zod validation pattern for quote operations
- **client-view.ts** - Existing type-safe query layer pattern (model for safe public views)

## Metadata

**Confidence breakdown:**
- **Standard stack:** HIGH — All libraries verified against npm registry and existing package.json
- **Architecture patterns:** HIGH — React Hook Form + Zod + shadcn/ui patterns are industry standard; verified via official docs
- **Rate limiting:** MEDIUM — Upstash pattern described in search results; in-memory fallback documented with caveat
- **Email integration:** MEDIUM — Resend is chosen per memory notes; deferred to Phase 12, so only reference implementation available
- **Security:** HIGH — ASVS mapping based on OWASP standards; token-based access mirrors Phase 1 proven pattern

**Research date:** 2026-06-11  
**Valid until:** 2026-06-25 (14 days — React/Next.js ecosystem is stable; patterns unlikely to shift in 2-week window)

---

## Phase 9 Research Complete

This research document provides the planner with concrete patterns, verified libraries, code examples, and architectural guidance for implementing Phase 9: Quote Builder & Client Acceptance. The multi-step form patterns (React Hook Form + Zod + shadcn/ui) are production-proven; server-side validation and immutability enforcement are security-critical and non-negotiable. Rate limiting is optional but recommended for public routes. Email notification logic can be added in Phase 12 without blocking Phase 9 completion.

**Ready for planning.** Planner can now design 3-5 wave tasks for quote builder UI, public quote page, server actions, and database schema completion (Phase 8).
