import { useState, useEffect, useCallback, useMemo } from 'react'; import { fetchFinishes, fetchServiceOptions, calculatePrice, submitOrder } from '../api/client'; import WardrobePreview from '../components/WardrobePreview'; import MaterialDetailModal from '../components/MaterialDetailModal'; import RoomUpload from '../components/RoomUpload'; import ColorPickerModal from '../components/ColorPickerModal'; import Plans2DView from '../components/Plans2DView'; import DesignTip from '../components/DesignTip'; import AIRenderModal from '../components/AIRenderModal'; import styles from './Configurator.module.css'; const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; // Column element types -- single source of truth const COLUMN_TYPES = [ { key: 'penderie', label: 'Penderie', icon: '👔', shelves: 1, drawers: 0, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1200 }, { key: 'tiroirs', label: 'Tiroirs', icon: '🗄️', shelves: 0, drawers: 3, hasRod: false, defaultWidth: 500, minWidth: 400, maxWidth: 800 }, { key: 'rayonnage', label: 'Rayonnage', icon: '📚', shelves: 4, drawers: 0, hasRod: false, defaultWidth: 500, minWidth: 400, maxWidth: 900 }, { key: 'bloc_carre', label: 'Bloc carré', icon: '⬜', shelves: 0, drawers: 0, hasRod: false, defaultWidth: 400, minWidth: 300, maxWidth: 600 }, { key: 'mixte', label: 'Mixte', icon: '📦', shelves: 2, drawers: 2, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1000 }, ]; // Default cycling types when creating new columns const DEFAULT_CYCLE = ['penderie', 'rayonnage', 'tiroirs']; // Default colors for each element type — must include ALL element types rendered in 3D const DEFAULT_COLORS = { plintheBas: { finishId: null, label: 'Non défini', hexColor: '#8b7355' }, fileur: { finishId: null, label: 'Non défini', hexColor: '#8b7355' }, bandeauHaut: { finishId: null, label: 'Non défini', hexColor: '#8b7355' }, champs: { finishId: null, label: 'Non défini', hexColor: '#6b5344' }, caisse: { finishId: null, label: 'Non défini', hexColor: '#c4a574' }, cotesDoublage: { finishId: null, label: 'Non défini', hexColor: '#c4a574' }, cotes: { finishId: null, label: 'Non défini', hexColor: '#b09060' }, dessus: { finishId: null, label: 'Non défini', hexColor: '#c4a574' }, fond: { finishId: null, label: 'Non défini', hexColor: '#d4c4a4' }, dos: { finishId: null, label: 'Non défini', hexColor: '#d4c4a4' }, portes: { finishId: null, label: 'Non défini', hexColor: '#a08060' }, panneauxInterieurs: { finishId: null, label: 'Non défini', hexColor: '#d4c4a4' }, separateurs: { finishId: null, label: 'Non défini', hexColor: '#6b5344' }, etageres: { finishId: null, label: 'Non défini', hexColor: '#d4c4a4' }, tiroirs: { finishId: null, label: 'Non défini', hexColor: '#c4a574' }, penderie: { finishId: null, label: 'Non défini', hexColor: '#8b7355' } }; const DEFAULT_CONFIG = { installationType: 'free', widthMm: 2400, heightMm: 2400, depthMm: 600, plinthHeightMm: 100, modules: [], colors: { ...DEFAULT_COLORS }, hardware: { handleVariantId: null, hingeVariantId: null, drawerSystemId: null }, doorType: 'hinged', slidingPanelsCount: 2, finishId: null, dimensionId: null, serviceOptionIds: [] }; export default function Configurator() { const [config, setConfig] = useState(DEFAULT_CONFIG); const [finishes, setFinishes] = useState([]); const [serviceOptions, setServiceOptions] = useState([]); const [priceResult, setPriceResult] = useState(null); const [loading, setLoading] = useState(true); const [orderSent, setOrderSent] = useState(false); const [orderForm, setOrderForm] = useState({ email: '', name: '', phone: '', notes: '' }); // Material detail modal state const [selectedMaterial, setSelectedMaterial] = useState(null); const [showMaterialModal, setShowMaterialModal] = useState(false); const [materialFilter, setMaterialFilter] = useState(''); // Room visualization state const [showRoomUpload, setShowRoomUpload] = useState(false); const [roomConfig, setRoomConfig] = useState(null); const [roomAnalysis, setRoomAnalysis] = useState(null); const [roomViewActive, setRoomViewActive] = useState(false); // Column-based module state const [numColumns, setNumColumns] = useState(3); const [expandedColumn, setExpandedColumn] = useState(null); // Material picker modal state const [showMaterialPicker, setShowMaterialPicker] = useState(false); const [selectedCollection, setSelectedCollection] = useState('all'); // Hardware state const [handleVariants, setHandleVariants] = useState([]); const [hingeVariants, setHingeVariants] = useState([]); const [blumHardware, setBlumHardware] = useState([]); // Color picker modal state const [showColorPicker, setShowColorPicker] = useState(false); const [colorPickerElement, setColorPickerElement] = useState(null); // Plans view const [showPlansView, setShowPlansView] = useState(false); const [showAIRender, setShowAIRender] = useState(false); const [aiOptimizing, setAiOptimizing] = useState(false); const [aiOptimizeMsg, setAiOptimizeMsg] = useState(''); useEffect(() => { Promise.all([fetchFinishes(), fetchServiceOptions()]) .then(([fin, opt]) => { setFinishes(fin); setServiceOptions(opt); if (fin.length && !config.finishId) { const firstMaterial = fin[0]; const defaultDim = firstMaterial.dimensions?.find(d => d.is_default) || firstMaterial.dimensions?.[0]; setConfig((c) => ({ ...c, finishId: firstMaterial.id, dimensionId: defaultDim?.id || null })); } setLoading(false); }) .catch(console.error); // Fetch V2 data: hardware, handles, hinges fetch(`${API_BASE}/api/handles`) .then(r => r.json()) .then(setHandleVariants) .catch(console.error); fetch(`${API_BASE}/api/hinges`) .then(r => r.json()) .then(setHingeVariants) .catch(console.error); fetch(`${API_BASE}/api/hardware`) .then(r => r.json()) .then(setBlumHardware) .catch(console.error); }, []); const updatePrice = useCallback(() => { const totalW = config.modules.reduce((s, m) => s + (m.widthMm || 0), 0) || config.widthMm; const payload = { ...config, widthMm: totalW, blocks: config.modules.map((m) => ({ blockTypeId: 1, widthMm: m.widthMm })) }; calculatePrice(payload) .then(setPriceResult) .catch((err) => setPriceResult({ totalChf: 0, errors: [{ message: err.message }] })); }, [config]); useEffect(() => { if (!loading) updatePrice(); }, [config, loading, updatePrice]); const set = (key, value) => setConfig((c) => ({ ...c, [key]: value })); // V2: Column-based layout generation const generateColumns = useCallback((nCols, totalW, existingModules = []) => { // Calculate width: locked columns keep their width, rest share equally const lockedWidth = existingModules .filter((m, i) => i < nCols && m?.locked) .reduce((s, m) => s + (m.widthMm || 0), 0); const lockedCount = existingModules .filter((m, i) => i < nCols && m?.locked).length; const unlockedCount = nCols - lockedCount; const remainingW = totalW - lockedWidth; const colWidth = unlockedCount > 0 ? Math.round(remainingW / unlockedCount) : Math.round(totalW / nCols); return Array.from({ length: nCols }, (_, i) => { const existing = existingModules[i]; // Locked columns keep everything if (existing?.locked) { return { ...existing, position: i }; } const typeKey = existing?.type || DEFAULT_CYCLE[i % DEFAULT_CYCLE.length]; const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0]; return { id: existing?.id || `col-${Date.now()}-${i}`, type: typeDef.key, moduleCode: typeDef.key, widthMm: colWidth, minWidth: 300, maxWidth: 1200, position: i, locked: false, shelves: typeDef.shelves, drawers: typeDef.drawers, hasRod: typeDef.hasRod, }; }); }, []); // Handle column count change const handleNumColumnsChange = useCallback((newNum) => { setNumColumns(newNum); const modules = generateColumns(newNum, config.widthMm, config.modules); setConfig(c => ({ ...c, modules })); }, [config.widthMm, config.modules, generateColumns]); // AI layout optimization const handleAIOptimize = useCallback(async () => { setAiOptimizing(true); setAiOptimizeMsg(''); try { const res = await fetch(`${API_BASE}/api/layout/optimize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ totalWidth: config.totalWidth || config.widthMm, height: config.height, depth: config.depth, installationType: config.installationType, modules: config.modules.map(m => ({ type: m.type || m.moduleCode, width: m.width, locked: m.locked || false, shelves: m.shelves, drawers: m.drawers, hasRod: m.hasRod })) }) }); if (!res.ok) throw new Error('API error'); const data = await res.json(); if (data.modules && Array.isArray(data.modules)) { setConfig(c => { const newModules = data.modules.map((m, i) => ({ ...c.modules[i], ...m, id: c.modules[i]?.id || `col-${i}` })); return { ...c, modules: newModules }; }); setNumColumns(data.modules.length); } if (data.reasoning) { setAiOptimizeMsg(data.reasoning); } } catch (err) { setAiOptimizeMsg('Impossible d\'optimiser. Vérifiez la configuration IA.'); } finally { setAiOptimizing(false); } }, [config]); // Handle column type change const handleColumnTypeChange = useCallback((index, typeKey) => { const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0]; setConfig(c => { const modules = [...c.modules]; modules[index] = { ...modules[index], type: typeDef.key, moduleCode: typeDef.key, shelves: typeDef.shelves, drawers: typeDef.drawers, hasRod: typeDef.hasRod, }; return { ...c, modules }; }); }, []); // Handle column lock toggle const handleColumnLock = useCallback((index) => { setConfig(c => { const modules = [...c.modules]; modules[index] = { ...modules[index], locked: !modules[index].locked }; return { ...c, modules }; }); }, []); // Redistribute widths (keeps locked widths, redistributes rest) const redistributeWidths = useCallback(() => { setConfig(c => { const modules = [...c.modules]; const lockedWidth = modules.filter(m => m.locked).reduce((s, m) => s + m.widthMm, 0); const unlockedCount = modules.filter(m => !m.locked).length; const remainingW = c.widthMm - lockedWidth; const colWidth = unlockedCount > 0 ? Math.round(remainingW / unlockedCount) : 0; return { ...c, modules: modules.map(m => m.locked ? m : { ...m, widthMm: colWidth }) }; }); }, []); // Re-generate layout when wardrobe width changes (if we have modules) useEffect(() => { if (config.modules.length > 0) { redistributeWidths(); } }, [config.widthMm]); // Initialize columns on first load useEffect(() => { if (config.modules.length === 0) { const modules = generateColumns(numColumns, config.widthMm, []); setConfig(c => ({ ...c, modules })); } }, []); // Per-column property update (shelves, drawers, rod, etc.) const handleColumnUpdate = useCallback((index, updates) => { setConfig(c => { const modules = [...c.modules]; modules[index] = { ...modules[index], ...updates }; return { ...c, modules }; }); }, []); // Module click from 3D view -- expand that column const handleModuleClick = useCallback((moduleIndex) => { setExpandedColumn(prev => prev === moduleIndex ? null : moduleIndex); }, []); // V2: 3D resize callback (from dragging separators in 3D view) const handleResizeModules = useCallback((separatorIndex, deltaXmm) => { setConfig((c) => { const modules = [...c.modules]; const left = { ...modules[separatorIndex] }; const right = { ...modules[separatorIndex + 1] }; const leftMin = left.minWidth || 300; const leftMax = left.maxWidth || 1200; const rightMin = right.minWidth || 300; const rightMax = right.maxWidth || 1200; let newLeftW = left.widthMm + deltaXmm; let newRightW = right.widthMm - deltaXmm; // Clamp if (newLeftW < leftMin) { newRightW += (newLeftW - leftMin); newLeftW = leftMin; } if (newLeftW > leftMax) { newRightW += (newLeftW - leftMax); newLeftW = leftMax; } if (newRightW < rightMin) { newLeftW += (newRightW - rightMin); newRightW = rightMin; } if (newRightW > rightMax) { newLeftW += (newRightW - rightMax); newRightW = rightMax; } left.widthMm = Math.round(newLeftW); right.widthMm = Math.round(newRightW); modules[separatorIndex] = left; modules[separatorIndex + 1] = right; return { ...c, modules }; }); }, []); // V2: Color management const handleElementClick = (elementType, label) => { setColorPickerElement({ type: elementType, label }); setShowColorPicker(true); }; const handleColorSelect = (elementType, finish) => { setConfig((c) => ({ ...c, colors: { ...c.colors, [elementType]: { finishId: finish.id, label: finish.label || finish.name, hexColor: finish.hexColor || '#c4a574', imageUrl: finish.image_url || null } } })); }; const handleColorApplyToAll = (finish) => { const colorEntry = { finishId: finish.id, label: finish.label || finish.name, hexColor: finish.hexColor || '#c4a574', imageUrl: finish.image_url || null }; const newColors = {}; // Cover all standard element types Object.keys(DEFAULT_COLORS).forEach(key => { newColors[key] = { ...colorEntry }; }); // Also include any extra keys already in config.colors Object.keys(config.colors).forEach(key => { if (!newColors[key]) newColors[key] = { ...colorEntry }; }); setConfig((c) => ({ ...c, colors: newColors })); }; // V2: Hardware management const setHardware = (key, value) => { setConfig((c) => ({ ...c, hardware: { ...c.hardware, [key]: value } })); }; // Calculate total module width const totalModuleWidth = useMemo(() => config.modules.reduce((sum, m) => sum + (m.widthMm || 0), 0), [config.modules] ); const handleSubmitOrder = (e) => { e.preventDefault(); submitOrder({ config, totalChf: priceResult?.totalChf, ...orderForm }) .then(() => setOrderSent(true)) .catch(alert); }; // Material selection handlers const handleMaterialClick = (material) => { setSelectedMaterial(material); setShowMaterialModal(true); }; const handleMaterialConfirm = (materialId, dimensionId) => { // Also apply the selected material to all 3D element colors const material = finishes.find(f => f.id === materialId); setConfig((c) => { const updated = { ...c, finishId: materialId, dimensionId }; if (material) { const colorEntry = { finishId: material.id, label: material.label || material.name, hexColor: material.hexColor || '#c4a574', imageUrl: material.image_url || null }; const newColors = {}; Object.keys(DEFAULT_COLORS).forEach(key => { newColors[key] = { ...colorEntry }; }); // Also include any extra keys already in config.colors Object.keys(c.colors).forEach(key => { if (!newColors[key]) newColors[key] = { ...colorEntry }; }); updated.colors = newColors; } return updated; }); setShowMaterialModal(false); }; const handleVariantSwitch = (variant) => { // When switching to a color variant, update the selectedMaterial const variantMaterial = finishes.find(f => f.code === variant.variant_code); if (variantMaterial) { setSelectedMaterial(variantMaterial); } }; // Group finishes by collection for display const materialsByCollection = finishes.reduce((acc, mat) => { const coll = mat.collection || 'Autres'; if (!acc[coll]) acc[coll] = []; acc[coll].push(mat); return acc; }, {}); // Filter materials by search term and selected collection const filteredCollections = Object.entries(materialsByCollection).reduce((acc, [coll, mats]) => { // Filter by selected collection tab if (selectedCollection !== 'all' && coll !== selectedCollection) return acc; // Filter by search term if (!materialFilter) { acc[coll] = mats; } else { const filtered = mats.filter(m => m.label?.toLowerCase().includes(materialFilter.toLowerCase()) || m.code?.toLowerCase().includes(materialFilter.toLowerCase()) ); if (filtered.length > 0) acc[coll] = filtered; } return acc; }, {}); const selectedFinish = finishes.find(f => f.id === config.finishId); if (loading) return
Chargement…
; const errors = priceResult?.errors || []; const totalChf = priceResult?.totalChf ?? 0; return (

Configurer mon armoire

Installation

Dimensions (mm)

Plinthe (60–160 mm)

Configuration intérieure

{/* Column count selector */}
Nombre de colonnes
{[1, 2, 3, 4, 5, 6].map(n => ( ))}
{aiOptimizeMsg && (
IA {aiOptimizeMsg}
)} {/* Expandable column list */} {config.modules.length > 0 && (
{config.modules.map((mod, i) => { const isExpanded = expandedColumn === i; const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0]; return (
{/* Collapsed header row */}
setExpandedColumn(isExpanded ? null : i)} > {i + 1} {typeDef.icon} {typeDef.label} {mod.widthMm}mm ▸
{/* Expanded detail controls */} {isExpanded && (
{/* Type preset */}
Type
{/* Shelves counter */}
Étagères
{mod.shelves || 0}
{/* Drawers counter */}
Tiroirs
{mod.drawers || 0}
{/* Rod toggle */}
Tringle
)}
); })}
)} {/* Summary */} {config.modules.length > 0 && (

{config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} — Largeur totale : {totalModuleWidth} mm

Cliquer sur un module dans la vue 3D pour le configurer • Tirer les séparateurs pour redimensionner

)}
{/* V2: Hardware section */}

