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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,10 +17,16 @@ export const PROFILE_DEPTH = 40; // mm - Tube depth
|
||||
export const PROFILE_CORNER_RADIUS = 2; // mm - Rounded corners for welding
|
||||
|
||||
/**
|
||||
* Glass Specifications
|
||||
* Steel Profile Named Exports (aliases for pricing/manufacturing clarity)
|
||||
*/
|
||||
export const GLASS_THICKNESS = 10; // mm - Standard tempered glass
|
||||
export const GLASS_OFFSET = 18; // mm - Center glass in 40mm profile (40-10)/2 - 2mm clearance
|
||||
export const STILE_WIDTH = 40; // mm - Vertical profiles (same as PROFILE_WIDTH)
|
||||
export const RAIL_WIDTH = 20; // mm - Horizontal slim-line profiles
|
||||
|
||||
/**
|
||||
* Glass Specifications - Standard 33.1 laminated safety glass (VSG 33.1)
|
||||
*/
|
||||
export const GLASS_THICKNESS = 7; // mm - Standard 33.1 Safety Glass
|
||||
export const GLASS_OFFSET = 15; // mm - Center glass in 40mm profile: (40-7)/2 - 1.5mm clearance
|
||||
|
||||
/**
|
||||
* Rail Height Variations
|
||||
@@ -28,6 +34,11 @@ export const GLASS_OFFSET = 18; // mm - Center glass in 40mm profile (40-10)/2 -
|
||||
export const RAIL_HEIGHT_SLIM = 20; // mm - Slim horizontal rails
|
||||
export const RAIL_HEIGHT_ROBUST = 40; // mm - Standard robust rails (same as profile)
|
||||
|
||||
/**
|
||||
* Taats (Pivot) Door Mechanism
|
||||
*/
|
||||
export const TAATS_PIVOT_OFFSET = 60; // mm - Pivot axis offset from wall for Taats doors
|
||||
|
||||
// ============================================
|
||||
// PHYSICAL PART TYPES
|
||||
// ============================================
|
||||
|
||||
126
lib/pricing.ts
Normal file
126
lib/pricing.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Pricing Engine for Proinn Configurator
|
||||
* Based on Dutch market standard pricing (Metalworks/Aluwdoors reference)
|
||||
*/
|
||||
|
||||
import {
|
||||
PROFILE_WIDTH,
|
||||
RAIL_HEIGHT_SLIM,
|
||||
RAIL_HEIGHT_ROBUST,
|
||||
type GridLayout,
|
||||
type DoorModel,
|
||||
} from './door-models';
|
||||
|
||||
// Pricing constants (EUR)
|
||||
const STEEL_PRICE_PER_METER = 45;
|
||||
const GLASS_PRICE_PER_SQM = 140;
|
||||
const BASE_FEE = 650;
|
||||
const SIDE_PANEL_SURCHARGE = 250;
|
||||
const DOUBLE_DOOR_SURCHARGE = 350;
|
||||
const TAATS_MECHANISM_SURCHARGE = 450;
|
||||
const HANDLE_PRICES: Record<string, number> = {
|
||||
'beugelgreep': 85,
|
||||
'hoekgreep': 75,
|
||||
'maangreep': 95,
|
||||
'ovaalgreep': 90,
|
||||
'klink': 65,
|
||||
'u-greep': 55,
|
||||
'geen': 0,
|
||||
};
|
||||
|
||||
function calculateSteelLength(
|
||||
doorWidth: number,
|
||||
doorHeight: number,
|
||||
gridLayout: GridLayout,
|
||||
hasVerticalDivider: boolean
|
||||
): number {
|
||||
const innerWidth = doorWidth - PROFILE_WIDTH * 2;
|
||||
let totalLength = doorHeight * 2 + innerWidth * 2;
|
||||
|
||||
if (gridLayout === '3-vlak') {
|
||||
totalLength += innerWidth * 2;
|
||||
} else if (gridLayout === '4-vlak') {
|
||||
totalLength += innerWidth * 3;
|
||||
}
|
||||
|
||||
if (hasVerticalDivider) {
|
||||
totalLength += doorHeight - RAIL_HEIGHT_ROBUST * 2;
|
||||
}
|
||||
|
||||
return totalLength / 1000;
|
||||
}
|
||||
|
||||
function calculateGlassArea(
|
||||
doorWidth: number,
|
||||
doorHeight: number,
|
||||
gridLayout: GridLayout
|
||||
): number {
|
||||
const glassWidth = doorWidth - PROFILE_WIDTH * 2;
|
||||
const glassHeight = doorHeight - RAIL_HEIGHT_ROBUST * 2;
|
||||
|
||||
let dividerArea = 0;
|
||||
if (gridLayout === '3-vlak') {
|
||||
dividerArea = glassWidth * RAIL_HEIGHT_SLIM * 2;
|
||||
} else if (gridLayout === '4-vlak') {
|
||||
dividerArea = glassWidth * RAIL_HEIGHT_SLIM * 3;
|
||||
}
|
||||
|
||||
return (glassWidth * glassHeight - dividerArea) / 1_000_000;
|
||||
}
|
||||
|
||||
export interface PriceBreakdown {
|
||||
steelCost: number;
|
||||
glassCost: number;
|
||||
baseFee: number;
|
||||
mechanismSurcharge: number;
|
||||
sidePanelSurcharge: number;
|
||||
handleCost: number;
|
||||
totalPrice: number;
|
||||
steelLengthM: number;
|
||||
glassAreaSqm: number;
|
||||
}
|
||||
|
||||
export function calculatePrice(
|
||||
doorWidth: number,
|
||||
doorHeight: number,
|
||||
doorType: DoorModel,
|
||||
gridLayout: GridLayout,
|
||||
doorConfig: 'enkele' | 'dubbele',
|
||||
sidePanel: 'geen' | 'links' | 'rechts' | 'beide',
|
||||
handle: string
|
||||
): PriceBreakdown {
|
||||
const leafCount = doorConfig === 'dubbele' ? 2 : 1;
|
||||
const hasVerticalDivider = doorType === 'paneel';
|
||||
|
||||
const steelLengthPerLeaf = calculateSteelLength(doorWidth, doorHeight, gridLayout, hasVerticalDivider);
|
||||
const glassAreaPerLeaf = calculateGlassArea(doorWidth, doorHeight, gridLayout);
|
||||
|
||||
const totalSteelLength = steelLengthPerLeaf * leafCount;
|
||||
const totalGlassArea = glassAreaPerLeaf * leafCount;
|
||||
|
||||
const sidePanelCount = sidePanel === 'beide' ? 2 : (sidePanel === 'geen' ? 0 : 1);
|
||||
|
||||
const steelCost = Math.round(totalSteelLength * STEEL_PRICE_PER_METER);
|
||||
const glassCost = Math.round(totalGlassArea * GLASS_PRICE_PER_SQM);
|
||||
|
||||
let mechanismSurcharge = 0;
|
||||
if (doorType === 'taats') mechanismSurcharge += TAATS_MECHANISM_SURCHARGE;
|
||||
if (doorConfig === 'dubbele') mechanismSurcharge += DOUBLE_DOOR_SURCHARGE;
|
||||
|
||||
const handleCost = HANDLE_PRICES[handle] || 0;
|
||||
const sidePanelSurchrg = sidePanelCount * SIDE_PANEL_SURCHARGE;
|
||||
|
||||
const totalPrice = steelCost + glassCost + BASE_FEE + mechanismSurcharge + sidePanelSurchrg + handleCost;
|
||||
|
||||
return {
|
||||
steelCost,
|
||||
glassCost,
|
||||
baseFee: BASE_FEE,
|
||||
mechanismSurcharge,
|
||||
sidePanelSurcharge: sidePanelSurchrg,
|
||||
handleCost,
|
||||
totalPrice,
|
||||
steelLengthM: Math.round(totalSteelLength * 100) / 100,
|
||||
glassAreaSqm: Math.round(totalGlassArea * 100) / 100,
|
||||
};
|
||||
}
|
||||
31
lib/store.ts
31
lib/store.ts
@@ -9,6 +9,7 @@ import {
|
||||
type SidePanel,
|
||||
} from './calculations';
|
||||
import type { GlassPattern } from './glass-patterns';
|
||||
import { calculatePrice, type PriceBreakdown } from './pricing';
|
||||
|
||||
export type DoorType = 'taats' | 'scharnier' | 'paneel';
|
||||
export type GridType = '3-vlak' | '4-vlak' | 'geen';
|
||||
@@ -38,6 +39,9 @@ interface ConfiguratorState {
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
|
||||
// Pricing
|
||||
priceBreakdown: PriceBreakdown;
|
||||
|
||||
// Actions
|
||||
setDoorType: (type: DoorType) => void;
|
||||
setDoorConfig: (config: DoorConfig) => void;
|
||||
@@ -64,6 +68,13 @@ const recalculate = (get: () => ConfiguratorState, set: (state: Partial<Configur
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function for price recalculation
|
||||
const recalculatePrice = (get: () => ConfiguratorState, set: (state: Partial<ConfiguratorState>) => void) => {
|
||||
const { doorLeafWidth, height, doorType, gridType, doorConfig, sidePanel, handle } = get();
|
||||
const priceBreakdown = calculatePrice(doorLeafWidth, height, doorType, gridType, doorConfig, sidePanel, handle);
|
||||
set({ priceBreakdown });
|
||||
};
|
||||
|
||||
export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
|
||||
// Initial state
|
||||
doorType: 'taats',
|
||||
@@ -83,27 +94,39 @@ export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
|
||||
minWidth: 860,
|
||||
maxWidth: 1360,
|
||||
|
||||
// Initial price (computed with defaults: taats, 3-vlak, enkele, geen, beugelgreep, 1000x2400)
|
||||
priceBreakdown: calculatePrice(1000, 2400, 'taats', '3-vlak', 'enkele', 'geen', 'beugelgreep'),
|
||||
|
||||
// Actions with automatic recalculation
|
||||
setDoorType: (doorType) => {
|
||||
set({ doorType });
|
||||
recalculate(get, set);
|
||||
recalculatePrice(get, set);
|
||||
},
|
||||
|
||||
setDoorConfig: (doorConfig) => {
|
||||
set({ doorConfig });
|
||||
recalculate(get, set);
|
||||
recalculatePrice(get, set);
|
||||
},
|
||||
|
||||
setSidePanel: (sidePanel) => {
|
||||
set({ sidePanel });
|
||||
recalculate(get, set);
|
||||
recalculatePrice(get, set);
|
||||
},
|
||||
|
||||
setGridType: (gridType) => set({ gridType }),
|
||||
setGridType: (gridType) => {
|
||||
set({ gridType });
|
||||
recalculatePrice(get, set);
|
||||
},
|
||||
|
||||
setFinish: (finish) => set({ finish }),
|
||||
|
||||
setHandle: (handle) => set({ handle }),
|
||||
setHandle: (handle) => {
|
||||
set({ handle });
|
||||
recalculatePrice(get, set);
|
||||
},
|
||||
|
||||
setGlassPattern: (glassPattern) => set({ glassPattern }),
|
||||
|
||||
@@ -112,16 +135,16 @@ export const useConfiguratorStore = create<ConfiguratorState>((set, get) => ({
|
||||
const minWidth = calculateHoleMinWidth(doorConfig, sidePanel);
|
||||
const maxWidth = calculateHoleMaxWidth(doorConfig, sidePanel);
|
||||
|
||||
// Clamp width to valid range
|
||||
const clampedWidth = Math.max(minWidth, Math.min(maxWidth, width));
|
||||
set({ width: clampedWidth });
|
||||
recalculate(get, set);
|
||||
recalculatePrice(get, set);
|
||||
},
|
||||
|
||||
setHeight: (height) => {
|
||||
// Clamp height to valid range
|
||||
const clampedHeight = Math.max(1800, Math.min(3000, height));
|
||||
set({ height: clampedHeight });
|
||||
recalculatePrice(get, set);
|
||||
},
|
||||
|
||||
setDimensions: (width, height) => {
|
||||
|
||||
Reference in New Issue
Block a user