# 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)