From 6a35c97cfdd4585849562286c78ef9fbadfbeffa Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 11 Jun 2026 07:38:57 +0200 Subject: [PATCH] feat(09-03): create public quote page with server actions - layout.tsx: Simple centered public layout (no auth header) with quote branding - page.tsx: Server component validating token, handling quote states (draft/accepted/rejected) - actions.ts: Server actions for acceptQuote() and rejectQuote() with immutable accepted_at enforcement - Validates token format (21-char nanoid), returns 404 for invalid tokens - Shows 'already accepted' message if quote was previously accepted - Integrates with QuoteMultistep component for active quotes Co-Authored-By: Claude Haiku 4.5 --- src/app/quote/[token]/actions.ts | 119 +++++++++++++++++++++++++++++++ src/app/quote/[token]/layout.tsx | 20 ++++++ src/app/quote/[token]/page.tsx | 88 +++++++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 src/app/quote/[token]/actions.ts create mode 100644 src/app/quote/[token]/layout.tsx create mode 100644 src/app/quote/[token]/page.tsx diff --git a/src/app/quote/[token]/actions.ts b/src/app/quote/[token]/actions.ts new file mode 100644 index 0000000..c782b5f --- /dev/null +++ b/src/app/quote/[token]/actions.ts @@ -0,0 +1,119 @@ +"use server"; + +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { quotes } from "@/db/schema"; +import { acceptQuoteSchema } from "@/lib/quote-validators"; +import { revalidatePath } from "next/cache"; + +export async function acceptQuote( + token: string, + email?: string, + notes?: string +) { + // Validate input + const parsed = acceptQuoteSchema.safeParse({ token, email, notes }); + if (!parsed.success) { + return { + success: false, + error: parsed.error.issues[0]?.message || "Dati invalidi", + }; + } + + const { token: validatedToken, email: validatedEmail, notes: validatedNotes } = parsed.data; + + try { + // Fetch quote + const [quote] = await db + .select() + .from(quotes) + .where(eq(quotes.token, validatedToken)) + .limit(1); + + if (!quote) { + return { success: false, error: "Preventivo non trovato" }; + } + + // Check if already accepted (immutability) + if (quote.accepted_at !== null) { + return { success: false, error: "Preventivo già accettato" }; + } + + // Atomic update: set accepted_at (immutable) + optional email/notes + const updated = await db + .update(quotes) + .set({ + accepted_at: new Date(), + state: "accepted", + client_email: validatedEmail || null, + client_notes: validatedNotes || null, + updated_at: new Date(), + }) + .where(eq(quotes.token, validatedToken)) + .returning(); + + if (!updated[0]) { + return { success: false, error: "Errore nel salvataggio" }; + } + + // Revalidate page to show accepted state + revalidatePath(`/quote/${validatedToken}`); + + // Phase 11 will hook into this event for auto-provisioning + // For now, just return success + return { + success: true, + message: "Preventivo accettato con successo!", + quoteId: quote.id, + }; + } catch (error) { + console.error("acceptQuote error:", error); + return { success: false, error: "Errore del server" }; + } +} + +export async function rejectQuote(token: string, notes?: string) { + // Validate token format + if (!token || typeof token !== "string" || token.length !== 21) { + return { success: false, error: "Token invalido" }; + } + + try { + // Fetch quote + const [quote] = await db + .select() + .from(quotes) + .where(eq(quotes.token, token)) + .limit(1); + + if (!quote) { + return { success: false, error: "Preventivo non trovato" }; + } + + // Update state to rejected + const updated = await db + .update(quotes) + .set({ + state: "rejected", + client_notes: notes || null, + updated_at: new Date(), + }) + .where(eq(quotes.token, token)) + .returning(); + + if (!updated[0]) { + return { success: false, error: "Errore nel salvataggio" }; + } + + revalidatePath(`/quote/${token}`); + + return { + success: true, + message: "Preventivo rifiutato", + }; + } catch (error) { + console.error("rejectQuote error:", error); + return { success: false, error: "Errore del server" }; + } +} diff --git a/src/app/quote/[token]/layout.tsx b/src/app/quote/[token]/layout.tsx new file mode 100644 index 0000000..ea7330a --- /dev/null +++ b/src/app/quote/[token]/layout.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +export default function QuoteLayout({ children }: { children: ReactNode }) { + return ( +
+
+ {/* Simple header */} +
+

Preventivo

+

Accetta il tuo preventivo personalizzato

+
+ + {/* Main content */} +
+ {children} +
+
+
+ ); +} diff --git a/src/app/quote/[token]/page.tsx b/src/app/quote/[token]/page.tsx new file mode 100644 index 0000000..92b6d8b --- /dev/null +++ b/src/app/quote/[token]/page.tsx @@ -0,0 +1,88 @@ +import { notFound } from "next/navigation"; +import { getQuoteByToken } from "@/lib/quote-service"; +import { QuoteMultistep } from "@/components/public/quote/QuoteMultistep"; +import { Card } from "@/components/ui/card"; + +export const revalidate = 0; // Always fetch fresh data (no caching) + +interface QuotePageProps { + params: Promise<{ token: string }>; +} + +export default async function QuotePage({ params }: QuotePageProps) { + const { token } = await params; + + // Validate token format (nanoid: 21 chars, alphanumeric + underscore/dash) + if (!token || typeof token !== "string" || !/^[a-zA-Z0-9_-]{21}$/.test(token)) { + notFound(); + } + + // Fetch quote data + const quote = await getQuoteByToken(token); + if (!quote) { + notFound(); + } + + // If already accepted, show read-only message + if (quote.state === "accepted" && quote.accepted_at) { + return ( +
+
+

+ ✓ Preventivo già accettato +

+

+ Questo preventivo è stato accettato il{" "} + + {new Date(quote.accepted_at).toLocaleDateString("it-IT", { + year: "numeric", + month: "long", + day: "numeric", + })} + +

+
+
+

Dettagli preventivo

+ +
+ Offerta: + {quote.offerName} +
+
+ Totale: + + {parseFloat(quote.accepted_total).toLocaleString("it-IT", { + style: "currency", + currency: "EUR", + })} + +
+
+
+
+ ); + } + + // If rejected, show message + if (quote.state === "rejected") { + return ( +
+
+

+ Preventivo rifiutato +

+

+ Hai rifiutato questo preventivo. Se desideri discutere una proposta diversa, + contattaci direttamente. +

+
+
+ ); + } + + // Active quote — show multistep form + return ( + + ); +}