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