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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 07:38:57 +02:00
parent 9facd3ff85
commit 6a35c97cfd
3 changed files with 227 additions and 0 deletions
+119
View File
@@ -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" };
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { ReactNode } from "react";
export default function QuoteLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-8 px-4">
<div className="max-w-2xl mx-auto">
{/* Simple header */}
<div className="mb-8 text-center">
<h1 className="text-3xl font-bold text-slate-900">Preventivo</h1>
<p className="text-slate-600 mt-2">Accetta il tuo preventivo personalizzato</p>
</div>
{/* Main content */}
<div className="bg-white rounded-lg shadow-lg">
{children}
</div>
</div>
</div>
);
}
+88
View File
@@ -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 (
<div className="space-y-6 p-8">
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<h2 className="text-lg font-semibold text-green-900 mb-2">
Preventivo già accettato
</h2>
<p className="text-green-800">
Questo preventivo è stato accettato il{" "}
<span className="font-medium">
{new Date(quote.accepted_at).toLocaleDateString("it-IT", {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
</p>
</div>
<div className="border-t border-slate-200 pt-6">
<h3 className="font-semibold text-slate-900 mb-3">Dettagli preventivo</h3>
<Card className="p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-slate-600">Offerta:</span>
<span className="font-medium">{quote.offerName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-slate-600">Totale:</span>
<span className="font-medium">
{parseFloat(quote.accepted_total).toLocaleString("it-IT", {
style: "currency",
currency: "EUR",
})}
</span>
</div>
</Card>
</div>
</div>
);
}
// If rejected, show message
if (quote.state === "rejected") {
return (
<div className="space-y-6 p-8">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6">
<h2 className="text-lg font-semibold text-amber-900 mb-2">
Preventivo rifiutato
</h2>
<p className="text-amber-800">
Hai rifiutato questo preventivo. Se desideri discutere una proposta diversa,
contattaci direttamente.
</p>
</div>
</div>
);
}
// Active quote — show multistep form
return (
<QuoteMultistep quote={quote} />
);
}