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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 07:38:50 +02:00
parent f5d571e89d
commit 9facd3ff85
4 changed files with 460 additions and 0 deletions
@@ -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 = (
<div className="flex items-center justify-center gap-2 mb-6">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center font-semibold text-sm ${
step <= currentStep
? "bg-blue-600 text-white"
: "bg-slate-200 text-slate-600"
}`}
>
{step}
</div>
{step < 3 && (
<div
className={`w-8 h-0.5 ${
step < currentStep ? "bg-blue-600" : "bg-slate-200"
}`}
/>
)}
</div>
))}
</div>
);
return (
<div>
{stepIndicator}
{currentStep === 1 && (
<QuoteStep1Overview
quote={quote}
onNext={handleNextStep}
/>
)}
{currentStep === 2 && (
<QuoteStep2Selection
quote={quote}
onNext={handleNextStep}
onPrev={handlePrevStep}
/>
)}
{currentStep === 3 && (
<QuoteStep3Summary
quote={quote}
onPrev={handlePrevStep}
/>
)}
</div>
);
}
@@ -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 (
<div className="space-y-8 p-8">
{/* Offer Name */}
<div className="text-center space-y-2">
<h2 className="text-2xl font-semibold text-slate-900">{quote.offerName}</h2>
</div>
{/* Total Price (Large & Prominent) */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg p-8 text-center border border-blue-200">
<p className="text-sm font-medium text-slate-600 mb-2">Investimento totale</p>
<p className="text-5xl font-bold text-blue-600">{totalFormatted}</p>
<p className="text-sm text-slate-600 mt-4">
Tutto ciò che serve per il tuo progetto di personal branding
</p>
</div>
{/* Phase Summary */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide">
Cosa è incluso
</h3>
<div className="space-y-2">
{quote.phaseSummary.length > 0 ? (
quote.phaseSummary.map((phase) => (
<Card key={phase.id} className="p-4 border-l-4 border-l-blue-500">
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-slate-900">{phase.title}</p>
</div>
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full font-medium">
{phase.serviceCount} servizio{phase.serviceCount !== 1 ? "i" : ""}
</span>
</div>
</Card>
))
) : (
<p className="text-slate-600 text-sm">
Nessuna fase configurata per questo preventivo
</p>
)}
</div>
</div>
{/* CTA */}
<div className="pt-4">
<Button
onClick={onNext}
size="lg"
className="w-full bg-blue-600 hover:bg-blue-700 text-white h-12"
>
Continua <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
{/* Info text */}
<p className="text-xs text-slate-500 text-center">
Passo 1 di 3 Panoramica preventivo
</p>
</div>
);
}
@@ -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 (
<div className="space-y-8 p-8">
{/* Phase Listing */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-slate-900">Fasi incluse nel preventivo</h2>
{quote.phaseSummary.length > 0 ? (
<div className="space-y-3">
{quote.phaseSummary.map((phase) => (
<Card key={phase.id} className="p-4 border-l-4 border-l-green-500 hover:shadow-md transition-shadow">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-green-600 mt-1 flex-shrink-0" />
<div className="flex-1">
<h3 className="font-semibold text-slate-900">{phase.title}</h3>
<p className="text-sm text-slate-600 mt-1">
{phase.serviceCount} servizio{phase.serviceCount !== 1 ? "i" : ""}
</p>
</div>
</div>
</Card>
))}
</div>
) : (
<p className="text-slate-600 text-sm">
Nessuna fase configurata per questo preventivo
</p>
)}
</div>
{/* Divider */}
<div className="border-t border-slate-200"></div>
{/* Total Summary */}
<div className="bg-slate-50 rounded-lg p-6 space-y-2">
<div className="flex justify-between items-center">
<span className="text-slate-700 font-medium">Totale preventivo:</span>
<span className="text-3xl font-bold text-blue-600">{totalFormatted}</span>
</div>
<p className="text-xs text-slate-500">
Questo totale è immutabile e comprende tutti i servizi sopra elencati
</p>
</div>
{/* Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-slate-700">
<span className="font-semibold">Nota:</span> Nel passo successivo potrai confermare l'accettazione fornendo
il tuo indirizzo email e eventuali note.
</p>
</div>
{/* Navigation */}
<div className="flex gap-3 pt-4">
<Button
onClick={onPrev}
variant="outline"
className="flex-1 h-12"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Indietro
</Button>
<Button
onClick={onNext}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white h-12"
>
Continua <ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
{/* Info text */}
<p className="text-xs text-slate-500 text-center">
Passo 2 di 3 Dettagli fasi
</p>
</div>
);
}
@@ -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<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div className="space-y-8 p-8">
<div className="flex justify-center">
<div className="rounded-full bg-green-100 p-3">
<CheckCircle className="h-12 w-12 text-green-600" />
</div>
</div>
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold text-slate-900">Perfetto!</h2>
<p className="text-slate-600">{successMessage}</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-slate-900">Cosa succede ora?</p>
<ul className="text-sm text-slate-600 space-y-1 ml-4">
<li> Abbiamo registrato l'accettazione del preventivo</li>
<li> Ti contatteremo entro 24 ore per concordare i prossimi passi</li>
<li> Inizieremo la pianificazione del tuo progetto di personal branding</li>
</ul>
</div>
</div>
);
}
return (
<div className="space-y-8 p-8">
{/* Summary Card */}
<Card className="p-6 border-l-4 border-l-blue-500 bg-blue-50">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Riepilogo</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-600">Offerta:</span>
<span className="font-medium text-slate-900">{quote.offerName}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Investimento totale:</span>
<span className="font-bold text-blue-600">{totalFormatted}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Fasi incluse:</span>
<span className="font-medium text-slate-900">{quote.phaseSummary.length}</span>
</div>
</div>
</Card>
{/* Form Fields */}
<div className="space-y-4">
<div>
<Label htmlFor="email" className="block mb-2 font-medium text-slate-700">
Email (facoltativo)
</Label>
<Input
id="email"
type="email"
placeholder="il-tuo@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isSubmitting}
className="h-10"
/>
<p className="text-xs text-slate-500 mt-1">
Useremo questa email per contattarti riguardo al tuo preventivo
</p>
</div>
<div>
<Label htmlFor="notes" className="block mb-2 font-medium text-slate-700">
Note (facoltativo, max 500 caratteri)
</Label>
<Textarea
id="notes"
placeholder="Es. Preferisco iniziare nel mese di settembre..."
value={notes}
onChange={(e) => setNotes(e.target.value.slice(0, 500))}
disabled={isSubmitting}
rows={4}
className="resize-none"
/>
<p className="text-xs text-slate-500 mt-1">
{notes.length}/500 caratteri
</p>
</div>
</div>
{/* Error message */}
{errorMessage && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex gap-3">
<X className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{errorMessage}</p>
</div>
)}
{/* CTA Buttons */}
<div className="space-y-3 pt-4">
<Button
onClick={handleAccept}
disabled={isSubmitting}
size="lg"
className="w-full bg-green-600 hover:bg-green-700 text-white h-12"
>
{isSubmitting ? "Invio in corso..." : "Accetta Preventivo"}
</Button>
<div className="flex gap-2">
<Button
onClick={onPrev}
variant="outline"
disabled={isSubmitting}
className="flex-1 h-10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Indietro
</Button>
<Button
onClick={handleReject}
disabled={isSubmitting}
variant="ghost"
className="flex-1 h-10 text-red-600 hover:text-red-700 hover:bg-red-50"
>
Rifiuta
</Button>
</div>
</div>
{/* Info text */}
<p className="text-xs text-slate-500 text-center">
Passo 3 di 3 Conferma e accettazione
</p>
</div>
);
}