Files
apex/client/src/pages/Configurator.jsx
T
2026-02-13 00:21:53 +01:00

1171 lines
45 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <div className={styles.loading}>Chargement…</div>;
const errors = priceResult?.errors || [];
const totalChf = priceResult?.totalChf ?? 0;
return (
<div className={styles.configurator}>
<h1 className={styles.title}>Configurer mon armoire</h1>
<div className={styles.twoCol}>
<div className={styles.left}>
<section className={styles.section}>
<h2>Installation</h2>
<label>
<input
type="radio"
checked={config.installationType === 'free'}
onChange={() => set('installationType', 'free')}
/>
Libre
</label>
<label>
<input
type="radio"
checked={config.installationType === 'niche'}
onChange={() => set('installationType', 'niche')}
/>
Niche
</label>
</section>
<section className={styles.section}>
<h2>Dimensions (mm)</h2>
<div className={styles.fields}>
<label>
Largeur totale
<input
type="number"
min="400"
max="5000"
value={config.widthMm}
onChange={(e) => set('widthMm', Number(e.target.value) || 0)}
/>
</label>
<label>
Hauteur
<input
type="number"
min="1800"
max="2800"
value={config.heightMm}
onChange={(e) => set('heightMm', Number(e.target.value) || 0)}
/>
</label>
<label>
Profondeur
<input
type="number"
min="400"
max="800"
value={config.depthMm}
onChange={(e) => set('depthMm', Number(e.target.value) || 0)}
/>
</label>
</div>
</section>
<section className={styles.section}>
<h2>Plinthe (60–160 mm)</h2>
<label>
Hauteur plinthe (mm)
<input
type="number"
min="60"
max="160"
value={config.plinthHeightMm}
onChange={(e) => set('plinthHeightMm', Number(e.target.value) || 60)}
/>
</label>
</section>
<section className={styles.section}>
<h2>Configuration intérieure</h2>
{/* Column count selector */}
<div className={styles.colCountRow}>
<span className={styles.colCountLabel}>Nombre de colonnes</span>
<div className={styles.colCountBtns}>
{[1, 2, 3, 4, 5, 6].map(n => (
<button
key={n}
type="button"
className={`${styles.colCountBtn} ${numColumns === n ? styles.colCountActive : ''}`}
onClick={() => handleNumColumnsChange(n)}
>
{n}
</button>
))}
<button
type="button"
className={styles.optimizeBtn}
onClick={handleAIOptimize}
disabled={aiOptimizing}
title="L'IA propose un agencement optimal de vos colonnes"
>
{aiOptimizing ? '...' : '✨ Optimiser'}
</button>
</div>
</div>
{aiOptimizeMsg && (
<div className={styles.aiOptimizeMsg}>
<span className={styles.aiBadgeSmall}>IA</span>
<span>{aiOptimizeMsg}</span>
<button className={styles.aiOptimizeDismiss} onClick={() => setAiOptimizeMsg('')}>×</button>
</div>
)}
{/* Expandable column list */}
{config.modules.length > 0 && (
<div className={styles.columnList}>
{config.modules.map((mod, i) => {
const isExpanded = expandedColumn === i;
const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0];
return (
<div
key={mod.id}
className={`${styles.columnItem} ${mod.locked ? styles.columnLocked : ''} ${isExpanded ? styles.columnExpanded : ''}`}
>
{/* Collapsed header row */}
<div
className={styles.columnHeader}
onClick={() => setExpandedColumn(isExpanded ? null : i)}
>
<span className={styles.columnIndex}>{i + 1}</span>
<span className={styles.columnTypeLabel}>{typeDef.icon} {typeDef.label}</span>
<span className={styles.columnWidth}>{mod.widthMm}mm</span>
<button
type="button"
className={`${styles.columnLockBtn} ${mod.locked ? styles.isLocked : ''}`}
onClick={(e) => { e.stopPropagation(); handleColumnLock(i); }}
title={mod.locked ? 'Déverrouiller' : 'Verrouiller'}
>
{mod.locked ? '🔒' : '🔓'}
</button>
<span className={`${styles.columnChevron} ${isExpanded ? styles.chevronOpen : ''}`}>▸</span>
</div>
{/* Expanded detail controls */}
{isExpanded && (
<div className={styles.columnDetails}>
{/* Type preset */}
<div className={styles.columnDetailRow}>
<span className={styles.detailLabel}>Type</span>
<select
className={styles.columnTypeSelect}
value={mod.type || 'penderie'}
onChange={(e) => handleColumnTypeChange(i, e.target.value)}
>
{COLUMN_TYPES.map(t => (
<option key={t.key} value={t.key}>{t.icon} {t.label}</option>
))}
</select>
</div>
{/* Shelves counter */}
<div className={styles.columnDetailRow}>
<span className={styles.detailLabel}>Étagères</span>
<div className={styles.detailCounter}>
<button
type="button"
className={styles.counterBtn}
onClick={() => handleColumnUpdate(i, { shelves: Math.max(0, (mod.shelves || 0) - 1) })}
disabled={(mod.shelves || 0) <= 0}
>−</button>
<span className={styles.counterValue}>{mod.shelves || 0}</span>
<button
type="button"
className={styles.counterBtn}
onClick={() => handleColumnUpdate(i, { shelves: Math.min(10, (mod.shelves || 0) + 1) })}
disabled={(mod.shelves || 0) >= 10}
>+</button>
</div>
</div>
{/* Drawers counter */}
<div className={styles.columnDetailRow}>
<span className={styles.detailLabel}>Tiroirs</span>
<div className={styles.detailCounter}>
<button
type="button"
className={styles.counterBtn}
onClick={() => handleColumnUpdate(i, { drawers: Math.max(0, (mod.drawers || 0) - 1) })}
disabled={(mod.drawers || 0) <= 0}
>−</button>
<span className={styles.counterValue}>{mod.drawers || 0}</span>
<button
type="button"
className={styles.counterBtn}
onClick={() => handleColumnUpdate(i, { drawers: Math.min(6, (mod.drawers || 0) + 1) })}
disabled={(mod.drawers || 0) >= 6}
>+</button>
</div>
</div>
{/* Rod toggle */}
<div className={styles.columnDetailRow}>
<span className={styles.detailLabel}>Tringle</span>
<button
type="button"
className={`${styles.toggleBtn} ${mod.hasRod ? styles.toggleOn : ''}`}
onClick={() => handleColumnUpdate(i, { hasRod: !mod.hasRod })}
>
{mod.hasRod ? 'Oui' : 'Non'}
</button>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* Summary */}
{config.modules.length > 0 && (
<div className={styles.moduleSummary}>
<p className={styles.muted}>
{config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} — Largeur totale : {totalModuleWidth} mm
</p>
<p className={styles.hint3d}>
Cliquer sur un module dans la vue 3D pour le configurer • Tirer les séparateurs pour redimensionner
</p>
</div>
)}
</section>
{/* V2: Hardware section */}
<section className={styles.section}>
<h2>Quincaillerie</h2>
{/* Handle variants */}
<div className={styles.hardwareGroup}>
<label className={styles.hardwareLabel}>Poignées</label>
<div className={styles.hardwareOptions}>
{handleVariants.map(h => (
<button
key={h.id}
type="button"
className={`${styles.hardwareBtn} ${config.hardware.handleVariantId === h.id ? styles.selected : ''}`}
onClick={() => setHardware('handleVariantId', h.id)}
title={`${h.name} - ${h.price_chf} CHF`}
>
{h.image_url ? (
<img src={h.image_url} alt={h.name} />
) : (
<span className={styles.hardwareIcon}>🚪</span>
)}
<span className={styles.hardwareName}>{h.name}</span>
<span className={styles.hardwarePrice}>{h.price_chf} CHF</span>
</button>
))}
</div>
</div>
{/* Hinge variants */}
<div className={styles.hardwareGroup}>
<label className={styles.hardwareLabel}>Charnières</label>
<div className={styles.hardwareOptions}>
{hingeVariants.map(h => (
<button
key={h.id}
type="button"
className={`${styles.hardwareBtn} ${config.hardware.hingeVariantId === h.id ? styles.selected : ''}`}
onClick={() => setHardware('hingeVariantId', h.id)}
>
<span className={styles.hardwareIcon}>{h.type === 'vissee' ? '🔩' : '📌'}</span>
<span className={styles.hardwareName}>{h.name}</span>
<span className={styles.hardwareType}>{h.type === 'vissee' ? 'Vissée' : 'Plantée'}</span>
<span className={styles.hardwarePrice}>{h.price_chf} CHF</span>
</button>
))}
</div>
</div>
{/* Drawer systems (if modules with drawers) */}
{config.modules.some(m => m.drawers > 0) && (
<div className={styles.hardwareGroup}>
<label className={styles.hardwareLabel}>Système de tiroirs (Blum)</label>
<div className={styles.hardwareOptions}>
{blumHardware.filter(h => h.type === 'drawer_system').map(h => (
<button
key={h.id}
type="button"
className={`${styles.hardwareBtn} ${config.hardware.drawerSystemId === h.id ? styles.selected : ''}`}
onClick={() => setHardware('drawerSystemId', h.id)}
>
<span className={styles.hardwareIcon}>🗄️</span>
<span className={styles.hardwareName}>{h.name}</span>
<span className={styles.hardwarePrice}>{h.price_chf} CHF</span>
</button>
))}
</div>
</div>
)}
</section>
<section className={styles.section}>
<h2>Portes</h2>
<label>
<input
type="radio"
checked={config.doorType === 'hinged'}
onChange={() => set('doorType', 'hinged')}
/>
Battantes
</label>
<label>
<input
type="radio"
checked={config.doorType === 'sliding'}
onChange={() => set('doorType', 'sliding')}
/>
Coulissantes
</label>
{config.doorType === 'sliding' && (
<label>
Nombre de vantaux
<select
value={config.slidingPanelsCount}
onChange={(e) => set('slidingPanelsCount', Number(e.target.value))}
>
<option value={2}>2</option>
<option value={3}>3</option>
</select>
</label>
)}
</section>
<section className={styles.section}>
<h2>Finitions / Matériaux</h2>
{/* Current selection + browse button */}
<div className={styles.materialCompact}>
{selectedFinish ? (
<div className={styles.currentMaterialCard} onClick={() => setShowMaterialPicker(true)}>
{selectedFinish.image_url ? (
<img src={selectedFinish.image_url} alt={selectedFinish.label} className={styles.currentMaterialImg} />
) : (
<div className={styles.noImg}>?</div>
)}
<div className={styles.currentMaterialInfo}>
<strong>{selectedFinish.label}</strong>
<span className={styles.muted}>{selectedFinish.code} — {selectedFinish.collection}</span>
</div>
</div>
) : (
<span className={styles.muted}>Aucun matériau sélectionné</span>
)}
<button
type="button"
className={styles.browseMatBtn}
onClick={() => setShowMaterialPicker(true)}
>
Parcourir les matériaux
</button>
</div>
</section>
<section className={styles.section}>
<h2>Options</h2>
{serviceOptions.map((opt) => (
<label key={opt.id} className={styles.checkLabel}>
<input
type="checkbox"
checked={(config.serviceOptionIds || []).includes(opt.id)}
onChange={(e) => {
const ids = config.serviceOptionIds || [];
set('serviceOptionIds', e.target.checked ? [...ids, opt.id] : ids.filter((id) => id !== opt.id));
}}
/>
{opt.label} — CHF {opt.price_chf}/h
</label>
))}
</section>
</div>
<div className={styles.right}>
{/* Room visualization controls */}
<div className={styles.roomControls}>
{roomViewActive && roomConfig ? (
<>
<span className={styles.roomLabel}>
Vue: Ma pièce ({roomAnalysis?.roomDimensions?.widthM}m × {roomAnalysis?.roomDimensions?.lengthM}m)
</span>
<div className={styles.roomBtnGroup}>
<button
type="button"
className={styles.roomBtnSecondary}
onClick={() => setShowRoomUpload(true)}
title="Changer de photo"
>
Nouvelle photo
</button>
<button
type="button"
className={styles.roomBtn}
onClick={() => setRoomViewActive(false)}
>
Vue simple
</button>
</div>
</>
) : (
<>
<span className={styles.roomLabel}>Vue: Simple</span>
<div className={styles.roomBtnGroup}>
{roomConfig && (
<button
type="button"
className={styles.roomBtn}
onClick={() => setRoomViewActive(true)}
>
Afficher ma pièce
</button>
)}
<button
type="button"
className={roomConfig ? styles.roomBtnSecondary : styles.roomBtn}
onClick={() => setShowRoomUpload(true)}
>
{roomConfig ? 'Nouvelle photo' : 'Visualiser dans ma pièce'}
</button>
<button
type="button"
className={styles.aiRenderBtn}
onClick={() => setShowAIRender(true)}
>
<span className={styles.aiBadgeSmall}>IA</span>
Rendu réaliste
</button>
</div>
</>
)}
</div>
<div className={styles.preview}>
<WardrobePreview
config={config}
roomConfig={roomViewActive ? roomConfig : null}
colors={config.colors}
modules={config.modules}
onElementClick={handleElementClick}
onModuleClick={handleModuleClick}
onResizeModules={handleResizeModules}
selectedModuleIndex={expandedColumn}
/>
</div>
<DesignTip config={config} />
<div className={styles.recap}>
<h2>Récapitulatif</h2>
{errors.length > 0 && (
<ul className={styles.errors}>
{errors.map((err, i) => (
<li key={i}>{err.message}</li>
))}
</ul>
)}
{/* Panel Optimization Info */}
{priceResult?.details?.panelOptimization && (
<div className={styles.panelInfo}>
<h3>Optimisation panneaux</h3>
<div className={styles.panelDetails}>
<div className={styles.panelRow}>
<span>Format optimal :</span>
<strong>
{priceResult.details.panelOptimization.selectedDimension.width_mm} x{' '}
{priceResult.details.panelOptimization.selectedDimension.height_mm} mm
</strong>
</div>
<div className={styles.panelRow}>
<span>Panneaux nécessaires :</span>
<strong>{priceResult.details.panelOptimization.panelsNeeded}</strong>
</div>
<div className={styles.panelRow}>
<span>Surface requise :</span>
<span>{priceResult.surfaceM2?.toFixed(2)} m²</span>
</div>
<div className={styles.panelRow}>
<span>Chute estimée :</span>
<span>{priceResult.details.panelOptimization.wasteM2?.toFixed(2)} m² ({priceResult.details.panelOptimization.wastePercent}%)</span>
</div>
</div>
</div>
)}
{/* Plans button */}
<button
type="button"
className={styles.plansBtn}
onClick={() => setShowPlansView(true)}
>
Voir les plans de fabrication
</button>
<p className={styles.total}>
Total : <strong>CHF {totalChf.toLocaleString('fr-CH', { minimumFractionDigits: 2 })}.–</strong>
</p>
<p className={styles.muted}>Le prix tient compte des formats réels de fabrication.</p>
{!orderSent ? (
<form onSubmit={handleSubmitOrder} className={styles.orderForm}>
<input
type="email"
placeholder="Email"
value={orderForm.email}
onChange={(e) => setOrderForm((f) => ({ ...f, email: e.target.value }))}
required
/>
<input
type="text"
placeholder="Nom"
value={orderForm.name}
onChange={(e) => setOrderForm((f) => ({ ...f, name: e.target.value }))}
/>
<input
type="tel"
placeholder="Téléphone"
value={orderForm.phone}
onChange={(e) => setOrderForm((f) => ({ ...f, phone: e.target.value }))}
/>
<textarea
placeholder="Remarques"
value={orderForm.notes}
onChange={(e) => setOrderForm((f) => ({ ...f, notes: e.target.value }))}
rows={2}
/>
<button type="submit" className={styles.cta}>Commander / Envoyer ma demande</button>
</form>
) : (
<p className={styles.success}>Votre demande a bien été envoyée. Nous vous recontacterons.</p>
)}
</div>
</div>
</div>
{/* Material Detail Modal */}
{showMaterialModal && selectedMaterial && (
<MaterialDetailModal
material={selectedMaterial}
selectedDimensionId={config.dimensionId}
onSelectDimension={(dimId) => setConfig((c) => ({ ...c, dimensionId: dimId }))}
onSelectVariant={handleVariantSwitch}
onConfirm={handleMaterialConfirm}
onClose={() => setShowMaterialModal(false)}
/>
)}
{/* Room Upload Modal */}
{showRoomUpload && (
<RoomUpload
onAnalysisComplete={(result) => {
setRoomConfig(result.room3DConfig);
setRoomAnalysis(result.analysis);
setRoomViewActive(true);
setShowRoomUpload(false);
}}
onClose={() => setShowRoomUpload(false)}
/>
)}
{/* V2: Color Picker Modal */}
{showColorPicker && colorPickerElement && (
<ColorPickerModal
isOpen={showColorPicker}
elementType={colorPickerElement.type}
currentFinish={config.colors[colorPickerElement.type]}
finishes={finishes}
config={config}
onSelect={handleColorSelect}
onApplyToAll={handleColorApplyToAll}
onClose={() => {
setShowColorPicker(false);
setColorPickerElement(null);
}}
/>
)}
{/* V2: Plans View Modal */}
{showPlansView && (
<Plans2DView
config={config}
modules={config.modules}
colors={config.colors}
onClose={() => setShowPlansView(false)}
/>
)}
{/* AI Photorealistic Render Modal */}
{showAIRender && (
<AIRenderModal
isOpen={showAIRender}
config={config}
roomConfig={roomConfig}
onClose={() => setShowAIRender(false)}
/>
)}
{/* Material Picker Modal (large) */}
{showMaterialPicker && (
<div className={styles.matPickerOverlay} onClick={() => setShowMaterialPicker(false)}>
<div className={styles.matPickerModal} onClick={e => e.stopPropagation()}>
<div className={styles.matPickerHeader}>
<h2>Choisir un matériau</h2>
<input
type="text"
placeholder="Rechercher..."
value={materialFilter}
onChange={(e) => setMaterialFilter(e.target.value)}
className={styles.matPickerSearch}
autoFocus
/>
<button className={styles.matPickerClose} onClick={() => setShowMaterialPicker(false)}>×</button>
</div>
{/* Collection tabs */}
<div className={styles.matPickerTabs}>
<button
type="button"
className={`${styles.matPickerTab} ${selectedCollection === 'all' ? styles.matPickerTabActive : ''}`}
onClick={() => setSelectedCollection('all')}
>
Tous ({finishes.length})
</button>
{Object.entries(materialsByCollection).map(([coll, mats]) => (
<button
key={coll}
type="button"
className={`${styles.matPickerTab} ${selectedCollection === coll ? styles.matPickerTabActive : ''}`}
onClick={() => setSelectedCollection(coll)}
>
{coll} ({mats.length})
</button>
))}
</div>
{/* Large material grid */}
<div className={styles.matPickerContent}>
{Object.entries(filteredCollections).map(([collection, materials]) => (
<div key={collection} className={styles.matPickerGroup}>
<h3 className={styles.matPickerGroupTitle}>{collection}</h3>
<div className={styles.matPickerGrid}>
{materials.map((mat) => (
<button
key={mat.id}
type="button"
className={`${styles.matPickerCard} ${mat.id === config.finishId ? styles.matPickerCardSelected : ''}`}
onClick={() => {
handleMaterialClick(mat);
setShowMaterialPicker(false);
}}
title={`${mat.label} — ${mat.code}`}
>
{mat.image_url ? (
<img src={mat.image_url} alt={mat.label} className={styles.matPickerCardImg} />
) : (
<div className={styles.matPickerCardNoImg}>{mat.code?.slice(0, 4) || '?'}</div>
)}
<span className={styles.matPickerCardLabel}>{mat.label || mat.code}</span>
{mat.id === config.finishId && <span className={styles.matPickerCheck}>✓</span>}
</button>
))}
</div>
</div>
))}
{Object.keys(filteredCollections).length === 0 && (
<div className={styles.matPickerEmpty}>Aucun matériau trouvé.</div>
)}
</div>
</div>
</div>
)}
</div>
);
}