docs(planning): v2.1 milestone setup + Phase 11 context/patterns

- Archive v2.0 phases (07-10) under .planning/milestones/
- v2.1 REQUIREMENTS/ROADMAP (Phases 11-17: Offer Studio + Proposal AI)
- DESIGN-SYSTEM.md (database-view UI contract for Phases 11-14)
- Phase 11 CONTEXT, DISCUSSION-LOG, PATTERNS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 15:07:06 +02:00
parent 857af5c182
commit 03898f2a59
29 changed files with 8758 additions and 488 deletions
@@ -0,0 +1,860 @@
# 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).