Files
clienthub/.planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md
T

41 KiB

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:

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
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:

// 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:

// 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:

// 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):

// 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:

// 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

// 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

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

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

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