From 329ccd49b494c63545be71109d7e656b05d77bdc Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 11 Jun 2026 07:01:09 +0200 Subject: [PATCH] =?UTF-8?q?docs(09):=20research=20phase=209=20=E2=80=94=20?= =?UTF-8?q?quote=20builder=20&=20public=20routes=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../09-RESEARCH.md | 860 ++++++++++++++++++ 1 file changed, 860 insertions(+) create mode 100644 .planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md diff --git a/.planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md b/.planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md new file mode 100644 index 0000000..200332a --- /dev/null +++ b/.planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md @@ -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>({ + resolver: zodResolver(formSchema), + mode: "onBlur", + }); + + async function onSubmit(data: z.infer) { + 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 ( +
+ {step === 1 && } + {step === 2 && } + {step === 3 && } + +
+ {step > 1 && ( + + )} + +
+ + ); +} +``` + +### 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 { + 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(); + +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>({ + 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) { + const result = await createQuote(data); + if (result.success) { + window.location.href = `/admin/quotes/${result.quoteId}`; + } + } + + return ( +
+ {/* Left: Form */} +
+ {/* Client select, offer select, price override inputs */} +
+ + {/* Right: Preview */} + {preview && ( +
+

Anteprima

+ {/* Display selected offer, services, total */} +
+ )} +
+ ); +} +``` + +### 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>({ + resolver: zodResolver(step2Schema), + mode: "onBlur", + }); + + return ( +
+ + ( + + Email (opzionale) + + + + {/* Accessible error display */} + + )} + /> + + ( + + Note (opzionale) + +