1171 lines
45 KiB
React
1171 lines
45 KiB
React
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>
|
||
);
|
||
}
|