Quincaillerie

{/* Handle variants */}
{handleVariants.map(h => ( ))}
{/* Hinge variants */}
{hingeVariants.map(h => ( ))}
{/* Drawer systems (if modules with drawers) */} {config.modules.some(m => m.drawers > 0) && (
{blumHardware.filter(h => h.type === 'drawer_system').map(h => ( ))}
)}

Portes

{config.doorType === 'sliding' && ( )}

Finitions / Matériaux

{/* Current selection + browse button */}
{selectedFinish ? (
setShowMaterialPicker(true)}> {selectedFinish.image_url ? ( {selectedFinish.label} ) : (
?
)}
{selectedFinish.label} {selectedFinish.code} — {selectedFinish.collection}
) : ( Aucun matériau sélectionné )}

Options

{serviceOptions.map((opt) => ( ))}
{/* Room visualization controls */}
{roomViewActive && roomConfig ? ( <> Vue: Ma pièce ({roomAnalysis?.roomDimensions?.widthM}m × {roomAnalysis?.roomDimensions?.lengthM}m)
) : ( <> Vue: Simple
{roomConfig && ( )}
)}

Récapitulatif

{errors.length > 0 && (
    {errors.map((err, i) => (
  • {err.message}
  • ))}
)} {/* Panel Optimization Info */} {priceResult?.details?.panelOptimization && (

Optimisation panneaux

Format optimal : {priceResult.details.panelOptimization.selectedDimension.width_mm} x{' '} {priceResult.details.panelOptimization.selectedDimension.height_mm} mm
Panneaux nécessaires : {priceResult.details.panelOptimization.panelsNeeded}
Surface requise : {priceResult.surfaceM2?.toFixed(2)} m²
Chute estimée : {priceResult.details.panelOptimization.wasteM2?.toFixed(2)} m² ({priceResult.details.panelOptimization.wastePercent}%)
)} {/* Plans button */}

Total : CHF {totalChf.toLocaleString('fr-CH', { minimumFractionDigits: 2 })}.–

Le prix tient compte des formats réels de fabrication.

{!orderSent ? (
setOrderForm((f) => ({ ...f, email: e.target.value }))} required /> setOrderForm((f) => ({ ...f, name: e.target.value }))} /> setOrderForm((f) => ({ ...f, phone: e.target.value }))} />