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

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

View File

@@ -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) => {