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:
Ubuntu
2026-03-01 14:50:31 +00:00
parent 748a5814e7
commit 3d788740cb
110 changed files with 162553 additions and 13070 deletions

View File

@@ -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,
}}
>

View File

@@ -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>

View 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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);