feat: Production-ready configurator with Dutch standards, pricing & visual UI

- Update door-models.ts: 7mm VSG 33.1 safety glass, 15mm offset, Taats pivot 60mm
- Add pricing engine (lib/pricing.ts): steel €45/m + glass €140/m² + €650 base
- Wire reactive pricing into Zustand store on every config change
- Fix 3D materials: glass thickness 0.007m, corrected roughness/metalness
- Upgrade scene: apartment environment, wider contact shadows
- Add Dutch height presets: Renovatie 2015mm, Nieuwbouw 2315mm, Plafondhoog 2500mm
- Replace text buttons with visual SVG tiles for door type & grid selection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu
2026-02-14 01:11:55 +00:00
parent 9319750912
commit 87be70e78b
7 changed files with 349 additions and 83 deletions

View File

@@ -54,27 +54,25 @@ function SteelMaterialTextured({ color, finish }: { color: string; finish: strin
<meshStandardMaterial
map={texture}
color={color}
roughness={0.6}
metalness={0.7}
roughness={0.7}
metalness={0.6}
envMapIntensity={1.5}
/>
);
} catch (error) {
// Fallback to solid color if texture fails
return <SteelMaterialFallback color={color} />;
}
}
/**
* Fallback Steel Material (Solid Color)
* Used when textures fail to load or as initial state
*/
function SteelMaterialFallback({ color }: { color: string }) {
return (
<meshStandardMaterial
color={color}
roughness={0.6}
metalness={0.7}
roughness={0.7}
metalness={0.6}
envMapIntensity={1.5}
/>
);
@@ -88,12 +86,12 @@ const GlassMaterial = () => (
<meshPhysicalMaterial
transmission={0.98}
roughness={0.05}
thickness={0.5}
thickness={0.007}
ior={1.5}
color="#eff6ff"
transparent
opacity={0.98}
envMapIntensity={0.8}
envMapIntensity={1.0}
/>
);

View File

@@ -230,16 +230,16 @@ export function Scene3D() {
{/* Premium Studio Lighting */}
<Lighting />
{/* Studio Environment for photorealistic steel reflections */}
<Environment preset="studio" environmentIntensity={1.0} />
{/* Apartment Environment for warm, realistic steel reflections */}
<Environment preset="apartment" blur={0.6} environmentIntensity={1.2} />
{/* High-Resolution Contact Shadows for grounding */}
<ContactShadows
position={[0, 0.01, 0]}
opacity={0.6}
scale={15}
blur={2}
far={2}
scale={20}
blur={2.5}
far={4}
resolution={2048}
/>

View File

@@ -150,6 +150,44 @@ export function StepDimensions() {
/>
</div>
{/* Height Presets - Dutch Market Standards */}
<div>
<Label className="text-base font-bold text-[#1A2E2E]">
Standaard Hoogtes
</Label>
<p className="mb-3 text-sm text-gray-600">
Kies een standaard hoogte of stel handmatig in.
</p>
<div className="grid grid-cols-3 gap-2">
{[
{ label: 'Renovatie', value: 2015, desc: '201.5 cm' },
{ label: 'Nieuwbouw', value: 2315, desc: '231.5 cm' },
{ label: 'Plafondhoog', value: 2500, desc: '250+ cm' },
].map((preset) => {
const isActive = height === preset.value;
return (
<button
key={preset.value}
type="button"
onClick={() => setHeight(preset.value)}
className={`rounded-lg border-2 px-3 py-2.5 text-center transition-all ${
isActive
? 'border-[#C4D668] bg-[#1A2E2E] text-white shadow-md'
: 'border-gray-200 bg-white text-gray-700 hover:border-[#1A2E2E]/30 hover:shadow-sm'
}`}
>
<span className="block text-xs font-bold uppercase tracking-wide">
{preset.label}
</span>
<span className={`block font-mono text-sm ${isActive ? 'text-[#C4D668]' : 'text-gray-500'}`}>
{preset.desc}
</span>
</button>
);
})}
</div>
</div>
{/* Height Control */}
<div>
<div className="mb-4 flex items-end justify-between">

View File

@@ -4,6 +4,88 @@ import { useConfiguratorStore, type DoorType } from "@/lib/store";
import { useFormContext } from "@/components/offerte/form-context";
import { Check } from "lucide-react";
// Door type visual icons (inline SVGs)
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>
);
}
function ScharnierIcon({ 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" />
{/* 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>
);
}
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>
);
}
const doorTypeIcons: Record<DoorType, (props: { selected: boolean }) => React.ReactElement> = {
taats: TaatsIcon,
scharnier: ScharnierIcon,
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";
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>
);
}
const doorTypes: Array<{
value: DoorType;
label: string;
@@ -12,17 +94,17 @@ const doorTypes: Array<{
{
value: "taats",
label: "Taatsdeur",
description: "Pivoterende deur met verticaal draaimechanisme",
description: "Pivoterende deur",
},
{
value: "scharnier",
label: "Scharnierdeur",
description: "Klassieke deur met zijscharnieren",
description: "Zijscharnieren",
},
{
value: "paneel",
label: "Vast Paneel",
description: "Vast glaspaneel zonder bewegend mechanisme",
description: "Geen beweging",
},
];
@@ -30,10 +112,11 @@ const gridTypes: Array<{
value: "3-vlak" | "4-vlak" | "geen";
label: string;
description: string;
dividers: number;
}> = [
{ value: "geen", label: "Geen verdeling", description: "Volledig vlak" },
{ value: "3-vlak", label: "3-vlaks", description: "2 horizontale balken" },
{ value: "4-vlak", label: "4-vlaks", description: "3 horizontale balken" },
{ 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 },
];
export function StepProduct() {
@@ -41,73 +124,61 @@ export function StepProduct() {
const { doorType, gridType, setDoorType, setGridType } =
useConfiguratorStore();
function handleDoorTypeSelect(type: DoorType) {
setDoorType(type);
}
function handleGridTypeSelect(type: "3-vlak" | "4-vlak" | "geen") {
setGridType(type);
}
function handleContinue() {
nextStep();
}
return (
<div className="space-y-8">
{/* Door Type Selection */}
{/* Door Type Selection - Visual Tiles */}
<div>
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Kies uw deurtype</h2>
<p className="mb-4 text-sm text-gray-600">
Selecteer het type stalen deur dat u wilt configureren.
</p>
<div className="grid gap-3">
<div className="grid grid-cols-3 gap-3">
{doorTypes.map((type) => {
const selected = doorType === type.value;
const IconComponent = doorTypeIcons[type.value];
return (
<button
key={type.value}
type="button"
onClick={() => handleDoorTypeSelect(type.value)}
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
onClick={() => setDoorType(type.value)}
className={`group relative flex flex-col items-center rounded-xl border-2 px-2 py-4 transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
? "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="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-bold">{type.label}</h3>
<p
className={`mt-1 text-sm ${
selected ? "text-white/80" : "text-gray-500"
}`}
>
{type.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="mb-3">
<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"
}`}
>
{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>
)}
</button>
);
})}
</div>
</div>
{/* Grid Type Selection */}
{/* Grid Type Selection - Visual Tiles */}
<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.
</p>
<div className="grid gap-3">
<div className="grid grid-cols-3 gap-3">
{gridTypes.map((type) => {
const selected = gridType === type.value;
@@ -115,30 +186,29 @@ export function StepProduct() {
<button
key={type.value}
type="button"
onClick={() => handleGridTypeSelect(type.value)}
className={`group relative rounded-xl border-2 p-4 text-left transition-all ${
onClick={() => setGridType(type.value)}
className={`group relative flex flex-col items-center rounded-xl border-2 px-2 py-4 transition-all ${
selected
? "border-[#C4D668] bg-[#1A2E2E] text-white"
: "border-gray-200 bg-white text-gray-900 hover:border-[#1A2E2E]/30"
? "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="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-bold">{type.label}</h3>
<p
className={`mt-1 text-sm ${
selected ? "text-white/80" : "text-gray-500"
}`}
>
{type.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="mb-3 flex items-center justify-center">
<GridIllustration dividers={type.dividers} 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"
}`}
>
{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>
)}
</button>
);
})}
@@ -147,7 +217,7 @@ export function StepProduct() {
{/* Continue Button */}
<button
onClick={handleContinue}
onClick={() => nextStep()}
className="w-full rounded-xl bg-[#C4D668] py-3 font-bold text-[#1A2E2E] transition-all hover:bg-[#b5c75a]"
>
Volgende stap