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