feat: Latest production version with interior scene and glass
Includes room interior with floor, walls, glass you can see through, and all uncommitted production changes that were running live. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,20 +7,15 @@ import {
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { QuoteData } from "@/lib/validators";
|
||||
|
||||
const TOTAL_STEPS = 5; // 4 form steps + 1 summary
|
||||
|
||||
type FormData = Partial<QuoteData>;
|
||||
const TOTAL_STEPS = 6; // Product, Dimensions, Options, Extras, Contact, Summary
|
||||
|
||||
interface FormContextValue {
|
||||
currentStep: number;
|
||||
formData: FormData;
|
||||
totalSteps: number;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
goToStep: (step: number) => void;
|
||||
updateData: (data: Partial<FormData>) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -28,7 +23,6 @@ const FormContext = createContext<FormContextValue | null>(null);
|
||||
|
||||
export function FormProvider({ children }: { children: ReactNode }) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [formData, setFormData] = useState<FormData>({});
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
setCurrentStep((s) => Math.min(s + 1, TOTAL_STEPS - 1));
|
||||
@@ -42,25 +36,18 @@ export function FormProvider({ children }: { children: ReactNode }) {
|
||||
setCurrentStep(Math.max(0, Math.min(step, TOTAL_STEPS - 1)));
|
||||
}, []);
|
||||
|
||||
const updateData = useCallback((data: Partial<FormData>) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCurrentStep(0);
|
||||
setFormData({});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FormContext.Provider
|
||||
value={{
|
||||
currentStep,
|
||||
formData,
|
||||
totalSteps: TOTAL_STEPS,
|
||||
nextStep,
|
||||
prevStep,
|
||||
goToStep,
|
||||
updateData,
|
||||
reset,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useFormContext } from "@/components/offerte/form-context";
|
||||
import { useConfiguratorStore } from "@/lib/store";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { User, Mail, Phone, MessageSquare } from "lucide-react";
|
||||
|
||||
export function StepContact() {
|
||||
const { formData, updateData } = useFormContext();
|
||||
const { name, email, phone, note, setName, setEmail, setPhone, setNote } =
|
||||
useConfiguratorStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -24,8 +25,8 @@ export function StepContact() {
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Uw volledige naam"
|
||||
value={formData.name ?? ""}
|
||||
onChange={(e) => updateData({ name: e.target.value })}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-11 focus-visible:ring-brand-orange"
|
||||
/>
|
||||
</div>
|
||||
@@ -40,8 +41,8 @@ export function StepContact() {
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="naam@bedrijf.nl"
|
||||
value={formData.email ?? ""}
|
||||
onChange={(e) => updateData({ email: e.target.value })}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-11 focus-visible:ring-brand-orange"
|
||||
/>
|
||||
</div>
|
||||
@@ -55,8 +56,8 @@ export function StepContact() {
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="06 1234 5678"
|
||||
value={formData.phone ?? ""}
|
||||
onChange={(e) => updateData({ phone: e.target.value })}
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="h-11 focus-visible:ring-brand-orange"
|
||||
/>
|
||||
</div>
|
||||
@@ -72,8 +73,8 @@ export function StepContact() {
|
||||
id="note"
|
||||
rows={3}
|
||||
placeholder="Bijv. specifieke wensen, RAL-kleur, plaatsing..."
|
||||
value={formData.note ?? ""}
|
||||
onChange={(e) => updateData({ note: e.target.value })}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
89
components/offerte/step-extras.tsx
Normal file
89
components/offerte/step-extras.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useConfiguratorStore } from "@/lib/store";
|
||||
import { Check, Ruler, Wrench, MessageCircle, Truck } from "lucide-react";
|
||||
|
||||
const extraOptionsList = [
|
||||
{
|
||||
id: "Meetservice",
|
||||
label: "Meetservice",
|
||||
description: "Wij komen bij u langs om de exacte maten op te nemen.",
|
||||
icon: Ruler,
|
||||
},
|
||||
{
|
||||
id: "Montage",
|
||||
label: "Montage",
|
||||
description: "Professionele plaatsing door onze vakmensen.",
|
||||
icon: Wrench,
|
||||
},
|
||||
{
|
||||
id: "Adviesgesprek",
|
||||
label: "Adviesgesprek",
|
||||
description: "Vrijblijvend advies over mogelijkheden en materialen.",
|
||||
icon: MessageCircle,
|
||||
},
|
||||
{
|
||||
id: "Bezorging",
|
||||
label: "Bezorging",
|
||||
description: "Bezorging aan huis, of afhalen op locatie.",
|
||||
icon: Truck,
|
||||
},
|
||||
];
|
||||
|
||||
export function StepExtras() {
|
||||
const { extraOptions, toggleExtraOption } = useConfiguratorStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Extra opties</h2>
|
||||
<p className="mb-6 text-sm text-gray-600">
|
||||
Selecteer eventuele extra services bij uw stalen deur.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{extraOptionsList.map((option) => {
|
||||
const selected = extraOptions.includes(option.id);
|
||||
const Icon = option.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => toggleExtraOption(option.id)}
|
||||
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
|
||||
selected
|
||||
? "border-[#C4D668] bg-[#1A2E2E] text-white"
|
||||
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`flex size-10 shrink-0 items-center justify-center rounded-lg ${
|
||||
selected ? "bg-[#C4D668]/20" : "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`size-5 ${selected ? "text-[#C4D668]" : "text-[#1A2E2E]"}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold">{option.label}</h3>
|
||||
<p className={`mt-1 text-sm ${selected ? "text-white/80" : "text-gray-500"}`}>
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`flex size-6 shrink-0 items-center justify-center rounded-md border-2 transition-all ${
|
||||
selected
|
||||
? "border-[#C4D668] bg-[#C4D668]"
|
||||
: "border-gray-300 bg-white"
|
||||
}`}
|
||||
>
|
||||
{selected && <Check className="size-4 text-[#1A2E2E]" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useConfiguratorStore, type Finish, type Handle } from "@/lib/store";
|
||||
import { useConfiguratorStore, type Finish, type GlassColor, type Handle, type FrameSize } from "@/lib/store";
|
||||
import { glassPatternOptions, type GlassPattern } from "@/lib/glass-patterns";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
// ============================================
|
||||
// OPTIONS DATA
|
||||
// ============================================
|
||||
|
||||
const finishOptions: Array<{
|
||||
value: Finish;
|
||||
label: string;
|
||||
description: string;
|
||||
swatch: string;
|
||||
}> = [
|
||||
{ value: "zwart", label: "Mat Zwart", description: "Klassiek en tijdloos" },
|
||||
{
|
||||
value: "brons",
|
||||
label: "Brons",
|
||||
description: "Warm en industrieel",
|
||||
},
|
||||
{ value: "grijs", label: "Antraciet", description: "Modern en neutraal" },
|
||||
{ value: "zwart", label: "Mat Zwart", description: "Klassiek en tijdloos", swatch: "#1A1A1A" },
|
||||
{ value: "brons", label: "Brons", description: "Warm en industrieel", swatch: "#8B6F47" },
|
||||
{ value: "grijs", label: "Antraciet", description: "Modern en neutraal", swatch: "#525252" },
|
||||
{ value: "goud", label: "Goud", description: "Luxe en opvallend", swatch: "#B8860B" },
|
||||
{ value: "beige", label: "Beige", description: "Zacht en natuurlijk", swatch: "#C8B88A" },
|
||||
{ value: "ral", label: "RAL Kleur", description: "Op maat, +EUR 200", swatch: "#4A6741" },
|
||||
];
|
||||
|
||||
const glassColorOptions: Array<{
|
||||
value: GlassColor;
|
||||
label: string;
|
||||
description: string;
|
||||
swatch: string;
|
||||
}> = [
|
||||
{ value: "helder", label: "Helder", description: "Maximale transparantie", swatch: "#dbeafe" },
|
||||
{ value: "grijs", label: "Rookglas", description: "Getint grijs glas", swatch: "#4B5563" },
|
||||
{ value: "brons", label: "Bronsglas", description: "Warm getint glas", swatch: "#92764A" },
|
||||
{ value: "mat-blank", label: "Mat Blank", description: "Zacht diffuus licht", swatch: "#e2e2e2" },
|
||||
{ value: "mat-brons", label: "Mat Brons", description: "Warm en gedempd", swatch: "#A0845C" },
|
||||
{ value: "mat-zwart", label: "Mat Zwart", description: "Privacy glas", swatch: "#2D2D2D" },
|
||||
];
|
||||
|
||||
const handleOptions: Array<{
|
||||
@@ -23,46 +41,69 @@ const handleOptions: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "beugelgreep",
|
||||
label: "Beugelgreep",
|
||||
description: "Verticale staaf met montageblokken",
|
||||
},
|
||||
{
|
||||
value: "hoekgreep",
|
||||
label: "Hoekgreep",
|
||||
description: "L-vormige minimalistisch design",
|
||||
},
|
||||
{
|
||||
value: "maangreep",
|
||||
label: "Maangreep",
|
||||
description: "Gebogen half-maanvormige greep",
|
||||
},
|
||||
{
|
||||
value: "ovaalgreep",
|
||||
label: "Ovaalgreep",
|
||||
description: "Moderne ovale trekgreep",
|
||||
},
|
||||
{
|
||||
value: "klink",
|
||||
label: "Deurklink",
|
||||
description: "Klassieke deurklink met hendel",
|
||||
},
|
||||
{
|
||||
value: "u-greep",
|
||||
label: "U-Greep",
|
||||
description: "Eenvoudige rechte staaf",
|
||||
},
|
||||
{
|
||||
value: "geen",
|
||||
label: "Geen greep",
|
||||
description: "Voor vaste panelen",
|
||||
},
|
||||
{ value: "beugelgreep", label: "Beugelgreep", description: "Verticale staaf met montageblokken" },
|
||||
{ value: "hoekgreep", label: "Hoekgreep", description: "L-vormige minimalistisch design" },
|
||||
{ value: "maangreep", label: "Maangreep", description: "Gebogen half-maanvormige greep" },
|
||||
{ value: "ovaalgreep", label: "Ovaalgreep", description: "Moderne ovale trekgreep" },
|
||||
{ value: "klink", label: "Deurklink", description: "Klassieke deurklink met hendel" },
|
||||
{ value: "u-greep", label: "U-Greep", description: "Eenvoudige rechte staaf" },
|
||||
{ value: "geen", label: "Geen greep", description: "Voor vaste panelen" },
|
||||
];
|
||||
|
||||
const frameSizeOptions: Array<{
|
||||
value: FrameSize;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{ value: 20, label: "Smal (20mm)", description: "Minimalistisch profiel" },
|
||||
{ value: 30, label: "Standaard (30mm)", description: "Populairste keuze" },
|
||||
{ value: 40, label: "Robuust (40mm)", description: "Industrieel karakter" },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// SHARED SELECTION COMPONENTS
|
||||
// ============================================
|
||||
|
||||
function SelectionButton({
|
||||
selected,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
|
||||
selected
|
||||
? "border-[#C4D668] bg-[#1A2E2E] text-white"
|
||||
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
{children}
|
||||
{selected && (
|
||||
<div className="ml-2 flex size-6 shrink-0 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-4 text-[#1A2E2E]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================
|
||||
|
||||
export function StepOptions() {
|
||||
const { finish, handle, glassPattern, setFinish, setHandle, setGlassPattern } =
|
||||
useConfiguratorStore();
|
||||
const {
|
||||
finish, handle, glassPattern, glassColor, frameSize,
|
||||
setFinish, setHandle, setGlassPattern, setGlassColor, setFrameSize,
|
||||
} = useConfiguratorStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -73,52 +114,113 @@ export function StepOptions() {
|
||||
Kies de kleur en afwerking van het staal.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{finishOptions.map((option) => {
|
||||
const selected = finish === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setFinish(option.value)}
|
||||
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
|
||||
className={`group relative flex flex-col items-center rounded-xl border-2 p-3 transition-all ${
|
||||
selected
|
||||
? "border-[#C4D668] bg-[#1A2E2E] text-white"
|
||||
? "border-[#C4D668] bg-[#1A2E2E] text-white ring-2 ring-[#C4D668]"
|
||||
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
{/* Color swatch */}
|
||||
<div
|
||||
className="size-10 rounded-lg border-2 border-white shadow-md"
|
||||
style={{
|
||||
backgroundColor:
|
||||
option.value === "zwart"
|
||||
? "#1A1A1A"
|
||||
: option.value === "brons"
|
||||
? "#8B6F47"
|
||||
: "#4A5568",
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold">{option.label}</h3>
|
||||
<p
|
||||
className={`mt-1 text-sm ${
|
||||
selected ? "text-white/80" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="mb-2 size-10 rounded-lg border-2 border-white shadow-md"
|
||||
style={{ backgroundColor: option.swatch }}
|
||||
/>
|
||||
<h3 className="text-sm font-bold">{option.label}</h3>
|
||||
<p className={`mt-0.5 text-xs ${selected ? "text-white/70" : "text-gray-500"}`}>
|
||||
{option.description}
|
||||
</p>
|
||||
{selected && (
|
||||
<div className="absolute right-2 top-2 flex size-5 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-3 text-[#1A2E2E]" />
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-4 text-[#1A2E2E]" />
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Glass Color Selection */}
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Glaskleur</h2>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Kies het type en de kleur van het glas.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{glassColorOptions.map((option) => {
|
||||
const selected = glassColor === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setGlassColor(option.value)}
|
||||
className={`group relative flex flex-col items-center rounded-xl border-2 p-3 transition-all ${
|
||||
selected
|
||||
? "border-[#C4D668] bg-[#1A2E2E] text-white ring-2 ring-[#C4D668]"
|
||||
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="mb-2 size-8 rounded-full border-2 border-white shadow-md"
|
||||
style={{ backgroundColor: option.swatch }}
|
||||
/>
|
||||
<h3 className="text-xs font-bold">{option.label}</h3>
|
||||
{selected && (
|
||||
<div className="absolute right-1.5 top-1.5 flex size-4 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-2.5 text-[#1A2E2E]" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frame Size Selection */}
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Profielbreedte</h2>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Kies de breedte van het stalen profiel.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{frameSizeOptions.map((option) => {
|
||||
const selected = frameSize === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setFrameSize(option.value)}
|
||||
className={`group relative flex flex-col items-center rounded-xl border-2 px-2 py-3 transition-all ${
|
||||
selected
|
||||
? "border-[#C4D668] bg-[#1A2E2E] text-white ring-2 ring-[#C4D668]"
|
||||
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
|
||||
}`}
|
||||
>
|
||||
{/* Visual profile width indicator */}
|
||||
<div className="mb-2 flex h-12 items-center justify-center">
|
||||
<div
|
||||
className={`rounded-sm ${selected ? "bg-[#C4D668]" : "bg-[#1A2E2E]"}`}
|
||||
style={{ width: `${option.value * 0.4}px`, height: "40px" }}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-xs font-bold">{option.label}</h3>
|
||||
<p className={`mt-0.5 text-[10px] ${selected ? "text-white/70" : "text-gray-500"}`}>
|
||||
{option.description}
|
||||
</p>
|
||||
{selected && (
|
||||
<div className="absolute right-1.5 top-1.5 flex size-4 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-2.5 text-[#1A2E2E]" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -135,36 +237,19 @@ export function StepOptions() {
|
||||
<div className="grid gap-3">
|
||||
{glassPatternOptions.map((option) => {
|
||||
const selected = glassPattern === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
<SelectionButton
|
||||
key={option.value}
|
||||
type="button"
|
||||
selected={selected}
|
||||
onClick={() => setGlassPattern(option.value)}
|
||||
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
|
||||
selected
|
||||
? "border-[#C4D668] bg-[#1A2E2E] text-white"
|
||||
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold">{option.label}</h3>
|
||||
<p
|
||||
className={`mt-1 text-sm ${
|
||||
selected ? "text-white/80" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-4 text-[#1A2E2E]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold">{option.label}</h3>
|
||||
<p className={`mt-1 text-sm ${selected ? "text-white/80" : "text-gray-500"}`}>
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</SelectionButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -180,36 +265,19 @@ export function StepOptions() {
|
||||
<div className="grid gap-3">
|
||||
{handleOptions.map((option) => {
|
||||
const selected = handle === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
<SelectionButton
|
||||
key={option.value}
|
||||
type="button"
|
||||
selected={selected}
|
||||
onClick={() => setHandle(option.value)}
|
||||
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
|
||||
selected
|
||||
? "border-[#C4D668] bg-[#1A2E2E] text-white"
|
||||
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold">{option.label}</h3>
|
||||
<p
|
||||
className={`mt-1 text-sm ${
|
||||
selected ? "text-white/80" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-4 text-[#1A2E2E]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold">{option.label}</h3>
|
||||
<p className={`mt-1 text-sm ${selected ? "text-white/80" : "text-gray-500"}`}>
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</SelectionButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useConfiguratorStore, type DoorType } from "@/lib/store";
|
||||
import { useConfiguratorStore, type DoorType, type GridType } from "@/lib/store";
|
||||
import { useFormContext } from "@/components/offerte/form-context";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
// Door type visual icons (inline SVGs)
|
||||
// ============================================
|
||||
// DOOR TYPE ICONS (SVG)
|
||||
// ============================================
|
||||
|
||||
function TaatsIcon({ selected }: { selected: boolean }) {
|
||||
const stroke = selected ? "#C4D668" : "#1A2E2E";
|
||||
const fill = selected ? "#C4D668" : "none";
|
||||
return (
|
||||
<svg viewBox="0 0 64 80" className="h-20 w-16">
|
||||
{/* Door frame */}
|
||||
<rect x="8" y="4" width="48" height="72" rx="2" fill="none" stroke={stroke} strokeWidth="2" />
|
||||
{/* Glass */}
|
||||
<rect x="14" y="10" width="36" height="60" rx="1" fill={selected ? "#C4D668" : "#e5e7eb"} opacity="0.2" />
|
||||
{/* Pivot point (center) */}
|
||||
<circle cx="32" cy="40" r="3" fill={fill} stroke={stroke} strokeWidth="1.5" />
|
||||
{/* Rotation arrow */}
|
||||
<path d="M 44 20 A 16 16 0 0 1 44 60" fill="none" stroke={stroke} strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
<polygon points="44,58 48,54 40,54" fill={stroke} />
|
||||
</svg>
|
||||
@@ -28,14 +27,10 @@ function ScharnierIcon({ selected }: { selected: boolean }) {
|
||||
const fill = selected ? "#C4D668" : "none";
|
||||
return (
|
||||
<svg viewBox="0 0 64 80" className="h-20 w-16">
|
||||
{/* Door frame */}
|
||||
<rect x="8" y="4" width="48" height="72" rx="2" fill="none" stroke={stroke} strokeWidth="2" />
|
||||
{/* Glass */}
|
||||
<rect x="14" y="10" width="36" height="60" rx="1" fill={selected ? "#C4D668" : "#e5e7eb"} opacity="0.2" />
|
||||
{/* Hinge dots on left side */}
|
||||
<circle cx="10" cy="20" r="2.5" fill={fill} stroke={stroke} strokeWidth="1.5" />
|
||||
<circle cx="10" cy="60" r="2.5" fill={fill} stroke={stroke} strokeWidth="1.5" />
|
||||
{/* Rotation arrow from hinge side */}
|
||||
<path d="M 56 18 A 24 24 0 0 1 56 62" fill="none" stroke={stroke} strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
<polygon points="56,60 60,56 52,56" fill={stroke} />
|
||||
</svg>
|
||||
@@ -46,11 +41,8 @@ function PaneelIcon({ selected }: { selected: boolean }) {
|
||||
const stroke = selected ? "#C4D668" : "#1A2E2E";
|
||||
return (
|
||||
<svg viewBox="0 0 64 80" className="h-20 w-16">
|
||||
{/* Door frame */}
|
||||
<rect x="8" y="4" width="48" height="72" rx="2" fill="none" stroke={stroke} strokeWidth="2" />
|
||||
{/* Glass */}
|
||||
<rect x="14" y="10" width="36" height="60" rx="1" fill={selected ? "#C4D668" : "#e5e7eb"} opacity="0.2" />
|
||||
{/* Fixed indicator - lock symbol */}
|
||||
<rect x="26" y="34" width="12" height="12" rx="2" fill="none" stroke={stroke} strokeWidth="1.5" />
|
||||
<circle cx="32" cy="34" r="5" fill="none" stroke={stroke} strokeWidth="1.5" />
|
||||
</svg>
|
||||
@@ -63,70 +55,149 @@ const doorTypeIcons: Record<DoorType, (props: { selected: boolean }) => React.Re
|
||||
paneel: PaneelIcon,
|
||||
};
|
||||
|
||||
// Grid type visual illustrations (CSS-based rectangles with dividers)
|
||||
function GridIllustration({ dividers, selected }: { dividers: number; selected: boolean }) {
|
||||
const borderColor = selected ? "border-[#C4D668]" : "border-[#1A2E2E]/40";
|
||||
const dividerBg = selected ? "bg-[#C4D668]" : "bg-[#1A2E2E]/30";
|
||||
const glassBg = selected ? "bg-[#C4D668]/10" : "bg-gray-100";
|
||||
// ============================================
|
||||
// GRID PATTERN SVG ILLUSTRATIONS
|
||||
// ============================================
|
||||
|
||||
return (
|
||||
<div className={`flex h-20 w-14 flex-col overflow-hidden rounded border-2 ${borderColor}`}>
|
||||
{dividers === 0 && (
|
||||
<div className={`flex-1 ${glassBg}`} />
|
||||
)}
|
||||
{dividers > 0 &&
|
||||
Array.from({ length: dividers + 1 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-1 flex-col">
|
||||
{i > 0 && <div className={`h-[2px] shrink-0 ${dividerBg}`} />}
|
||||
<div className={`flex-1 ${glassBg}`} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
function GridSVG({ pattern, selected }: { pattern: GridType; selected: boolean }) {
|
||||
const stroke = selected ? "#C4D668" : "#1A2E2E";
|
||||
const glass = selected ? "#C4D668" : "#e5e7eb";
|
||||
const opacity = selected ? 0.15 : 0.3;
|
||||
const sw = 1.5;
|
||||
|
||||
// Frame: outer rect with inner glass area
|
||||
const frame = (children: React.ReactNode) => (
|
||||
<svg viewBox="0 0 40 60" className="h-14 w-10">
|
||||
<rect x="2" y="2" width="36" height="56" rx="1" fill="none" stroke={stroke} strokeWidth={sw} />
|
||||
<rect x="5" y="5" width="30" height="50" fill={glass} opacity={opacity} />
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
switch (pattern) {
|
||||
case "geen":
|
||||
return frame(null);
|
||||
|
||||
case "2-vlak":
|
||||
return frame(
|
||||
<line x1="5" y1="30" x2="35" y2="30" stroke={stroke} strokeWidth={sw} />
|
||||
);
|
||||
|
||||
case "3-vlak":
|
||||
return frame(
|
||||
<>
|
||||
<line x1="5" y1="22" x2="35" y2="22" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="5" y1="38" x2="35" y2="38" stroke={stroke} strokeWidth={sw} />
|
||||
</>
|
||||
);
|
||||
|
||||
case "4-vlak":
|
||||
return frame(
|
||||
<>
|
||||
<line x1="5" y1="18" x2="35" y2="18" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="5" y1="30" x2="35" y2="30" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="5" y1="42" x2="35" y2="42" stroke={stroke} strokeWidth={sw} />
|
||||
</>
|
||||
);
|
||||
|
||||
case "6-vlak":
|
||||
return frame(
|
||||
<>
|
||||
<line x1="5" y1="22" x2="35" y2="22" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="5" y1="38" x2="35" y2="38" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="20" y1="5" x2="20" y2="55" stroke={stroke} strokeWidth={sw} />
|
||||
</>
|
||||
);
|
||||
|
||||
case "8-vlak":
|
||||
return frame(
|
||||
<>
|
||||
<line x1="5" y1="18" x2="35" y2="18" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="5" y1="30" x2="35" y2="30" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="5" y1="42" x2="35" y2="42" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="20" y1="5" x2="20" y2="55" stroke={stroke} strokeWidth={sw} />
|
||||
</>
|
||||
);
|
||||
|
||||
case "kruis":
|
||||
return frame(
|
||||
<>
|
||||
<line x1="5" y1="30" x2="35" y2="30" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="20" y1="5" x2="20" y2="55" stroke={stroke} strokeWidth={sw} />
|
||||
</>
|
||||
);
|
||||
|
||||
case "ongelijk-3":
|
||||
return frame(
|
||||
<>
|
||||
<line x1="5" y1="24" x2="35" y2="24" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="5" y1="40" x2="35" y2="40" stroke={stroke} strokeWidth={sw} />
|
||||
</>
|
||||
);
|
||||
|
||||
case "boerderij":
|
||||
return frame(
|
||||
<>
|
||||
<line x1="5" y1="20" x2="35" y2="20" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="20" y1="20" x2="20" y2="55" stroke={stroke} strokeWidth={sw} />
|
||||
</>
|
||||
);
|
||||
|
||||
case "herenhuis":
|
||||
return frame(
|
||||
<>
|
||||
<line x1="5" y1="18" x2="35" y2="18" stroke={stroke} strokeWidth={sw} />
|
||||
<line x1="5" y1="40" x2="35" y2="40" stroke={stroke} strokeWidth={sw} />
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return frame(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DATA
|
||||
// ============================================
|
||||
|
||||
const doorTypes: Array<{
|
||||
value: DoorType;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "taats",
|
||||
label: "Taatsdeur",
|
||||
description: "Pivoterende deur",
|
||||
},
|
||||
{
|
||||
value: "scharnier",
|
||||
label: "Scharnierdeur",
|
||||
description: "Zijscharnieren",
|
||||
},
|
||||
{
|
||||
value: "paneel",
|
||||
label: "Vast Paneel",
|
||||
description: "Geen beweging",
|
||||
},
|
||||
{ value: "taats", label: "Taatsdeur", description: "Pivoterende deur" },
|
||||
{ value: "scharnier", label: "Scharnierdeur", description: "Zijscharnieren" },
|
||||
{ value: "paneel", label: "Vast Paneel", description: "Geen beweging" },
|
||||
];
|
||||
|
||||
const gridTypes: Array<{
|
||||
value: "3-vlak" | "4-vlak" | "geen";
|
||||
value: GridType;
|
||||
label: string;
|
||||
description: string;
|
||||
dividers: number;
|
||||
}> = [
|
||||
{ value: "geen", label: "Geen", description: "Volledig vlak", dividers: 0 },
|
||||
{ value: "3-vlak", label: "3-vlaks", description: "2 balken", dividers: 2 },
|
||||
{ value: "4-vlak", label: "4-vlaks", description: "3 balken", dividers: 3 },
|
||||
{ value: "geen", label: "Geen", description: "Volledig vlak" },
|
||||
{ value: "2-vlak", label: "2-vlaks", description: "1 balk" },
|
||||
{ value: "3-vlak", label: "3-vlaks", description: "2 balken" },
|
||||
{ value: "4-vlak", label: "4-vlaks", description: "3 balken" },
|
||||
{ value: "kruis", label: "Kruis", description: "1H + 1V" },
|
||||
{ value: "6-vlak", label: "6-vlaks", description: "2H + 1V" },
|
||||
{ value: "8-vlak", label: "8-vlaks", description: "3H + 1V" },
|
||||
{ value: "ongelijk-3", label: "Ongelijk", description: "3 ongelijk" },
|
||||
{ value: "boerderij", label: "Boerderij", description: "2+2 onder" },
|
||||
{ value: "herenhuis", label: "Herenhuis", description: "3 horizontaal" },
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================
|
||||
|
||||
export function StepProduct() {
|
||||
const { nextStep } = useFormContext();
|
||||
const { doorType, gridType, setDoorType, setGridType } =
|
||||
useConfiguratorStore();
|
||||
const { doorType, gridType, setDoorType, setGridType } = useConfiguratorStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Door Type Selection - Visual Tiles */}
|
||||
{/* Door Type Selection */}
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Kies uw deurtype</h2>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
@@ -153,11 +224,7 @@ export function StepProduct() {
|
||||
<IconComponent selected={selected} />
|
||||
</div>
|
||||
<h3 className="text-sm font-bold">{type.label}</h3>
|
||||
<p
|
||||
className={`mt-1 text-xs ${
|
||||
selected ? "text-white/70" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<p className={`mt-1 text-xs ${selected ? "text-white/70" : "text-gray-500"}`}>
|
||||
{type.description}
|
||||
</p>
|
||||
{selected && (
|
||||
@@ -171,14 +238,14 @@ export function StepProduct() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Type Selection - Visual Tiles */}
|
||||
{/* Grid Type Selection - 10 patterns */}
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Verdeling</h2>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Kies het aantal horizontale vlakken.
|
||||
Kies het patroon van de glasverdeling.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{gridTypes.map((type) => {
|
||||
const selected = gridType === type.value;
|
||||
|
||||
@@ -187,26 +254,22 @@ export function StepProduct() {
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => setGridType(type.value)}
|
||||
className={`group relative flex flex-col items-center rounded-xl border-2 px-2 py-4 transition-all ${
|
||||
className={`group relative flex flex-col items-center rounded-xl border-2 px-1 py-3 transition-all ${
|
||||
selected
|
||||
? "border-[#C4D668] bg-[#1A2E2E] text-white ring-2 ring-[#C4D668] shadow-lg shadow-[#C4D668]/20"
|
||||
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/20 hover:shadow-md"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-center">
|
||||
<GridIllustration dividers={type.dividers} selected={selected} />
|
||||
<div className="mb-2 flex items-center justify-center">
|
||||
<GridSVG pattern={type.value} selected={selected} />
|
||||
</div>
|
||||
<h3 className="text-sm font-bold">{type.label}</h3>
|
||||
<p
|
||||
className={`mt-1 text-xs ${
|
||||
selected ? "text-white/70" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-[10px] font-bold leading-tight">{type.label}</h3>
|
||||
<p className={`text-[9px] leading-tight ${selected ? "text-white/70" : "text-gray-500"}`}>
|
||||
{type.description}
|
||||
</p>
|
||||
{selected && (
|
||||
<div className="absolute right-2 top-2 flex size-5 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-3 text-[#1A2E2E]" />
|
||||
<div className="absolute right-1 top-1 flex size-4 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-2.5 text-[#1A2E2E]" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1,83 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useFormContext } from "@/components/offerte/form-context";
|
||||
import { useState } from "react";
|
||||
import { useConfiguratorStore } from "@/lib/store";
|
||||
import { sendQuoteAction } from "@/actions/send-quote";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Send, Check } from "lucide-react";
|
||||
import { Send, Check, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
productType: "Product",
|
||||
height: "Hoogte",
|
||||
width: "Breedte",
|
||||
glassType: "Glas Type",
|
||||
finish: "Afwerking",
|
||||
name: "Naam",
|
||||
email: "E-mail",
|
||||
phone: "Telefoon",
|
||||
note: "Opmerking",
|
||||
// ============================================
|
||||
// LABEL MAPS
|
||||
// ============================================
|
||||
|
||||
const DOOR_TYPE_LABELS: Record<string, string> = {
|
||||
taats: "Taatsdeur",
|
||||
scharnier: "Scharnierdeur",
|
||||
paneel: "Vast Paneel",
|
||||
};
|
||||
|
||||
const fieldOrder = [
|
||||
"productType",
|
||||
"height",
|
||||
"width",
|
||||
"glassType",
|
||||
"finish",
|
||||
"name",
|
||||
"email",
|
||||
"phone",
|
||||
"note",
|
||||
];
|
||||
const CONFIG_LABELS: Record<string, string> = {
|
||||
enkele: "Enkele deur",
|
||||
dubbele: "Dubbele deur",
|
||||
};
|
||||
|
||||
function formatValue(key: string, value: unknown): string {
|
||||
if (value === undefined || value === null || value === "") return "—";
|
||||
if (key === "height" || key === "width") return `${value} mm`;
|
||||
return String(value);
|
||||
const SIDE_PANEL_LABELS: Record<string, string> = {
|
||||
geen: "Geen",
|
||||
links: "Links",
|
||||
rechts: "Rechts",
|
||||
beide: "Beide zijden",
|
||||
};
|
||||
|
||||
const FINISH_LABELS: Record<string, string> = {
|
||||
zwart: "Mat Zwart",
|
||||
brons: "Brons",
|
||||
grijs: "Antraciet",
|
||||
goud: "Goud",
|
||||
beige: "Beige",
|
||||
ral: "RAL Kleur",
|
||||
};
|
||||
|
||||
const GLASS_COLOR_LABELS: Record<string, string> = {
|
||||
helder: "Helder",
|
||||
grijs: "Rookglas",
|
||||
brons: "Bronsglas",
|
||||
"mat-blank": "Mat Blank",
|
||||
"mat-brons": "Mat Brons",
|
||||
"mat-zwart": "Mat Zwart",
|
||||
};
|
||||
|
||||
const HANDLE_LABELS: Record<string, string> = {
|
||||
beugelgreep: "Beugelgreep",
|
||||
hoekgreep: "Hoekgreep",
|
||||
maangreep: "Maangreep",
|
||||
ovaalgreep: "Ovaalgreep",
|
||||
klink: "Deurklink",
|
||||
"u-greep": "U-Greep",
|
||||
geen: "Geen greep",
|
||||
};
|
||||
|
||||
const PATTERN_LABELS: Record<string, string> = {
|
||||
standard: "Standaard",
|
||||
"dt9-rounded": "DT9 Afgerond",
|
||||
"dt10-ushape": "DT10 U-vorm",
|
||||
};
|
||||
|
||||
function formatPrice(amount: number): string {
|
||||
return new Intl.NumberFormat("nl-NL", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COMPONENTS
|
||||
// ============================================
|
||||
|
||||
function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
<span className={`text-sm font-medium ${highlight ? "text-[#C4D668]" : "text-[#1A2E2E]"}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummarySection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4">
|
||||
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-gray-400">{title}</h3>
|
||||
<div className="divide-y divide-gray-100">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceRow({ label, amount, bold }: { label: string; amount: number; bold?: boolean }) {
|
||||
if (amount === 0) return null;
|
||||
return (
|
||||
<div className={`flex items-center justify-between py-1.5 ${bold ? "text-base" : "text-sm"}`}>
|
||||
<span className={bold ? "font-bold text-[#1A2E2E]" : "text-gray-500"}>{label}</span>
|
||||
<span className={bold ? "font-bold text-[#1A2E2E]" : "text-gray-700"}>
|
||||
{formatPrice(amount)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================
|
||||
|
||||
export function StepSummary() {
|
||||
const { formData } = useFormContext();
|
||||
const store = useConfiguratorStore();
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
const {
|
||||
doorType, gridType, doorConfig, sidePanel,
|
||||
width, height, doorLeafWidth,
|
||||
finish, glassColor, handle, frameSize, glassPattern,
|
||||
extraOptions,
|
||||
name, email, phone, note,
|
||||
priceBreakdown, screenshotDataUrl,
|
||||
} = store;
|
||||
|
||||
const canSubmit = name.length >= 2 && email.includes("@") && phone.length >= 10;
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return;
|
||||
setStatus("loading");
|
||||
setErrorMsg("");
|
||||
|
||||
const result = await sendQuoteAction({
|
||||
doorType, gridType, doorConfig, sidePanel,
|
||||
width, height, doorLeafWidth,
|
||||
finish, glassColor, handle, frameSize, glassPattern,
|
||||
extraOptions,
|
||||
name, email, phone, note,
|
||||
totalPrice: priceBreakdown.totalPrice,
|
||||
steelCost: priceBreakdown.steelCost,
|
||||
glassCost: priceBreakdown.glassCost,
|
||||
baseFee: priceBreakdown.baseFee,
|
||||
mechanismSurcharge: priceBreakdown.mechanismSurcharge,
|
||||
sidePanelSurcharge: priceBreakdown.sidePanelSurcharge,
|
||||
handleCost: priceBreakdown.handleCost,
|
||||
finishSurcharge: priceBreakdown.finishSurcharge,
|
||||
screenshotDataUrl,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setStatus("success");
|
||||
} else {
|
||||
setStatus("error");
|
||||
setErrorMsg(result.error || "Onbekende fout");
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<div className="mb-4 flex size-16 items-center justify-center rounded-full bg-green-100">
|
||||
<CheckCircle2 className="size-8 text-green-600" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-bold text-[#1A2E2E]">Aanvraag Verstuurd!</h2>
|
||||
<p className="mb-1 text-sm text-gray-600">
|
||||
Bedankt {name}, uw offerte aanvraag is ontvangen.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
We sturen een bevestiging naar {email}.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-xl font-semibold">Overzicht</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Controleer uw configuratie en verstuur de aanvraag.
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{fieldOrder.map((key, i) => {
|
||||
const value = formData[key as keyof typeof formData];
|
||||
return (
|
||||
<tr
|
||||
key={key}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : "bg-card"}
|
||||
>
|
||||
<td className="w-1/3 px-4 py-3 font-medium text-muted-foreground">
|
||||
{fieldLabels[key]}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
{value !== undefined && value !== "" && (
|
||||
<Check className="size-3.5 text-green-600" />
|
||||
)}
|
||||
{formatValue(key, value)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="mb-1 text-xl font-bold text-[#1A2E2E]">Overzicht</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Controleer uw configuratie en verstuur de aanvraag.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Product Section */}
|
||||
<SummarySection title="Product">
|
||||
<SummaryRow label="Deurtype" value={DOOR_TYPE_LABELS[doorType] || doorType} />
|
||||
<SummaryRow label="Verdeling" value={gridType} />
|
||||
<SummaryRow label="Configuratie" value={CONFIG_LABELS[doorConfig] || doorConfig} />
|
||||
<SummaryRow label="Zijpanelen" value={SIDE_PANEL_LABELS[sidePanel] || sidePanel} />
|
||||
</SummarySection>
|
||||
|
||||
{/* Dimensions Section */}
|
||||
<SummarySection title="Afmetingen">
|
||||
<SummaryRow label="Wandopening (breedte)" value={`${width} mm`} />
|
||||
<SummaryRow label="Hoogte" value={`${height} mm`} />
|
||||
<SummaryRow label="Deurblad breedte" value={`${Math.round(doorLeafWidth)} mm`} />
|
||||
</SummarySection>
|
||||
|
||||
{/* Style Section */}
|
||||
<SummarySection title="Stijl">
|
||||
<SummaryRow label="Afwerking" value={FINISH_LABELS[finish] || finish} />
|
||||
<SummaryRow label="Glaskleur" value={GLASS_COLOR_LABELS[glassColor] || glassColor} />
|
||||
<SummaryRow label="Greep" value={HANDLE_LABELS[handle] || handle} />
|
||||
<SummaryRow label="Profielbreedte" value={`${frameSize} mm`} />
|
||||
<SummaryRow label="Glaspatroon" value={PATTERN_LABELS[glassPattern] || glassPattern} />
|
||||
</SummarySection>
|
||||
|
||||
{/* Extra Options */}
|
||||
{extraOptions.length > 0 && (
|
||||
<SummarySection title="Extra opties">
|
||||
{extraOptions.map((opt) => (
|
||||
<div key={opt} className="flex items-center gap-2 py-1.5">
|
||||
<Check className="size-3.5 text-green-500" />
|
||||
<span className="text-sm text-[#1A2E2E]">{opt}</span>
|
||||
</div>
|
||||
))}
|
||||
</SummarySection>
|
||||
)}
|
||||
|
||||
{/* Price Breakdown */}
|
||||
<div className="rounded-xl border-2 border-[#1A2E2E] bg-gradient-to-b from-white to-gray-50 p-4">
|
||||
<h3 className="mb-3 text-sm font-bold uppercase tracking-wider text-gray-400">
|
||||
Indicatieprijs
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
<PriceRow label="Staal" amount={priceBreakdown.steelCost} />
|
||||
<PriceRow label="Glas" amount={priceBreakdown.glassCost} />
|
||||
<PriceRow label="Basiskost" amount={priceBreakdown.baseFee} />
|
||||
<PriceRow label="Mechanisme toeslag" amount={priceBreakdown.mechanismSurcharge} />
|
||||
<PriceRow label="Zijpaneel toeslag" amount={priceBreakdown.sidePanelSurcharge} />
|
||||
<PriceRow label="Greep" amount={priceBreakdown.handleCost} />
|
||||
<PriceRow label="Kleur toeslag" amount={priceBreakdown.finishSurcharge} />
|
||||
</div>
|
||||
<div className="mt-3 border-t-2 border-[#1A2E2E] pt-3">
|
||||
<PriceRow label="Totaal (indicatie)" amount={priceBreakdown.totalPrice} bold />
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-gray-400">
|
||||
* Dit is een indicatieprijs. De definitieve prijs wordt bepaald na opmeting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Summary */}
|
||||
<SummarySection title="Contactgegevens">
|
||||
<SummaryRow label="Naam" value={name || "—"} />
|
||||
<SummaryRow label="E-mail" value={email || "—"} />
|
||||
<SummaryRow label="Telefoon" value={phone || "—"} />
|
||||
{note && <SummaryRow label="Opmerking" value={note} />}
|
||||
</SummarySection>
|
||||
|
||||
{/* Validation Warning */}
|
||||
{!canSubmit && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-700">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>Vul eerst uw contactgegevens in (stap 5) om de aanvraag te versturen.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{status === "error" && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-700">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
size="lg"
|
||||
className="mt-8 w-full bg-brand-orange text-white hover:bg-brand-orange/90"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || status === "loading"}
|
||||
className="w-full bg-[#C4D668] text-[#1A2E2E] hover:bg-[#b5c75a] disabled:opacity-50"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
Verzend Aanvraag
|
||||
{status === "loading" ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Verzenden...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="size-4" />
|
||||
Verzend Offerte Aanvraag
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user