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:
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user