Integrate comprehensive dimension calculation logic
Created complete business logic system for door dimensions:
**New: lib/calculations.ts**
- calculateHoleWidth: Total wall opening needed
- calculateHoleMinWidth/MaxWidth: Dynamic limits based on config
- calculateSidePanelWidth: Side panel sizing logic
- calculateDoorLeafWidth: Individual door panel width
- validateDimensions: Real-time validation
- Constants: Frame profiles (80mm), min/max widths
**Enhanced: lib/store.ts**
- Added doorConfig: 'enkele' | 'dubbele' (single/double door)
- Added sidePanel: 'geen' | 'links' | 'rechts' | 'beide'
- Calculated fields: holeWidth, doorLeafWidth, sidePanelWidth, min/maxWidth
- Auto-recalculation on config changes
- setWidth/setHeight with automatic clamping to valid ranges
- Internal recalculate() method updates all derived values
**Redesigned: step-dimensions.tsx**
- Door configuration selector (enkele/dubbele)
- Side panel selector (geen/links/rechts/beide)
- Width slider with dynamic min/max based on configuration
- Height slider (1800-3000mm)
- Real-time input fields synced with sliders
- Summary panel showing:
- Door leaf width
- Total wall opening (hole width × height)
- Side panel width (when applicable)
- Premium tile-based UI (Dark Green/Pistachio theme)
**Added: components/ui/slider.tsx**
- Shadcn Slider component for smooth value selection
**Connected: door-3d.tsx**
- Door now uses doorLeafWidth from store (in mm, converted to meters)
- Door height from store (in mm, converted to meters)
- 3D door resizes in real-time as user drags sliders
- Maintains realistic proportions
**User Flow:**
1. User selects enkele/dubbele configuration
2. User selects side panels (if any)
3. Store calculates valid min/max widths
4. User drags width slider (clamped to valid range)
5. Store calculates door leaf width and side panel widths
6. 3D door updates instantly to show new dimensions
7. Summary shows all calculated dimensions
**Result:** Professional dimension configurator with real business logic,
automatic validation, and real-time 3D preview
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,8 @@ import { useConfiguratorStore } from "@/lib/store";
|
||||
import * as THREE from "three";
|
||||
|
||||
export function Door3D() {
|
||||
const { doorType, gridType, finish, handle } = useConfiguratorStore();
|
||||
const { doorType, gridType, finish, handle, doorLeafWidth, height } =
|
||||
useConfiguratorStore();
|
||||
const doorRef = useRef<THREE.Group>(null);
|
||||
|
||||
// Frame color based on finish
|
||||
@@ -15,9 +16,9 @@ export function Door3D() {
|
||||
grijs: "#525252",
|
||||
}[finish];
|
||||
|
||||
// Door dimensions (more realistic proportions)
|
||||
const doorWidth = doorType === "paneel" ? 1.5 : 1.2;
|
||||
const doorHeight = 2.4;
|
||||
// Convert mm to meters for 3D scene
|
||||
const doorWidth = doorLeafWidth / 1000; // Convert mm to m
|
||||
const doorHeight = height / 1000; // Convert mm to m
|
||||
const frameThickness = 0.03; // Slim steel profile (3cm)
|
||||
const frameDepth = 0.05; // Depth of frame
|
||||
const glassThickness = 0.015; // Realistic glass thickness
|
||||
|
||||
@@ -1,61 +1,203 @@
|
||||
"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 { Ruler } from "lucide-react";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
export function StepDimensions() {
|
||||
const { formData, updateData } = useFormContext();
|
||||
const {
|
||||
doorConfig,
|
||||
sidePanel,
|
||||
width,
|
||||
height,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
holeWidth,
|
||||
doorLeafWidth,
|
||||
sidePanelWidth,
|
||||
setDoorConfig,
|
||||
setSidePanel,
|
||||
setWidth,
|
||||
setHeight,
|
||||
} = useConfiguratorStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-xl font-semibold">Afmetingen</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Voer de gewenste afmetingen in millimeters in.
|
||||
</p>
|
||||
<div className="space-y-8">
|
||||
{/* Door Configuration */}
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Deur Configuratie</h2>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Kies tussen enkele of dubbele deur.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="height" className="flex items-center gap-2">
|
||||
<Ruler className="size-4 text-brand-orange" />
|
||||
Hoogte (mm)
|
||||
</Label>
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
placeholder="bijv. 2400"
|
||||
value={formData.height ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
updateData({ height: val === "" ? undefined : Number(val) });
|
||||
}}
|
||||
className="h-12 text-lg focus-visible:ring-brand-orange"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Min: 2000mm — Max: 3000mm
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(['enkele', 'dubbele'] as const).map((config) => {
|
||||
const selected = doorConfig === config;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={config}
|
||||
type="button"
|
||||
onClick={() => setDoorConfig(config)}
|
||||
className={`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-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold capitalize">{config} deur</h3>
|
||||
<p
|
||||
className={`mt-1 text-sm ${
|
||||
selected ? "text-white/80" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{config === 'enkele' ? '1 deurblad' : '2 deurbladen'}
|
||||
</p>
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-4 text-[#1A2E2E]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="width" className="flex items-center gap-2">
|
||||
<Ruler className="size-4 rotate-90 text-brand-orange" />
|
||||
Breedte (mm)
|
||||
</Label>
|
||||
{/* Side Panel Configuration */}
|
||||
<div>
|
||||
<h2 className="mb-2 text-lg font-bold text-[#1A2E2E]">Zijpanelen</h2>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Voeg vaste panelen toe naast de deur.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(['geen', 'links', 'rechts', 'beide'] as const).map((panel) => {
|
||||
const selected = sidePanel === panel;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={panel}
|
||||
type="button"
|
||||
onClick={() => setSidePanel(panel)}
|
||||
className={`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-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold capitalize">{panel}</h3>
|
||||
{panel !== 'geen' && sidePanelWidth > 0 && (
|
||||
<p
|
||||
className={`mt-1 text-sm ${
|
||||
selected ? "text-white/80" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{Math.round(sidePanelWidth)}mm breed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="flex size-6 items-center justify-center rounded-full bg-[#C4D668]">
|
||||
<Check className="size-4 text-[#1A2E2E]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Width Control */}
|
||||
<div>
|
||||
<div className="mb-4 flex items-end justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-bold text-[#1A2E2E]">
|
||||
Breedte
|
||||
</Label>
|
||||
<p className="text-sm text-gray-600">
|
||||
{minWidth}mm - {maxWidth}mm
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Number(e.target.value))}
|
||||
min={minWidth}
|
||||
max={maxWidth}
|
||||
className="h-10 w-32 text-right font-mono text-lg font-bold"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Deurblad: {Math.round(doorLeafWidth)}mm</p>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
value={[width]}
|
||||
onValueChange={(values) => setWidth(values[0])}
|
||||
min={minWidth}
|
||||
max={maxWidth}
|
||||
step={10}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Height Control */}
|
||||
<div>
|
||||
<div className="mb-4 flex items-end justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-bold text-[#1A2E2E]">
|
||||
Hoogte
|
||||
</Label>
|
||||
<p className="text-sm text-gray-600">1800mm - 3000mm</p>
|
||||
</div>
|
||||
<Input
|
||||
id="width"
|
||||
type="number"
|
||||
placeholder="bijv. 900"
|
||||
value={formData.width ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
updateData({ width: val === "" ? undefined : Number(val) });
|
||||
}}
|
||||
className="h-12 text-lg focus-visible:ring-brand-orange"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(Number(e.target.value))}
|
||||
min={1800}
|
||||
max={3000}
|
||||
className="h-10 w-32 text-right font-mono text-lg font-bold"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Min: 300mm — Max: 3000mm
|
||||
</div>
|
||||
<Slider
|
||||
value={[height]}
|
||||
onValueChange={(values) => setHeight(values[0])}
|
||||
min={1800}
|
||||
max={3000}
|
||||
step={10}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="rounded-xl bg-slate-50 p-4">
|
||||
<h3 className="mb-2 text-sm font-bold text-[#1A2E2E]">
|
||||
Samenvatting afmetingen
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<p>
|
||||
<span className="font-medium">Deurblad breedte:</span>{" "}
|
||||
{Math.round(doorLeafWidth)}mm
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">Totale wandopening:</span>{" "}
|
||||
{Math.round(holeWidth)}mm × {height}mm
|
||||
</p>
|
||||
{sidePanelWidth > 0 && (
|
||||
<p>
|
||||
<span className="font-medium">Zijpaneel breedte:</span>{" "}
|
||||
{Math.round(sidePanelWidth)}mm
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
63
components/ui/slider.tsx
Normal file
63
components/ui/slider.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slider as SliderPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max]
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
Reference in New Issue
Block a user