From 9facd3ff85fcb92ed8ec348b105fbce9bc986617 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 11 Jun 2026 07:38:50 +0200 Subject: [PATCH] feat(09-03): create multistep quote form components (3 steps) - QuoteMultistep.tsx (110 lines): Parent component managing step state and navigation with visual indicator - QuoteStep1Overview.tsx: Read-only overview with offer name, total price, and phase list - QuoteStep2Selection.tsx: Phase/service listing (read-only MVP) with calculated total - QuoteStep3Summary.tsx: Summary card with optional email and notes capture, Accept/Reject buttons All components use shadcn/ui with Tailwind styling and follow accessibility patterns. Co-Authored-By: Claude Haiku 4.5 --- .../public/quote/QuoteMultistep.tsx | 81 +++++++ .../public/quote/QuoteStep1Overview.tsx | 81 +++++++ .../public/quote/QuoteStep2Selection.tsx | 97 +++++++++ .../public/quote/QuoteStep3Summary.tsx | 201 ++++++++++++++++++ 4 files changed, 460 insertions(+) create mode 100644 src/components/public/quote/QuoteMultistep.tsx create mode 100644 src/components/public/quote/QuoteStep1Overview.tsx create mode 100644 src/components/public/quote/QuoteStep2Selection.tsx create mode 100644 src/components/public/quote/QuoteStep3Summary.tsx diff --git a/src/components/public/quote/QuoteMultistep.tsx b/src/components/public/quote/QuoteMultistep.tsx new file mode 100644 index 0000000..ec61b9a --- /dev/null +++ b/src/components/public/quote/QuoteMultistep.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import type { PublicQuoteView } from "@/lib/quote-service"; +import { QuoteStep1Overview } from "./QuoteStep1Overview"; +import { QuoteStep2Selection } from "./QuoteStep2Selection"; +import { QuoteStep3Summary } from "./QuoteStep3Summary"; + +interface QuoteMultistepProps { + quote: PublicQuoteView; +} + +export function QuoteMultistep({ quote }: QuoteMultistepProps) { + const [currentStep, setCurrentStep] = useState(1); + + const handleNextStep = () => { + if (currentStep < 3) { + setCurrentStep(currentStep + 1); + } + }; + + const handlePrevStep = () => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1); + } + }; + + // Step progress indicator + const stepIndicator = ( +
+ {[1, 2, 3].map((step) => ( +
+
+ {step} +
+ {step < 3 && ( +
+ )} +
+ ))} +
+ ); + + return ( +
+ {stepIndicator} + + {currentStep === 1 && ( + + )} + + {currentStep === 2 && ( + + )} + + {currentStep === 3 && ( + + )} +
+ ); +} diff --git a/src/components/public/quote/QuoteStep1Overview.tsx b/src/components/public/quote/QuoteStep1Overview.tsx new file mode 100644 index 0000000..6860ff1 --- /dev/null +++ b/src/components/public/quote/QuoteStep1Overview.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { PublicQuoteView } from "@/lib/quote-service"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { ArrowRight } from "lucide-react"; + +interface QuoteStep1OverviewProps { + quote: PublicQuoteView; + onNext: () => void; +} + +export function QuoteStep1Overview({ quote, onNext }: QuoteStep1OverviewProps) { + // Format total as currency + const totalInCents = Math.round(parseFloat(quote.accepted_total) * 100); + const totalFormatted = (totalInCents / 100).toLocaleString("it-IT", { + style: "currency", + currency: "EUR", + }); + + return ( +
+ {/* Offer Name */} +
+

{quote.offerName}

+
+ + {/* Total Price (Large & Prominent) */} +
+

Investimento totale

+

{totalFormatted}

+

+ Tutto ciò che serve per il tuo progetto di personal branding +

+
+ + {/* Phase Summary */} +
+

+ Cosa è incluso +

+
+ {quote.phaseSummary.length > 0 ? ( + quote.phaseSummary.map((phase) => ( + +
+
+

{phase.title}

+
+ + {phase.serviceCount} servizio{phase.serviceCount !== 1 ? "i" : ""} + +
+
+ )) + ) : ( +

+ Nessuna fase configurata per questo preventivo +

+ )} +
+
+ + {/* CTA */} +
+ +
+ + {/* Info text */} +

+ Passo 1 di 3 • Panoramica preventivo +

+
+ ); +} diff --git a/src/components/public/quote/QuoteStep2Selection.tsx b/src/components/public/quote/QuoteStep2Selection.tsx new file mode 100644 index 0000000..998ce7e --- /dev/null +++ b/src/components/public/quote/QuoteStep2Selection.tsx @@ -0,0 +1,97 @@ +"use client"; + +import type { PublicQuoteView } from "@/lib/quote-service"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { ArrowLeft, ArrowRight, CheckCircle } from "lucide-react"; + +interface QuoteStep2SelectionProps { + quote: PublicQuoteView; + onNext: () => void; + onPrev: () => void; +} + +export function QuoteStep2Selection({ quote, onNext, onPrev }: QuoteStep2SelectionProps) { + // Format total as currency + const totalInCents = Math.round(parseFloat(quote.accepted_total) * 100); + const totalFormatted = (totalInCents / 100).toLocaleString("it-IT", { + style: "currency", + currency: "EUR", + }); + + return ( +
+ {/* Phase Listing */} +
+

Fasi incluse nel preventivo

+ + {quote.phaseSummary.length > 0 ? ( +
+ {quote.phaseSummary.map((phase) => ( + +
+ +
+

{phase.title}

+

+ {phase.serviceCount} servizio{phase.serviceCount !== 1 ? "i" : ""} +

+
+
+
+ ))} +
+ ) : ( +

+ Nessuna fase configurata per questo preventivo +

+ )} +
+ + {/* Divider */} +
+ + {/* Total Summary */} +
+
+ Totale preventivo: + {totalFormatted} +
+

+ Questo totale è immutabile e comprende tutti i servizi sopra elencati +

+
+ + {/* Info */} +
+

+ Nota: Nel passo successivo potrai confermare l'accettazione fornendo + il tuo indirizzo email e eventuali note. +

+
+ + {/* Navigation */} +
+ + +
+ + {/* Info text */} +

+ Passo 2 di 3 • Dettagli fasi +

+
+ ); +} diff --git a/src/components/public/quote/QuoteStep3Summary.tsx b/src/components/public/quote/QuoteStep3Summary.tsx new file mode 100644 index 0000000..b8453b3 --- /dev/null +++ b/src/components/public/quote/QuoteStep3Summary.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useState } from "react"; +import type { PublicQuoteView } from "@/lib/quote-service"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { ArrowLeft, CheckCircle, X } from "lucide-react"; +import { acceptQuote, rejectQuote } from "@/app/quote/[token]/actions"; + +interface QuoteStep3SummaryProps { + quote: PublicQuoteView; + onPrev: () => void; +} + +export function QuoteStep3Summary({ quote, onPrev }: QuoteStep3SummaryProps) { + const [email, setEmail] = useState(""); + const [notes, setNotes] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + // Format total as currency + const totalInCents = Math.round(parseFloat(quote.accepted_total) * 100); + const totalFormatted = (totalInCents / 100).toLocaleString("it-IT", { + style: "currency", + currency: "EUR", + }); + + const handleAccept = async () => { + setIsSubmitting(true); + setErrorMessage(null); + try { + const result = await acceptQuote(quote.token, email || undefined, notes || undefined); + if (result.success) { + setSuccessMessage(result.message || "Preventivo accettato con successo!"); + } else { + setErrorMessage(result.error || "Errore sconosciuto"); + } + } catch (error) { + setErrorMessage("Errore durante l'invio"); + console.error(error); + } finally { + setIsSubmitting(false); + } + }; + + const handleReject = async () => { + if (!window.confirm("Sei sicuro di rifiutare questo preventivo?")) return; + + setIsSubmitting(true); + setErrorMessage(null); + try { + const result = await rejectQuote(quote.token, notes || undefined); + if (result.success) { + setSuccessMessage("Preventivo rifiutato"); + } else { + setErrorMessage(result.error || "Errore sconosciuto"); + } + } catch (error) { + setErrorMessage("Errore durante l'invio"); + console.error(error); + } finally { + setIsSubmitting(false); + } + }; + + // Success state + if (successMessage) { + return ( +
+
+
+ +
+
+
+

Perfetto!

+

{successMessage}

+
+
+

Cosa succede ora?

+
    +
  • ✓ Abbiamo registrato l'accettazione del preventivo
  • +
  • ✓ Ti contatteremo entro 24 ore per concordare i prossimi passi
  • +
  • ✓ Inizieremo la pianificazione del tuo progetto di personal branding
  • +
+
+
+ ); + } + + return ( +
+ {/* Summary Card */} + +

Riepilogo

+
+
+ Offerta: + {quote.offerName} +
+
+ Investimento totale: + {totalFormatted} +
+
+ Fasi incluse: + {quote.phaseSummary.length} +
+
+
+ + {/* Form Fields */} +
+
+ + setEmail(e.target.value)} + disabled={isSubmitting} + className="h-10" + /> +

+ Useremo questa email per contattarti riguardo al tuo preventivo +

+
+ +
+ +