This commit is contained in:
lejay
2026-02-13 00:21:53 +01:00
parent 81d2412835
commit 6923c319a1
85 changed files with 17836 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
node_modules/
server/data/
.env
*.db
*.sqlite
.DS_Store
# Build output
client/dist/
# Downloaded material images (generated by Formex crawler)
client/public/materials/*.jpg
client/public/materials/*.png
client/public/materials/*.webp
!client/public/materials/.gitkeep
# Uploaded room photos
client/public/rooms/*.jpg
client/public/rooms/*.jpeg
client/public/rooms/*.png
client/public/rooms/*.webp
!client/public/rooms/.gitkeep
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="fr-CH">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Configurateur armoires sur mesure — SIGNATURE</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2541
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "configurateur-armoires-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@react-three/drei": "^9.117.0",
"@react-three/fiber": "^8.17.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"three": "^0.169.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.3",
"vite": "^5.4.10"
}
}
+2
View File
@@ -0,0 +1,2 @@
# This directory stores downloaded Formex material images
# The actual images are not tracked by git
+2
View File
@@ -0,0 +1,2 @@
# This directory stores uploaded room photos
# The actual images are not tracked by git
+27
View File
@@ -0,0 +1,27 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import Layout from './components/Layout';
import ProtectedRoute from './components/ProtectedRoute';
import Home from './pages/Home';
import Configurator from './pages/Configurator';
import Login from './pages/Login';
import Admin from './pages/Admin';
import Materials from './pages/Materials';
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Layout>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/configurateur" element={<ProtectedRoute><Configurator /></ProtectedRoute>} />
<Route path="/materiaux" element={<ProtectedRoute><Materials /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><Admin /></ProtectedRoute>} />
</Routes>
</Layout>
</AuthProvider>
</BrowserRouter>
);
}
+157
View File
@@ -0,0 +1,157 @@
const API_BASE = '/api';
export async function fetchProducts() {
const res = await fetch(`${API_BASE}/products`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchBlockTypes() {
const res = await fetch(`${API_BASE}/block-types`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchFinishes() {
const res = await fetch(`${API_BASE}/finishes`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchServiceOptions() {
const res = await fetch(`${API_BASE}/service-options`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchSettings() {
const res = await fetch(`${API_BASE}/settings`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function calculatePrice(config) {
const res = await fetch(`${API_BASE}/price`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchOrders() {
const res = await fetch(`${API_BASE}/orders`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function submitOrder(order) {
const res = await fetch(`${API_BASE}/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Formex sync API
export async function triggerFormexSync() {
const res = await fetch(`${API_BASE}/sync/formex`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
/**
* Get the SSE stream URL for real-time sync progress
*/
export function getFormexSyncStreamUrl() {
return `${API_BASE}/sync/formex/stream`;
}
export async function fetchSyncLogs(limit = 20) {
const res = await fetch(`${API_BASE}/sync/logs?limit=${limit}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchSyncStatus() {
const res = await fetch(`${API_BASE}/sync/status`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Formex Materials CRUD
export async function fetchFormexMaterials({ search = '', collection = '', category = '', active = null, page = 1, limit = 25 } = {}) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (collection) params.set('collection', collection);
if (category) params.set('category', category);
if (active !== null) params.set('active', active ? '1' : '0');
params.set('page', String(page));
params.set('limit', String(limit));
const res = await fetch(`${API_BASE}/formex/materials?${params}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchFormexMaterial(id) {
const res = await fetch(`${API_BASE}/formex/materials/${id}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function createFormexMaterial(data) {
const res = await fetch(`${API_BASE}/formex/materials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function updateFormexMaterial(id, data) {
const res = await fetch(`${API_BASE}/formex/materials/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deleteFormexMaterial(id) {
const res = await fetch(`${API_BASE}/formex/materials/${id}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchFormexCollections() {
const res = await fetch(`${API_BASE}/formex/collections`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function fetchFormexCategories() {
const res = await fetch(`${API_BASE}/formex/categories`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
/**
* Fetch material details including dimensions and color variants
* Alias for fetchFormexMaterial for use in the configurator
*/
export async function fetchMaterialDetails(id) {
return fetchFormexMaterial(id);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* AIRenderModal - Generates and displays a DALL-E 3 photorealistic render
* of the configured wardrobe, giving the customer a realistic preview.
*/
import { useState, useEffect, useRef } from 'react';
import styles from './AIRenderModal.module.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function AIRenderModal({ isOpen, config, roomConfig, onClose }) {
const [imageUrl, setImageUrl] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [progress, setProgress] = useState(0);
const abortRef = useRef(null);
const timerRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
setImageUrl(null);
setError(null);
setLoading(true);
setProgress(0);
// Simulate progress (DALL-E takes ~15-25s)
timerRef.current = setInterval(() => {
setProgress(p => Math.min(p + Math.random() * 8, 92));
}, 1000);
const controller = new AbortController();
abortRef.current = controller;
const body = {
config: {
width: config.totalWidth,
height: config.height,
depth: config.depth,
installationType: config.installationType,
columns: config.modules?.map(m => ({
type: m.type || m.moduleCode,
width: m.width,
shelves: m.shelves,
drawers: m.drawers,
hasRod: m.hasRod
})),
materialName: config.finishLabel || null,
doorType: config.doorType
},
roomContext: roomConfig || null
};
fetch(`${API_BASE}/api/ai/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
})
.then(r => {
if (!r.ok) throw new Error(r.status === 402 ? 'quota' : 'api');
return r.json();
})
.then(data => {
if (data.imageUrl) {
setImageUrl(data.imageUrl);
setProgress(100);
} else {
throw new Error('no_image');
}
})
.catch(err => {
if (err.name === 'AbortError') return;
if (err.message === 'quota') {
setError('Quota IA dépassé. Réessayez plus tard.');
} else {
setError('Impossible de générer le rendu. Vérifiez la configuration IA.');
}
})
.finally(() => {
setLoading(false);
if (timerRef.current) clearInterval(timerRef.current);
});
return () => {
controller.abort();
if (timerRef.current) clearInterval(timerRef.current);
};
}, [isOpen]);
const handleDownload = () => {
if (!imageUrl) return;
const a = document.createElement('a');
a.href = imageUrl;
a.download = `armoire-rendu-${Date.now()}.png`;
a.target = '_blank';
a.click();
};
if (!isOpen) return null;
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.header}>
<h2 className={styles.title}>
<span className={styles.aiBadge}>IA</span>
Rendu photoréaliste
</h2>
<button className={styles.closeBtn} onClick={onClose}>×</button>
</div>
<div className={styles.body}>
{loading && !imageUrl && (
<div className={styles.loadingArea}>
<div className={styles.spinner} />
<p className={styles.loadingText}>Génération en cours...</p>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${progress}%` }}
/>
</div>
<p className={styles.loadingHint}>
L'IA crée un rendu réaliste de votre armoire. Cela peut prendre 15-25 secondes.
</p>
</div>
)}
{error && (
<div className={styles.errorArea}>
<p className={styles.errorText}>{error}</p>
<button className={styles.retryBtn} onClick={() => {
setError(null);
setLoading(true);
setProgress(0);
}}>
Réessayer
</button>
</div>
)}
{imageUrl && (
<div className={styles.imageArea}>
<img src={imageUrl} alt="Rendu photoréaliste de l'armoire" className={styles.renderImage} />
</div>
)}
</div>
<div className={styles.footer}>
{imageUrl && (
<button className={styles.downloadBtn} onClick={handleDownload}>
Télécharger l'image
</button>
)}
<button className={styles.closeFooterBtn} onClick={onClose}>
Fermer
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,199 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal {
background: #fff;
border-radius: 14px;
width: min(95vw, 900px);
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light, #e5e7eb);
}
.title {
display: flex;
align-items: center;
gap: 8px;
font-size: 1.1rem;
margin: 0;
color: var(--text, #1f2937);
}
.aiBadge {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 7px;
border-radius: 4px;
background: var(--action, #4f46e5);
color: #fff;
}
.closeBtn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted, #6b7280);
padding: 4px 8px;
border-radius: 6px;
}
.closeBtn:hover {
background: var(--bg-subtle, #f3f4f6);
}
.body {
flex: 1;
overflow: auto;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
/* Loading */
.loadingArea {
text-align: center;
padding: 40px 20px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--border-light, #e5e7eb);
border-top-color: var(--action, #4f46e5);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loadingText {
font-size: 1rem;
font-weight: 600;
color: var(--text, #1f2937);
margin: 0 0 12px;
}
.progressBar {
width: 260px;
height: 6px;
background: var(--border-light, #e5e7eb);
border-radius: 3px;
margin: 0 auto 12px;
overflow: hidden;
}
.progressFill {
height: 100%;
background: var(--action, #4f46e5);
border-radius: 3px;
transition: width 0.5s ease;
}
.loadingHint {
font-size: 0.8rem;
color: var(--text-muted, #6b7280);
margin: 0;
}
/* Error */
.errorArea {
text-align: center;
padding: 40px 20px;
}
.errorText {
font-size: 0.9rem;
color: var(--danger, #dc2626);
margin: 0 0 12px;
}
.retryBtn {
padding: 8px 16px;
border: 1px solid var(--action, #4f46e5);
background: transparent;
color: var(--action, #4f46e5);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.retryBtn:hover {
background: var(--bg-subtle, #f9fafb);
}
/* Image */
.imageArea {
padding: 12px;
width: 100%;
}
.renderImage {
width: 100%;
height: auto;
border-radius: 8px;
display: block;
}
/* Footer */
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 12px 20px;
border-top: 1px solid var(--border-light, #e5e7eb);
}
.downloadBtn {
padding: 8px 16px;
background: var(--action, #4f46e5);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
}
.downloadBtn:hover {
background: var(--action-hover, #4338ca);
}
.closeFooterBtn {
padding: 8px 16px;
background: transparent;
color: var(--text-muted, #6b7280);
border: 1px solid var(--border-light, #e5e7eb);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.closeFooterBtn:hover {
background: var(--bg-subtle, #f9fafb);
}
+301
View File
@@ -0,0 +1,301 @@
/**
* ColorPickerModal - Compact swatch-grid color picker for wardrobe elements.
* Sidebar collections + dense swatch grid = everything visible at once.
*/
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import styles from './ColorPickerModal.module.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const ELEMENT_LABELS = {
plintheBas: 'Plinthe bas',
fileur: 'Fileur',
bandeauHaut: 'Bandeau haut',
champs: 'Champs (bords)',
caisse: 'Caisse',
cotesDoublage: 'Doublage',
cotes: 'Cotes',
dessus: 'Dessus',
fond: 'Fond',
dos: 'Dos',
portes: 'Portes',
panneauxInterieurs: 'Panneaux int.',
separateurs: 'Separateurs',
etageres: 'Etageres',
tiroirs: 'Tiroirs',
penderie: 'Tringle'
};
export default function ColorPickerModal({
isOpen,
elementType,
currentFinish,
finishes = [],
onSelect,
onClose,
onApplyToAll,
config
}) {
const [selectedFinish, setSelectedFinish] = useState(currentFinish);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCollection, setSelectedCollection] = useState('all');
const [hoveredFinish, setHoveredFinish] = useState(null);
const [aiRecs, setAiRecs] = useState([]);
const [aiLoading, setAiLoading] = useState(false);
const tooltipRef = useRef(null);
useEffect(() => {
if (isOpen) {
setSelectedFinish(currentFinish);
setSearchTerm('');
setSelectedCollection('all');
setHoveredFinish(null);
setAiRecs([]);
// Fetch AI recommendations
if (config && finishes.length > 0) {
setAiLoading(true);
const controller = new AbortController();
fetch(`${API_BASE}/api/ai/recommend-materials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
config: {
width: config.totalWidth,
height: config.height,
depth: config.depth,
columns: config.modules?.map(m => ({
type: m.type || m.moduleCode,
width: m.width
})),
materialName: config.finishLabel || null,
doorType: config.doorType
},
finishes: finishes.map(f => ({ id: f.id, name: f.name || f.label, collection: f.collection, code: f.code }))
}),
signal: controller.signal
})
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data?.recommendations) {
setAiRecs(data.recommendations);
}
})
.catch(() => {})
.finally(() => setAiLoading(false));
return () => controller.abort();
}
}
}, [isOpen, currentFinish]);
const collections = useMemo(() => {
const cols = [...new Set(finishes.map(f => f.collection).filter(Boolean))];
return cols.sort();
}, [finishes]);
const filteredFinishes = useMemo(() => {
return finishes.filter(finish => {
const matchesSearch = !searchTerm ||
finish.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
finish.label?.toLowerCase().includes(searchTerm.toLowerCase()) ||
finish.code?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCollection = selectedCollection === 'all' ||
finish.collection === selectedCollection;
return matchesSearch && matchesCollection;
});
}, [finishes, searchTerm, selectedCollection]);
const handleConfirm = () => {
if (selectedFinish && onSelect) {
onSelect(elementType, selectedFinish);
}
onClose();
};
const handleApplyToAll = () => {
if (selectedFinish && onApplyToAll) {
onApplyToAll(selectedFinish);
}
onClose();
};
if (!isOpen) return null;
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
{/* Header: title + search */}
<div className={styles.header}>
<h2 className={styles.title}>
{ELEMENT_LABELS[elementType] || elementType}
</h2>
<input
type="text"
className={styles.searchInput}
placeholder="Rechercher..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
autoFocus
/>
<button className={styles.closeBtn} onClick={onClose}>×</button>
</div>
{/* Body: sidebar + swatch grid */}
<div className={styles.body}>
{/* Collection sidebar */}
<div className={styles.sidebar}>
<button
type="button"
className={`${styles.sidebarTab} ${selectedCollection === 'all' ? styles.sidebarTabActive : ''}`}
onClick={() => setSelectedCollection('all')}
>
Tous
<span className={styles.tabCount}>{finishes.length}</span>
</button>
{collections.map(col => {
const count = finishes.filter(f => f.collection === col).length;
return (
<button
key={col}
type="button"
className={`${styles.sidebarTab} ${selectedCollection === col ? styles.sidebarTabActive : ''}`}
onClick={() => setSelectedCollection(col)}
>
{col}
<span className={styles.tabCount}>{count}</span>
</button>
);
})}
</div>
{/* Swatch grid */}
<div className={styles.swatchArea}>
{/* AI Recommendations */}
{(aiRecs.length > 0 || aiLoading) && (
<div className={styles.aiSection}>
<div className={styles.aiHeader}>
<span className={styles.aiBadge}>IA</span>
<span className={styles.aiLabel}>Recommandations</span>
</div>
{aiLoading ? (
<div className={styles.aiShimmer}>
<div className={styles.aiShimmerDot} />
<div className={styles.aiShimmerDot} />
<div className={styles.aiShimmerDot} />
</div>
) : (
<div className={styles.aiSwatches}>
{aiRecs.map(rec => {
const finish = finishes.find(f => f.id === rec.finishId);
if (!finish) return null;
const isSelected = selectedFinish?.id === finish.id;
return (
<button
key={`ai-${finish.id}`}
type="button"
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''} ${styles.aiSwatch}`}
onClick={() => setSelectedFinish(finish)}
onMouseEnter={() => setHoveredFinish({ ...finish, aiReason: rec.reason })}
onMouseLeave={() => setHoveredFinish(null)}
title={rec.reason}
>
{finish.image_url ? (
<img src={finish.image_url} alt="" className={styles.swatchImg} loading="lazy" />
) : (
<div className={styles.swatchColor} style={{ backgroundColor: finish.hexColor || '#ccc' }} />
)}
{isSelected && <span className={styles.swatchCheck}>✓</span>}
<span className={styles.aiSwatchBadge}>★</span>
</button>
);
})}
</div>
)}
</div>
)}
{filteredFinishes.length > 0 ? (
<div className={styles.swatchGrid}>
{filteredFinishes.map(finish => {
const isSelected = selectedFinish?.id === finish.id;
const isHovered = hoveredFinish?.id === finish.id;
return (
<button
key={finish.id}
type="button"
className={`${styles.swatch} ${isSelected ? styles.swatchSelected : ''}`}
onClick={() => setSelectedFinish(finish)}
onMouseEnter={() => setHoveredFinish(finish)}
onMouseLeave={() => setHoveredFinish(null)}
title={`${finish.name || finish.label || ''} ${finish.code ? `(${finish.code})` : ''}`}
>
{finish.image_url ? (
<img
src={finish.image_url}
alt=""
className={styles.swatchImg}
loading="lazy"
/>
) : (
<div
className={styles.swatchColor}
style={{ backgroundColor: finish.hexColor || '#ccc' }}
/>
)}
{isSelected && <span className={styles.swatchCheck}>✓</span>}
</button>
);
})}
</div>
) : (
<div className={styles.empty}>Aucune finition trouvee</div>
)}
{/* Hover detail strip -- shows info of hovered swatch */}
{hoveredFinish && (
<div className={styles.hoverDetail} ref={tooltipRef}>
{hoveredFinish.image_url && (
<img src={hoveredFinish.image_url} alt="" className={styles.hoverImg} />
)}
<div className={styles.hoverInfo}>
<strong>{hoveredFinish.name || hoveredFinish.label}</strong>
{hoveredFinish.code && <span className={styles.hoverCode}>{hoveredFinish.code}</span>}
{hoveredFinish.collection && <span className={styles.hoverColl}>{hoveredFinish.collection}</span>}
{hoveredFinish.price_chf != null && (
<span className={styles.hoverPrice}>{hoveredFinish.price_chf.toFixed(2)} CHF</span>
)}
</div>
</div>
)}
</div>
</div>
{/* Footer: selection preview + actions */}
<div className={styles.footer}>
<div className={styles.preview}>
{selectedFinish ? (
<>
{selectedFinish.image_url ? (
<img src={selectedFinish.image_url} alt="" className={styles.previewImg} />
) : selectedFinish.hexColor ? (
<div className={styles.previewDot} style={{ backgroundColor: selectedFinish.hexColor }} />
) : null}
<strong>{selectedFinish.name || selectedFinish.label}</strong>
</>
) : (
<span className={styles.previewLabel}>Aucune selection</span>
)}
</div>
<div className={styles.actions}>
<button className={styles.secondaryBtn} onClick={handleApplyToAll} disabled={!selectedFinish}>
Appliquer a tous
</button>
<button className={styles.cancelBtn} onClick={onClose}>Annuler</button>
<button className={styles.confirmBtn} onClick={handleConfirm} disabled={!selectedFinish}>
Appliquer
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,491 @@
/* ===================================================================
ColorPickerModal -- compact swatch-grid layout
Sidebar collections + dense swatch grid = no scroll needed
=================================================================== */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
}
.modal {
background: #fff;
border-radius: 12px;
width: 95vw;
max-width: 1100px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
/* --- Header: title + search + close --- */
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
background: var(--bg-subtle);
flex-shrink: 0;
}
.title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.searchInput {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 0.85rem;
max-width: 260px;
}
.searchInput:focus {
outline: none;
border-color: var(--action);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
.closeBtn {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-muted);
cursor: pointer;
padding: 2px 6px;
line-height: 1;
margin-left: auto;
}
.closeBtn:hover {
color: var(--text);
}
/* --- Body: sidebar + swatch area --- */
.body {
display: flex;
flex: 1;
min-height: 0; /* allow flex shrink */
overflow: hidden;
}
/* --- Sidebar: vertical collection tabs --- */
.sidebar {
width: 140px;
flex-shrink: 0;
border-right: 1px solid var(--border-light);
background: var(--bg-subtle);
overflow-y: auto;
padding: 6px 0;
}
.sidebarTab {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 7px 12px;
border: none;
background: none;
font-size: 0.78rem;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
text-align: left;
transition: all 0.15s;
border-left: 3px solid transparent;
}
.sidebarTab:hover {
color: var(--text);
background: rgba(0, 0, 0, 0.03);
}
.sidebarTabActive {
color: var(--action);
background: #fff;
border-left-color: var(--action);
font-weight: 600;
}
.tabCount {
font-size: 0.65rem;
color: var(--text-muted);
background: var(--border-light);
padding: 1px 5px;
border-radius: 8px;
min-width: 1.4rem;
text-align: center;
}
.sidebarTabActive .tabCount {
background: rgba(79, 70, 229, 0.12);
color: var(--action);
}
/* --- Swatch area: grid + hover detail --- */
.swatchArea {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 12px 16px;
position: relative;
}
/* Dense swatch grid -- small squares, many per row */
.swatchGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
gap: 6px;
align-content: start;
}
.swatch {
position: relative;
width: 100%;
aspect-ratio: 1;
border: 2px solid var(--border-light);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: all 0.15s;
padding: 0;
background: #fff;
}
.swatch:hover {
border-color: var(--action);
transform: scale(1.08);
z-index: 2;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.swatchSelected {
border-color: var(--action);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.25);
z-index: 1;
}
.swatchImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.swatchColor {
width: 100%;
height: 100%;
}
.swatchCheck {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
background: var(--action);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
font-weight: 700;
line-height: 1;
}
.empty {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 0.9rem;
}
/* --- Hover detail strip (bottom of swatch area) --- */
.hoverDetail {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
margin-top: 8px;
background: var(--bg-subtle);
border: 1px solid var(--border-light);
border-radius: 8px;
flex-shrink: 0;
}
.hoverImg {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.hoverInfo {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
font-size: 0.8rem;
color: var(--text);
}
.hoverInfo strong {
font-size: 0.85rem;
}
.hoverCode {
font-size: 0.7rem;
color: var(--text-muted);
font-family: monospace;
}
.hoverColl {
font-size: 0.7rem;
color: var(--text-muted);
}
.hoverPrice {
font-size: 0.75rem;
color: var(--success);
font-weight: 600;
}
/* --- Footer: selection preview + action buttons --- */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 1px solid var(--border-light);
background: var(--bg-subtle);
flex-shrink: 0;
gap: 12px;
}
.preview {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--text);
min-width: 0;
}
.preview strong {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.previewImg {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border-light);
flex-shrink: 0;
}
.previewDot {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid var(--border-light);
flex-shrink: 0;
}
.previewLabel {
color: var(--text-muted);
font-size: 0.8rem;
}
.actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.cancelBtn,
.confirmBtn,
.secondaryBtn {
padding: 7px 14px;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.cancelBtn {
background: #fff;
border: 1px solid var(--border-light);
color: var(--text);
}
.cancelBtn:hover {
background: var(--bg-subtle);
}
.secondaryBtn {
background: #fff;
border: 1px solid var(--action);
color: var(--action);
}
.secondaryBtn:hover:not(:disabled) {
background: #eef2ff;
}
.secondaryBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.confirmBtn {
background: var(--action);
border: none;
color: #fff;
}
.confirmBtn:hover:not(:disabled) {
background: var(--action-hover);
}
.confirmBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* --- Mobile --- */
@media (max-width: 640px) {
.modal {
max-height: 95vh;
max-width: 100%;
}
.header {
flex-wrap: wrap;
}
.searchInput {
max-width: none;
order: 3;
flex-basis: 100%;
}
.sidebar {
width: 100px;
}
.swatchGrid {
grid-template-columns: repeat(auto-fill, minmax(44px, 1fr));
}
.footer {
flex-direction: column;
gap: 8px;
}
.actions {
width: 100%;
justify-content: flex-end;
}
}
/* AI Recommendations section */
.aiSection {
padding: 8px 12px;
margin-bottom: 8px;
background: linear-gradient(135deg, #f8f7ff 0%, #eef2ff 100%);
border-radius: 8px;
border: 1px solid rgba(79, 70, 229, 0.15);
}
.aiHeader {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.aiBadge {
font-size: 0.55rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 5px;
border-radius: 3px;
background: var(--action, #4f46e5);
color: #fff;
}
.aiLabel {
font-size: 0.75rem;
color: var(--text-muted, #6b7280);
font-weight: 500;
}
.aiSwatches {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.aiSwatch {
position: relative;
border-color: rgba(79, 70, 229, 0.3) !important;
}
.aiSwatchBadge {
position: absolute;
top: -3px;
right: -3px;
font-size: 0.5rem;
background: var(--action, #4f46e5);
color: #fff;
width: 12px;
height: 12px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.aiShimmer {
display: flex;
gap: 6px;
}
.aiShimmerDot {
width: 48px;
height: 48px;
border-radius: 6px;
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
animation: aiShimmer 1.5s infinite;
}
@keyframes aiShimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
+108
View File
@@ -0,0 +1,108 @@
/**
* CuttingListTable - Display cutting list in a table format
* Shows pieces grouped by finish with dimensions and quantities
*/
import { useMemo } from 'react';
import styles from './CuttingListTable.module.css';
export default function CuttingListTable({ cuttingList, onExport }) {
if (!cuttingList || !cuttingList.pieces) {
return (
<div className={styles.empty}>
Aucune donnée de débitage disponible
</div>
);
}
const { pieces, byFinish, summary } = cuttingList;
return (
<div className={styles.container}>
{/* Summary */}
<div className={styles.summary}>
<h3 className={styles.summaryTitle}>Résumé</h3>
<div className={styles.summaryGrid}>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Pièces totales</span>
<span className={styles.summaryValue}>{summary.totalPieces}</span>
</div>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Surface totale</span>
<span className={styles.summaryValue}>{summary.totalSurfaceM2.toFixed(2)} m²</span>
</div>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Chant plaqué</span>
<span className={styles.summaryValue}>{summary.edgeBandingMl} ml</span>
</div>
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>Finitions</span>
<span className={styles.summaryValue}>{summary.finishCount}</span>
</div>
</div>
</div>
{/* Tables by finish */}
{byFinish.map((group, groupIndex) => (
<div key={groupIndex} className={styles.finishGroup}>
<h4 className={styles.finishTitle}>
<span className={styles.finishBadge}>{group.finishLabel}</span>
<span className={styles.finishSummary}>
{group.pieceCount} pièces • {group.totalSurfaceM2.toFixed(2)} m²
</span>
</h4>
<table className={styles.table}>
<thead>
<tr>
<th>Description</th>
<th>Largeur</th>
<th>Hauteur</th>
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
<th>Chant</th>
</tr>
</thead>
<tbody>
{group.pieces.map((piece, pieceIndex) => (
<tr key={pieceIndex}>
<td className={styles.descCell}>{piece.description}</td>
<td className={styles.dimCell}>{piece.widthMm}</td>
<td className={styles.dimCell}>{piece.heightMm}</td>
<td className={styles.dimCell}>{piece.thicknessMm}</td>
<td className={styles.qtyCell}>{piece.quantity}</td>
<td className={styles.surfaceCell}>{piece.surfaceM2.toFixed(3)}</td>
<td className={styles.edgeCell}>
{piece.edgeBanding.type !== 'none' && (
<span className={`${styles.edgeBadge} ${styles[piece.edgeBanding.type]}`}>
{piece.edgeBanding.sides} côtés
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
{/* Export buttons */}
{onExport && (
<div className={styles.exportActions}>
<button
className={styles.exportBtn}
onClick={() => onExport('text')}
>
Exporter en texte
</button>
<button
className={`${styles.exportBtn} ${styles.primary}`}
onClick={() => onExport('pdf')}
>
Exporter en PDF
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,195 @@
.container {
background: #fff;
border-radius: 8px;
overflow: hidden;
}
.empty {
padding: 40px;
text-align: center;
color: #6b7280;
}
.summary {
background: #f0f7ff;
padding: 16px;
border-bottom: 1px solid #cce0ff;
}
.summaryTitle {
margin: 0 0 12px;
font-size: 1rem;
font-weight: 600;
color: #1e40af;
}
.summaryGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.summaryItem {
display: flex;
flex-direction: column;
gap: 2px;
}
.summaryLabel {
font-size: 0.75rem;
color: #6b7280;
}
.summaryValue {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
}
.finishGroup {
padding: 16px;
border-bottom: 1px solid #e5e7eb;
}
.finishGroup:last-of-type {
border-bottom: none;
}
.finishTitle {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 12px;
font-size: 0.9rem;
font-weight: 500;
}
.finishBadge {
padding: 4px 10px;
background: #4f46e5;
color: #fff;
border-radius: 4px;
font-size: 0.8rem;
}
.finishSummary {
color: #6b7280;
font-size: 0.8rem;
font-weight: 400;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.table th {
padding: 8px 10px;
background: #f9fafb;
border: 1px solid #e5e7eb;
text-align: left;
font-weight: 600;
color: #374151;
font-size: 0.75rem;
text-transform: uppercase;
}
.table td {
padding: 8px 10px;
border: 1px solid #e5e7eb;
color: #1f2937;
}
.descCell {
min-width: 180px;
}
.dimCell {
text-align: right;
font-family: monospace;
font-size: 0.85rem;
}
.qtyCell {
text-align: center;
font-weight: 500;
}
.surfaceCell {
text-align: right;
font-family: monospace;
font-size: 0.85rem;
color: #059669;
}
.edgeCell {
text-align: center;
}
.edgeBadge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 500;
}
.edgeBadge.visible {
background: #dcfce7;
color: #166534;
}
.edgeBadge.hidden {
background: #fef3c7;
color: #92400e;
}
.exportActions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.exportBtn {
padding: 10px 20px;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: #fff;
border: 1px solid #d1d5db;
color: #374151;
}
.exportBtn:hover {
background: #f3f4f6;
}
.exportBtn.primary {
background: #4f46e5;
border-color: #4f46e5;
color: #fff;
}
.exportBtn.primary:hover {
background: #4338ca;
}
@media (max-width: 640px) {
.table {
font-size: 0.75rem;
}
.descCell {
min-width: 100px;
}
.summaryGrid {
grid-template-columns: repeat(2, 1fr);
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* DesignTip - AI-powered design advice card that fades in below the 3D preview.
* Shows contextual tips, compliments, and trends based on current configuration.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import styles from './DesignTip.module.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const CATEGORY_ICONS = {
compliment: '✨',
conseil: '💡',
tendance: '🎯'
};
export default function DesignTip({ config }) {
const [tip, setTip] = useState(null);
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const debounceRef = useRef(null);
const abortRef = useRef(null);
const fetchTip = useCallback(async (cfg) => {
// Cancel previous request
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
try {
const body = {
config: {
width: cfg.totalWidth,
height: cfg.height,
depth: cfg.depth,
installationType: cfg.installationType,
columns: cfg.modules?.map(m => ({
type: m.type || m.moduleCode,
width: m.width,
shelves: m.shelves,
drawers: m.drawers,
hasRod: m.hasRod
})),
materialName: cfg.finishLabel || null,
doorType: cfg.doorType
}
};
const res = await fetch(`${API_BASE}/api/ai/design-tips`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal
});
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (data.tip) {
setTip(data);
setVisible(true);
}
} catch (err) {
if (err.name !== 'AbortError') {
// Silently fail — tip is optional
setVisible(false);
}
} finally {
setLoading(false);
}
}, []);
// Debounced fetch on config change
useEffect(() => {
if (!config || !config.modules?.length) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
fetchTip(config);
}, 2000); // 2s debounce to avoid spamming on rapid changes
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [
config?.totalWidth,
config?.height,
config?.modules?.length,
config?.modules?.map(m => m.type).join(','),
config?.finishId,
config?.doorType,
fetchTip
]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortRef.current) abortRef.current.abort();
};
}, []);
if (!visible && !loading) return null;
return (
<div className={`${styles.container} ${visible && tip ? styles.visible : ''}`}>
{loading && !tip && (
<div className={styles.shimmer}>
<div className={styles.shimmerLine} />
<div className={styles.shimmerLineShort} />
</div>
)}
{tip && (
<div className={styles.tipContent}>
<span className={styles.icon}>{CATEGORY_ICONS[tip.category] || '✨'}</span>
<p className={styles.text}>{tip.tip}</p>
<span className={styles.badge}>IA</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,78 @@
.container {
margin-top: 8px;
padding: 10px 14px;
border-radius: 10px;
background: linear-gradient(135deg, #f8f7ff 0%, #f0f4ff 100%);
border: 1px solid var(--border-light, #e5e7eb);
opacity: 0;
transform: translateY(6px);
transition: opacity 0.4s ease, transform 0.4s ease;
min-height: 40px;
}
.visible {
opacity: 1;
transform: translateY(0);
}
.tipContent {
display: flex;
align-items: flex-start;
gap: 10px;
}
.icon {
font-size: 1.2rem;
flex-shrink: 0;
margin-top: 1px;
}
.text {
margin: 0;
font-size: 0.85rem;
color: var(--text, #374151);
line-height: 1.45;
flex: 1;
}
.badge {
flex-shrink: 0;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 6px;
border-radius: 4px;
background: var(--action, #4f46e5);
color: #fff;
margin-top: 2px;
}
/* Loading shimmer */
.shimmer {
display: flex;
flex-direction: column;
gap: 6px;
}
.shimmerLine,
.shimmerLineShort {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.shimmerLine {
width: 80%;
}
.shimmerLineShort {
width: 50%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
+37
View File
@@ -0,0 +1,37 @@
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import styles from './Layout.module.css';
export default function Layout({ children }) {
const { isAdmin, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
return (
<div className={styles.app}>
<header className={styles.header}>
<Link to="/" className={styles.logo}>SIGNATURE</Link>
<nav>
{isAdmin ? (
<>
<Link to="/">Accueil</Link>
<Link to="/configurateur">Configurateur</Link>
<Link to="/materiaux">Matériaux</Link>
<Link to="/admin">Admin</Link>
<button type="button" className={styles.logoutBtn} onClick={handleLogout}>Déconnexion</button>
</>
) : (
<Link to="/login">Connexion</Link>
)}
</nav>
</header>
<main className={styles.main}>
{children}
</main>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.logo {
font-weight: 700;
font-size: 1.25rem;
letter-spacing: 0.02em;
text-decoration: none;
color: var(--primary);
}
.logo:hover {
text-decoration: none;
color: var(--accent);
}
.header nav {
display: flex;
gap: 1.5rem;
}
.header nav a {
color: var(--text-muted);
font-weight: 500;
}
.header nav a:hover {
color: var(--primary);
}
.header nav .logoutBtn {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--text-muted);
font-weight: 500;
cursor: pointer;
}
.header nav .logoutBtn:hover {
color: var(--primary);
}
.main {
flex: 1;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
}
@@ -0,0 +1,205 @@
import { useState, useEffect } from 'react';
import { fetchMaterialDetails } from '../api/client';
import styles from './MaterialDetailModal.module.css';
/**
* Modal for displaying detailed material information in the configurator.
* Shows product image, specs, dimension variants, and color variants.
*
* @param {Object} props
* @param {Object} props.material - The material object (basic info)
* @param {number|null} props.selectedDimensionId - Currently selected dimension variant ID
* @param {Function} props.onSelectDimension - Called when a dimension is selected (dimensionId)
* @param {Function} props.onSelectVariant - Called when a color variant is selected (material)
* @param {Function} props.onConfirm - Called when user confirms selection
* @param {Function} props.onClose - Called to close the modal
*/
export default function MaterialDetailModal({
material,
selectedDimensionId,
onSelectDimension,
onSelectVariant,
onConfirm,
onClose
}) {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [localDimensionId, setLocalDimensionId] = useState(selectedDimensionId);
useEffect(() => {
if (!material?.id) return;
setLoading(true);
setError(null);
fetchMaterialDetails(material.id)
.then(data => {
setDetails(data);
// Reset dimension to default when material changes
if (data.dimensions?.length > 0) {
const defaultDim = data.dimensions.find(d => d.is_default) || data.dimensions[0];
setLocalDimensionId(defaultDim.id);
if (onSelectDimension) onSelectDimension(defaultDim.id);
}
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [material?.id]);
const handleDimensionClick = (dimId) => {
setLocalDimensionId(dimId);
if (onSelectDimension) {
onSelectDimension(dimId);
}
};
const handleVariantClick = (variant) => {
if (onSelectVariant) {
onSelectVariant(variant);
}
};
const handleConfirm = () => {
if (onConfirm) {
onConfirm(material.id, localDimensionId);
}
onClose();
};
const selectedDim = details?.dimensions?.find(d => d.id === localDimensionId);
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<button className={styles.closeBtn} onClick={onClose} type="button" aria-label="Fermer">
&times;
</button>
{loading && <div className={styles.loading}>Chargement...</div>}
{error && <div className={styles.error}>Erreur: {error}</div>}
{!loading && !error && details && (
<>
<div className={styles.content}>
{/* Product Image */}
<div className={styles.imageSection}>
{details.image_url ? (
<img
src={details.image_url}
alt={details.name}
className={styles.productImage}
/>
) : (
<div className={styles.noImage}>Pas d'image</div>
)}
</div>
{/* Product Info */}
<div className={styles.infoSection}>
<h2 className={styles.productName}>{details.name}</h2>
<div className={styles.specs}>
<div className={styles.specRow}>
<span className={styles.specLabel}>Code:</span>
<span className={styles.specValue}>{details.formex_code}</span>
</div>
<div className={styles.specRow}>
<span className={styles.specLabel}>Collection:</span>
<span className={styles.specValue}>{details.collection}</span>
</div>
{details.category && (
<div className={styles.specRow}>
<span className={styles.specLabel}>Catégorie:</span>
<span className={styles.specValue}>{details.category}</span>
</div>
)}
{details.price_chf && (
<div className={styles.specRow}>
<span className={styles.specLabel}>Prix:</span>
<span className={styles.specValue}>{details.price_chf.toFixed(2)} CHF</span>
</div>
)}
</div>
{/* Dimension Variants */}
{details.dimensions && details.dimensions.length > 0 && (
<div className={styles.dimensionsSection}>
<h3 className={styles.sectionTitle}>Dimensions disponibles</h3>
<div className={styles.dimensionButtons}>
{details.dimensions.map(dim => (
<button
key={dim.id}
type="button"
className={`${styles.dimensionBtn} ${dim.id === localDimensionId ? styles.selected : ''}`}
onClick={() => handleDimensionClick(dim.id)}
>
{dim.width_mm} x {dim.height_mm} x {dim.thickness_mm} mm
</button>
))}
</div>
{selectedDim && (
<p className={styles.selectedDimInfo}>
Format sélectionné: <strong>{selectedDim.width_mm} x {selectedDim.height_mm} x {selectedDim.thickness_mm} mm</strong>
</p>
)}
</div>
)}
{/* Color Variants */}
{details.variants && details.variants.length > 0 && (
<div className={styles.variantsSection}>
<h3 className={styles.sectionTitle}>Autres coloris</h3>
<div className={styles.variantThumbs}>
{details.variants.map(variant => (
<button
key={variant.id}
type="button"
className={styles.variantThumb}
onClick={() => handleVariantClick(variant)}
title={variant.variant_name}
>
{variant.image_url ? (
<img src={variant.image_url} alt={variant.variant_name} />
) : (
<span className={styles.variantCode}>{variant.variant_code}</span>
)}
</button>
))}
</div>
</div>
)}
{/* Formex Link */}
{details.formex_url && (
<a
href={details.formex_url}
target="_blank"
rel="noopener noreferrer"
className={styles.formexLink}
>
Voir sur Formex.ch &rarr;
</a>
)}
</div>
</div>
{/* Actions */}
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
Annuler
</button>
<button type="button" className={styles.confirmBtn} onClick={handleConfirm}>
Sélectionner ce matériau
</button>
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,270 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #fff;
border-radius: 12px;
max-width: 900px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.closeBtn {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #666;
line-height: 1;
padding: 0;
z-index: 10;
}
.closeBtn:hover {
color: #000;
}
.loading,
.error {
padding: 3rem;
text-align: center;
font-size: 1.1rem;
}
.error {
color: #c00;
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
padding: 2rem;
}
@media (max-width: 768px) {
.content {
grid-template-columns: 1fr;
}
}
/* Image Section */
.imageSection {
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border-radius: 8px;
min-height: 300px;
}
.productImage {
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 8px;
}
.noImage {
color: #999;
font-style: italic;
}
/* Info Section */
.infoSection {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.productName {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
line-height: 1.3;
}
/* Specs */
.specs {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
}
.specRow {
display: flex;
gap: 0.75rem;
}
.specLabel {
font-weight: 500;
color: #666;
min-width: 100px;
}
.specValue {
color: #1a1a1a;
}
/* Dimensions */
.dimensionsSection,
.variantsSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sectionTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #333;
}
.dimensionButtons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.dimensionBtn {
padding: 0.5rem 1rem;
border: 2px solid #ddd;
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.dimensionBtn:hover {
border-color: var(--action);
}
.dimensionBtn.selected {
border-color: var(--action);
background: #eef2ff;
color: var(--action);
font-weight: 500;
}
.selectedDimInfo {
margin: 0;
font-size: 0.9rem;
color: #666;
}
/* Color Variants */
.variantThumbs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.variantThumb {
width: 60px;
height: 60px;
border: 2px solid #ddd;
border-radius: 6px;
padding: 2px;
background: #fff;
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.variantThumb:hover {
border-color: var(--action);
transform: scale(1.05);
}
.variantThumb img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
.variantCode {
font-size: 0.7rem;
color: #666;
text-align: center;
}
/* Formex Link */
.formexLink {
display: inline-block;
color: var(--action);
text-decoration: none;
font-size: 0.9rem;
margin-top: auto;
}
.formexLink:hover {
text-decoration: underline;
}
/* Actions -- sticky at bottom */
.actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem 2rem;
border-top: 1px solid var(--border-light);
background: var(--bg-subtle);
border-radius: 0 0 12px 12px;
position: sticky;
bottom: 0;
z-index: 5;
}
.cancelBtn,
.confirmBtn {
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.cancelBtn {
background: #fff;
border: 1px solid #ddd;
color: #666;
}
.cancelBtn:hover {
background: #f5f5f5;
}
.confirmBtn {
background: var(--action);
border: none;
color: #fff;
font-weight: 500;
}
.confirmBtn:hover {
background: var(--action-hover);
}
+201
View File
@@ -0,0 +1,201 @@
import { useState, useEffect } from 'react';
import { createFormexMaterial, updateFormexMaterial } from '../api/client';
import styles from './MaterialModal.module.css';
export default function MaterialModal({ material, collections, onClose, onSave }) {
const isEditing = !!material;
const [formData, setFormData] = useState({
formex_code: '',
name: '',
collection: '',
width_mm: '',
height_mm: '',
thickness_mm: '',
formex_url: ''
});
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (material) {
setFormData({
formex_code: material.formex_code || '',
name: material.name || '',
collection: material.collection || '',
width_mm: material.width_mm?.toString() || '',
height_mm: material.height_mm?.toString() || '',
thickness_mm: material.thickness_mm?.toString() || '',
formex_url: material.formex_url || ''
});
}
}, [material]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
// Validation
if (!formData.formex_code.trim() || !formData.name.trim() || !formData.collection.trim()) {
setError('Le code, le nom et la collection sont requis.');
return;
}
setSaving(true);
try {
const payload = {
formex_code: formData.formex_code.trim(),
name: formData.name.trim(),
collection: formData.collection.trim(),
width_mm: formData.width_mm ? parseInt(formData.width_mm, 10) : null,
height_mm: formData.height_mm ? parseInt(formData.height_mm, 10) : null,
thickness_mm: formData.thickness_mm ? parseInt(formData.thickness_mm, 10) : null,
formex_url: formData.formex_url.trim() || null
};
if (isEditing) {
await updateFormexMaterial(material.id, payload);
} else {
await createFormexMaterial(payload);
}
onSave();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleOverlayClick = (e) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div className={styles.overlay} onClick={handleOverlayClick}>
<div className={styles.modal}>
<div className={styles.header}>
<h2>{isEditing ? 'Modifier le matériau' : 'Nouveau matériau'}</h2>
<button type="button" onClick={onClose} className={styles.closeBtn}>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.field}>
<label htmlFor="formex_code">Code Formex *</label>
<input
type="text"
id="formex_code"
name="formex_code"
value={formData.formex_code}
onChange={handleChange}
placeholder="ex: 10000"
required
/>
</div>
<div className={styles.field}>
<label htmlFor="name">Nom *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="ex: Panneaux mélaminés Duplex Plus 10000 Tilia"
required
/>
</div>
<div className={styles.field}>
<label htmlFor="collection">Collection *</label>
<input
type="text"
id="collection"
name="collection"
value={formData.collection}
onChange={handleChange}
list="collections-list"
placeholder="ex: Duplex Plus"
required
/>
<datalist id="collections-list">
{collections.map((c) => (
<option key={c} value={c} />
))}
</datalist>
</div>
<div className={styles.row}>
<div className={styles.field}>
<label htmlFor="width_mm">Largeur (mm)</label>
<input
type="number"
id="width_mm"
name="width_mm"
value={formData.width_mm}
onChange={handleChange}
placeholder="2800"
/>
</div>
<div className={styles.field}>
<label htmlFor="height_mm">Hauteur (mm)</label>
<input
type="number"
id="height_mm"
name="height_mm"
value={formData.height_mm}
onChange={handleChange}
placeholder="2070"
/>
</div>
<div className={styles.field}>
<label htmlFor="thickness_mm">Épaisseur (mm)</label>
<input
type="number"
id="thickness_mm"
name="thickness_mm"
value={formData.thickness_mm}
onChange={handleChange}
placeholder="19"
/>
</div>
</div>
<div className={styles.field}>
<label htmlFor="formex_url">URL Formex</label>
<input
type="url"
id="formex_url"
name="formex_url"
value={formData.formex_url}
onChange={handleChange}
placeholder="https://www.formex.ch/fr/shop/..."
/>
</div>
<div className={styles.actions}>
<button type="button" onClick={onClose} className={styles.cancelBtn}>
Annuler
</button>
<button type="submit" disabled={saving} className={styles.saveBtn}>
{saving ? 'Enregistrement…' : (isEditing ? 'Enregistrer' : 'Créer')}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,135 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #fff;
border-radius: 8px;
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--primary);
}
.closeBtn {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-muted);
cursor: pointer;
padding: 0;
line-height: 1;
}
.closeBtn:hover {
color: var(--primary);
}
.form {
padding: 1.5rem;
}
.error {
color: #b91c1c;
font-size: 0.875rem;
margin: 0 0 1rem;
padding: 0.75rem;
background: #fef2f2;
border-radius: 6px;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.375rem;
color: var(--primary);
}
.field input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
}
.field input:focus {
outline: none;
border-color: var(--accent);
}
.row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.cancelBtn {
padding: 0.5rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
.cancelBtn:hover {
border-color: var(--accent);
}
.saveBtn {
padding: 0.5rem 1.25rem;
background: var(--accent);
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.saveBtn:hover:not(:disabled) {
background: var(--primary);
}
.saveBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Plans2DView - Display 2D plans (assembly, side, top views)
* With tabs for different views and export options
*/
import { useState, useEffect, useCallback } from 'react';
import CuttingListTable from './CuttingListTable';
import styles from './Plans2DView.module.css';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
export default function Plans2DView({ config, modules = [], colors = {}, onClose }) {
const [activeTab, setActiveTab] = useState('assembly');
const [plans, setPlans] = useState(null);
const [cuttingList, setCuttingList] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Fetch plans and cutting list
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
// Fetch plans
const plansRes = await fetch(`${API_BASE}/api/plans/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const plansData = await plansRes.json();
if (plansData.success) {
setPlans(plansData.plans);
}
// Fetch cutting list
const cutRes = await fetch(`${API_BASE}/api/plans/cutting-list`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const cutData = await cutRes.json();
if (cutData.success) {
setCuttingList(cutData.cuttingList);
}
} catch (err) {
console.error('Error fetching plans:', err);
setError('Erreur lors de la génération des plans');
} finally {
setLoading(false);
}
};
fetchData();
}, [config, modules, colors]);
// Export handlers
const handleExport = useCallback(async (format) => {
try {
if (format === 'text') {
const res = await fetch(`${API_BASE}/api/plans/export-text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'liste-debitage.txt';
a.click();
URL.revokeObjectURL(url);
} else if (format === 'pdf') {
const res = await fetch(`${API_BASE}/api/plans/export-pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config, modules, colors })
});
const html = await res.text();
// Open in new window for printing
const printWindow = window.open('', '_blank');
printWindow.document.write(html);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
}
} catch (err) {
console.error('Export error:', err);
alert('Erreur lors de l\'export');
}
}, [config, modules, colors]);
const tabs = [
{ id: 'assembly', label: 'Vue de face' },
{ id: 'side', label: 'Vue de côté' },
{ id: 'top', label: 'Vue de dessus' },
{ id: 'cutting', label: 'Liste de débitage' }
];
return (
<div className={styles.overlay}>
<div className={styles.modal}>
<div className={styles.header}>
<h2 className={styles.title}>Plans de fabrication</h2>
<button className={styles.closeBtn} onClick={onClose}>×</button>
</div>
<div className={styles.tabs}>
{tabs.map(tab => (
<button
key={tab.id}
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className={styles.content}>
{loading ? (
<div className={styles.loading}>Génération des plans...</div>
) : error ? (
<div className={styles.error}>{error}</div>
) : (
<>
{activeTab === 'assembly' && plans?.assembly && (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.assembly }}
/>
)}
{activeTab === 'side' && plans?.side && (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.side }}
/>
)}
{activeTab === 'top' && plans?.top && (
<div
className={styles.planContainer}
dangerouslySetInnerHTML={{ __html: plans.top }}
/>
)}
{activeTab === 'cutting' && (
<CuttingListTable
cuttingList={cuttingList}
onExport={handleExport}
/>
)}
</>
)}
</div>
<div className={styles.footer}>
<div className={styles.specs}>
<span>{config.widthMm} × {config.heightMm} × {config.depthMm} mm</span>
<span>•</span>
<span>{config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</span>
<span>•</span>
<span>{config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</span>
</div>
<div className={styles.actions}>
<button
className={styles.exportBtn}
onClick={() => handleExport('pdf')}
>
Imprimer / PDF
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,192 @@
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: #fff;
border-radius: 12px;
width: 100%;
max-width: 1000px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
}
.closeBtn {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-muted);
cursor: pointer;
padding: 4px 8px;
line-height: 1;
}
.closeBtn:hover {
color: var(--text);
}
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-light);
padding: 0 20px;
background: var(--bg-subtle);
}
.tab {
padding: 12px 20px;
background: none;
border: none;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
position: relative;
transition: color 0.2s;
}
.tab:hover {
color: var(--action);
}
.tab.active {
color: var(--action);
}
.tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--action);
}
.content {
flex: 1;
overflow: auto;
padding: 20px;
min-height: 400px;
}
.loading,
.error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 0.9rem;
}
.error {
color: var(--danger);
}
.planContainer {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--bg-subtle);
border-radius: 8px;
min-height: 300px;
}
.planContainer svg {
max-width: 100%;
height: auto;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-top: 1px solid var(--border-light);
background: var(--bg-subtle);
}
.specs {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--text-muted);
}
.actions {
display: flex;
gap: 10px;
}
.exportBtn {
padding: 10px 20px;
background: var(--action);
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.exportBtn:hover {
background: var(--action-hover);
}
@media (max-width: 768px) {
.modal {
max-height: 95vh;
}
.tabs {
overflow-x: auto;
padding: 0 10px;
}
.tab {
padding: 10px 14px;
white-space: nowrap;
font-size: 0.85rem;
}
.footer {
flex-direction: column;
gap: 12px;
}
.specs {
flex-wrap: wrap;
justify-content: center;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({ children }) {
const { isAdmin } = useAuth();
const location = useLocation();
if (!isAdmin) {
return <Navigate to={`/login?next=${encodeURIComponent(location.pathname)}`} replace />;
}
return children;
}
+370
View File
@@ -0,0 +1,370 @@
import { useMemo } from 'react';
import * as THREE from 'three';
/**
* Room3D component - Renders a 3D room based on AI analysis
* Used to display the wardrobe in context of the user's actual room
*/
// Floor material based on type
function FloorMaterial({ material, color }) {
const texture = useMemo(() => {
// In a real implementation, you'd load actual textures
// For now, we use colors with some roughness variation
return null;
}, [material]);
return (
<meshStandardMaterial
color={color || '#c4a574'}
roughness={material === 'wood' ? 0.7 : material === 'tile' ? 0.3 : 0.8}
metalness={0.05}
/>
);
}
// Get material properties from wall material type
function getWallMaterialProps(material, texture) {
const props = { roughness: 0.8, metalness: 0.02 };
switch (material) {
case 'paint':
props.roughness = texture === 'glossy' ? 0.3 : texture === 'smooth' ? 0.7 : 0.8;
break;
case 'wallpaper':
props.roughness = 0.85;
break;
case 'brick':
props.roughness = 0.95;
props.metalness = 0.0;
break;
case 'wood_panel':
props.roughness = 0.65;
props.metalness = 0.05;
break;
case 'concrete':
props.roughness = 0.9;
props.metalness = 0.01;
break;
case 'plaster':
props.roughness = 0.85;
break;
case 'stone':
props.roughness = 0.92;
props.metalness = 0.0;
break;
case 'tile':
props.roughness = 0.3;
props.metalness = 0.05;
break;
default:
break;
}
return props;
}
// Wall with optional window/door cutouts
function Wall({ position, rotation, size, color, material, texture, windows = [], doors = [] }) {
const [width, height] = size;
const matProps = getWallMaterialProps(material, texture);
// Create wall geometry with cutouts (simplified - just overlays for now)
return (
<group position={position} rotation={rotation}>
{/* Main wall */}
<mesh receiveShadow>
<planeGeometry args={[width, height]} />
<meshStandardMaterial
color={color || '#f5f5f0'}
side={THREE.DoubleSide}
roughness={matProps.roughness}
metalness={matProps.metalness}
/>
</mesh>
{/* Windows */}
{windows.map((win, i) => {
const winWidth = width * win.width;
const winHeight = height * win.height;
const winX = (win.x - 0.5) * width;
const winY = (win.y - 0.5) * height;
return (
<group key={`window-${i}`} position={[winX, winY, 0.01]}>
{/* Window glass */}
<mesh>
<planeGeometry args={[winWidth, winHeight]} />
<meshStandardMaterial
color="#87CEEB"
transparent
opacity={0.3}
side={THREE.DoubleSide}
/>
</mesh>
{/* Window frame */}
<lineSegments>
<edgesGeometry args={[new THREE.PlaneGeometry(winWidth, winHeight)]} />
<lineBasicMaterial color="#555" linewidth={2} />
</lineSegments>
</group>
);
})}
{/* Doors */}
{doors.map((door, i) => {
const doorWidth = width * door.width;
const doorHeight = height * door.height;
const doorX = (door.x - 0.5) * width;
const doorY = -(height / 2) + (doorHeight / 2);
return (
<group key={`door-${i}`} position={[doorX, doorY, 0.01]}>
{/* Door opening (darker rectangle) */}
<mesh>
<planeGeometry args={[doorWidth, doorHeight]} />
<meshStandardMaterial color="#2a2a2a" side={THREE.DoubleSide} />
</mesh>
{/* Door frame */}
<lineSegments>
<edgesGeometry args={[new THREE.PlaneGeometry(doorWidth, doorHeight)]} />
<lineBasicMaterial color="#8b7355" linewidth={2} />
</lineSegments>
</group>
);
})}
</group>
);
}
export default function Room3D({ roomConfig, wardrobeConfig }) {
if (!roomConfig) return null;
const { width, length, height, walls, windows, doors, floor, ceiling, wardrobePlacement } = roomConfig;
// Calculate wall positions
const halfWidth = width / 2;
const halfLength = length / 2;
const halfHeight = height / 2;
// Get windows/doors for each wall
const backWindows = windows.filter(w => w.wall === 'back').map(w => ({
x: w.x, y: w.y, width: w.width, height: w.height
}));
const leftWindows = windows.filter(w => w.wall === 'left').map(w => ({
x: w.x, y: w.y, width: w.width, height: w.height
}));
const rightWindows = windows.filter(w => w.wall === 'right').map(w => ({
x: w.x, y: w.y, width: w.width, height: w.height
}));
const backDoors = doors.filter(d => d.wall === 'back').map(d => ({
x: d.x, width: d.width, height: d.height
}));
const leftDoors = doors.filter(d => d.wall === 'left').map(d => ({
x: d.x, width: d.width, height: d.height
}));
const rightDoors = doors.filter(d => d.wall === 'right').map(d => ({
x: d.x, width: d.width, height: d.height
}));
// Calculate wardrobe position based on placement
const getWardrobePosition = () => {
if (!wardrobeConfig || !wardrobePlacement) return [0, 0, 0];
const wardrobeWidth = (wardrobeConfig.widthMm || 2400) / 1000;
const wardrobeDepth = (wardrobeConfig.depthMm || 600) / 1000;
switch (wardrobePlacement.wall) {
case 'back':
return [
((wardrobePlacement.xPercent / 100) - 0.5) * width + wardrobeWidth / 2,
0,
-halfLength + wardrobeDepth / 2 + 0.05
];
case 'left':
return [
-halfWidth + wardrobeDepth / 2 + 0.05,
0,
((wardrobePlacement.xPercent / 100) - 0.5) * length + wardrobeWidth / 2
];
case 'right':
return [
halfWidth - wardrobeDepth / 2 - 0.05,
0,
((wardrobePlacement.xPercent / 100) - 0.5) * length + wardrobeWidth / 2
];
default:
return [0, 0, -halfLength + wardrobeDepth / 2 + 0.05];
}
};
const wardrobePosition = getWardrobePosition();
const wardrobeRotation = wardrobePlacement?.wall === 'left'
? [0, Math.PI / 2, 0]
: wardrobePlacement?.wall === 'right'
? [0, -Math.PI / 2, 0]
: [0, 0, 0];
return (
<group>
{/* Floor */}
<mesh
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 0, 0]}
receiveShadow
>
<planeGeometry args={[width, length]} />
<FloorMaterial material={floor?.material} color={floor?.color} />
</mesh>
{/* Back wall */}
{walls?.back?.visible !== false && (
<Wall
position={[0, halfHeight, -halfLength]}
rotation={[0, 0, 0]}
size={[width, height]}
color={walls?.back?.color}
material={walls?.back?.material}
texture={walls?.back?.texture}
windows={backWindows}
doors={backDoors}
/>
)}
{/* Left wall */}
{walls?.left?.visible !== false && (
<Wall
position={[-halfWidth, halfHeight, 0]}
rotation={[0, Math.PI / 2, 0]}
size={[length, height]}
color={walls?.left?.color}
material={walls?.left?.material}
texture={walls?.left?.texture}
windows={leftWindows}
doors={leftDoors}
/>
)}
{/* Right wall */}
{walls?.right?.visible !== false && (
<Wall
position={[halfWidth, halfHeight, 0]}
rotation={[0, -Math.PI / 2, 0]}
size={[length, height]}
color={walls?.right?.color}
material={walls?.right?.material}
texture={walls?.right?.texture}
windows={rightWindows}
doors={rightDoors}
/>
)}
{/* Ceiling (plafond) */}
{ceiling?.visible !== false && (() => {
const ceilingColor = ceiling?.color || '#ffffff';
const ceilingType = ceiling?.type || 'flat';
const slopeAngle = ceiling?.slopeAngleDeg || 0;
const ceilingMatProps = getWallMaterialProps(ceiling?.material || 'paint', 'smooth');
if (ceilingType === 'sloped' && slopeAngle > 0) {
// Sloped ceiling - render as a tilted plane
const slopeRad = (slopeAngle * Math.PI) / 180;
const slopeDir = ceiling?.slopeDirection || 'front_to_back';
const slopeRotX = slopeDir === 'front_to_back' ? Math.PI / 2 + slopeRad * 0.5 : Math.PI / 2;
const slopeRotZ = slopeDir === 'left_to_right' ? slopeRad * 0.5 : 0;
const slopeW = slopeDir === 'left_to_right' ? width / Math.cos(slopeRad * 0.5) : width;
const slopeL = slopeDir === 'front_to_back' ? length / Math.cos(slopeRad * 0.5) : length;
return (
<mesh
rotation={[slopeRotX, 0, slopeRotZ]}
position={[0, height, 0]}
receiveShadow
>
<planeGeometry args={[slopeW, slopeL]} />
<meshStandardMaterial
color={ceilingColor}
side={THREE.DoubleSide}
roughness={ceilingMatProps.roughness}
metalness={ceilingMatProps.metalness}
/>
</mesh>
);
}
// Flat ceiling
return (
<mesh
rotation={[Math.PI / 2, 0, 0]}
position={[0, height, 0]}
receiveShadow
>
<planeGeometry args={[width, length]} />
<meshStandardMaterial
color={ceilingColor}
side={THREE.DoubleSide}
roughness={ceilingMatProps.roughness}
metalness={ceilingMatProps.metalness}
/>
</mesh>
);
})()}
{/* Wardrobe position indicator (for debugging/visualization) */}
{wardrobePlacement && (
<mesh position={[wardrobePosition[0], 0.01, wardrobePosition[2]]}>
<planeGeometry args={[
(wardrobeConfig?.widthMm || 2400) / 1000,
(wardrobeConfig?.depthMm || 600) / 1000
]} />
<meshBasicMaterial color="#0066cc" transparent opacity={0.3} />
</mesh>
)}
</group>
);
}
// Export position calculator for use by parent
export function calculateWardrobeTransform(roomConfig, wardrobeConfig) {
if (!roomConfig || !roomConfig.wardrobePlacement) {
return { position: [0, 0, 0], rotation: [0, 0, 0] };
}
const { width, length, wardrobePlacement } = roomConfig;
const halfWidth = width / 2;
const halfLength = length / 2;
const wardrobeWidth = (wardrobeConfig?.widthMm || 2400) / 1000;
const wardrobeDepth = (wardrobeConfig?.depthMm || 600) / 1000;
let position, rotation;
switch (wardrobePlacement.wall) {
case 'back':
position = [
((wardrobePlacement.xPercent / 100) - 0.5) * width + wardrobeWidth / 4,
0,
-halfLength + wardrobeDepth / 2 + 0.05
];
rotation = [0, 0, 0];
break;
case 'left':
position = [
-halfWidth + wardrobeDepth / 2 + 0.05,
0,
((wardrobePlacement.xPercent / 100) - 0.5) * length
];
rotation = [0, Math.PI / 2, 0];
break;
case 'right':
position = [
halfWidth - wardrobeDepth / 2 - 0.05,
0,
((wardrobePlacement.xPercent / 100) - 0.5) * length
];
rotation = [0, -Math.PI / 2, 0];
break;
default:
position = [0, 0, -halfLength + wardrobeDepth / 2 + 0.05];
rotation = [0, 0, 0];
}
return { position, rotation };
}
+234
View File
@@ -0,0 +1,234 @@
import { useState, useRef, useCallback } from 'react';
import styles from './RoomUpload.module.css';
/**
* RoomUpload component - Drag & drop or click to upload room photos
* Sends to AI for analysis and returns room configuration
*/
export default function RoomUpload({ onAnalysisComplete, onClose }) {
const [dragOver, setDragOver] = useState(false);
const [preview, setPreview] = useState(null);
const [file, setFile] = useState(null);
const [analyzing, setAnalyzing] = useState(false);
const [error, setError] = useState(null);
const [analysis, setAnalysis] = useState(null);
const fileInputRef = useRef(null);
const handleDragOver = useCallback((e) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e) => {
e.preventDefault();
setDragOver(false);
}, []);
const handleDrop = useCallback((e) => {
e.preventDefault();
setDragOver(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile && droppedFile.type.startsWith('image/')) {
handleFile(droppedFile);
} else {
setError('Veuillez déposer une image (JPEG, PNG ou WebP)');
}
}, []);
const handleFile = (selectedFile) => {
setFile(selectedFile);
setError(null);
setAnalysis(null);
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setPreview(e.target.result);
};
reader.readAsDataURL(selectedFile);
};
const handleFileSelect = (e) => {
const selectedFile = e.target.files[0];
if (selectedFile) {
handleFile(selectedFile);
}
};
const handleAnalyze = async () => {
if (!file) return;
setAnalyzing(true);
setError(null);
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/room/analyze', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Erreur lors de l\'analyse');
}
setAnalysis(data);
} catch (err) {
setError(err.message);
} finally {
setAnalyzing(false);
}
};
const handleConfirm = () => {
if (analysis && onAnalysisComplete) {
onAnalysisComplete(analysis);
}
};
const handleReset = () => {
setFile(null);
setPreview(null);
setAnalysis(null);
setError(null);
};
return (
<div className={styles.overlay}>
<div className={styles.modal}>
<button className={styles.closeBtn} onClick={onClose} type="button">
&times;
</button>
<h2 className={styles.title}>Visualiser dans ma pièce</h2>
<p className={styles.subtitle}>
Téléchargez une photo de votre pièce et notre IA analysera l'espace pour y placer votre armoire.
</p>
{!preview ? (
<div
className={`${styles.dropzone} ${dragOver ? styles.dragOver : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<div className={styles.dropIcon}>📷</div>
<p className={styles.dropText}>
Glissez-déposez une photo ici<br />
<span>ou cliquez pour sélectionner</span>
</p>
<p className={styles.dropHint}>JPEG, PNG ou WebP - Max 10 MB</p>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleFileSelect}
className={styles.fileInput}
/>
</div>
) : (
<div className={styles.previewContainer}>
<img src={preview} alt="Aperçu de la pièce" className={styles.previewImage} />
{!analysis && !analyzing && (
<button type="button" className={styles.changeBtn} onClick={handleReset}>
Changer d'image
</button>
)}
</div>
)}
{error && (
<div className={styles.error}>
{error}
</div>
)}
{analyzing && (
<div className={styles.analyzing}>
<div className={styles.spinner}></div>
<p>Analyse de votre pièce en cours...</p>
<p className={styles.analyzingHint}>L'IA détecte les dimensions, fenêtres et portes</p>
</div>
)}
{analysis && (
<div className={styles.analysisResult}>
<h3>Analyse terminée</h3>
<div className={styles.resultGrid}>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Dimensions estimées</span>
<span className={styles.resultValue}>
{analysis.analysis.roomDimensions.widthM}m × {analysis.analysis.roomDimensions.lengthM}m × {analysis.analysis.roomDimensions.heightM}m
</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Type de pièce</span>
<span className={styles.resultValue}>
{analysis.analysis.roomType === 'bedroom' ? 'Chambre' :
analysis.analysis.roomType === 'living_room' ? 'Salon' :
analysis.analysis.roomType === 'hallway' ? 'Couloir' :
analysis.analysis.roomType === 'dressing' ? 'Dressing' : 'Autre'}
</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Fenêtres détectées</span>
<span className={styles.resultValue}>{analysis.analysis.windows?.length || 0}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Portes détectées</span>
<span className={styles.resultValue}>{analysis.analysis.doors?.length || 0}</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Mur suggéré pour l'armoire</span>
<span className={styles.resultValue}>
{analysis.analysis.suggestedWardrobeWall === 'back' ? 'Fond' :
analysis.analysis.suggestedWardrobeWall === 'left' ? 'Gauche' : 'Droite'}
</span>
</div>
<div className={styles.resultItem}>
<span className={styles.resultLabel}>Confiance</span>
<span className={styles.resultValue}>{analysis.analysis.confidence}%</span>
</div>
</div>
{analysis.analysis.suggestedWardrobePosition?.reason && (
<p className={styles.suggestion}>
<strong>Suggestion :</strong> {analysis.analysis.suggestedWardrobePosition.reason}
</p>
)}
</div>
)}
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
Annuler
</button>
{preview && !analysis && !analyzing && (
<button type="button" className={styles.analyzeBtn} onClick={handleAnalyze}>
Analyser la pièce
</button>
)}
{analysis && (
<button type="button" className={styles.confirmBtn} onClick={handleConfirm}>
Utiliser cette configuration
</button>
)}
</div>
</div>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: #fff;
border-radius: 12px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
padding: 2rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.closeBtn {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #666;
line-height: 1;
}
.closeBtn:hover {
color: #000;
}
.title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
}
.subtitle {
margin: 0 0 1.5rem;
color: #666;
font-size: 0.95rem;
line-height: 1.5;
}
/* Dropzone */
.dropzone {
border: 2px dashed #ccc;
border-radius: 12px;
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #fafafa;
}
.dropzone:hover,
.dropzone.dragOver {
border-color: #0066cc;
background: #f0f7ff;
}
.dropIcon {
font-size: 3rem;
margin-bottom: 1rem;
}
.dropText {
margin: 0 0 0.5rem;
font-size: 1rem;
color: #333;
}
.dropText span {
color: #0066cc;
text-decoration: underline;
}
.dropHint {
margin: 0;
font-size: 0.85rem;
color: #999;
}
.fileInput {
display: none;
}
/* Preview */
.previewContainer {
position: relative;
border-radius: 8px;
overflow: hidden;
}
.previewImage {
width: 100%;
max-height: 300px;
object-fit: contain;
background: #f0f0f0;
border-radius: 8px;
}
.changeBtn {
position: absolute;
bottom: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
cursor: pointer;
}
.changeBtn:hover {
background: rgba(0, 0, 0, 0.9);
}
/* Error */
.error {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: #fee2e2;
border: 1px solid #fca5a5;
border-radius: 8px;
color: #b91c1c;
font-size: 0.9rem;
}
/* Analyzing */
.analyzing {
margin-top: 1.5rem;
text-align: center;
padding: 2rem;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e0e0e0;
border-top-color: #0066cc;
border-radius: 50%;
margin: 0 auto 1rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.analyzing p {
margin: 0;
color: #333;
font-weight: 500;
}
.analyzingHint {
margin-top: 0.5rem !important;
font-size: 0.85rem;
color: #666 !important;
font-weight: 400 !important;
}
/* Analysis Result */
.analysisResult {
margin-top: 1.5rem;
padding: 1.25rem;
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: 8px;
}
.analysisResult h3 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: #166534;
}
.resultGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.resultItem {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.resultLabel {
font-size: 0.8rem;
color: #666;
}
.resultValue {
font-size: 0.95rem;
font-weight: 500;
color: #1a1a1a;
}
.suggestion {
margin: 1rem 0 0;
padding-top: 1rem;
border-top: 1px solid #86efac;
font-size: 0.9rem;
color: #333;
line-height: 1.5;
}
/* Actions */
.actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #eee;
}
.cancelBtn,
.analyzeBtn,
.confirmBtn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.cancelBtn {
background: #fff;
border: 1px solid #ddd;
color: #666;
}
.cancelBtn:hover {
background: #f5f5f5;
}
.analyzeBtn {
background: #0066cc;
border: none;
color: #fff;
font-weight: 500;
}
.analyzeBtn:hover {
background: #0052a3;
}
.confirmBtn {
background: #16a34a;
border: none;
color: #fff;
font-weight: 500;
}
.confirmBtn:hover {
background: #15803d;
}
+447
View File
@@ -0,0 +1,447 @@
/**
* WardrobeElement - Clickable 3D element for the wardrobe preview
* Each element can have its own color/finish and responds to click events
* Uses dominant color extracted from material swatch images for realistic preview
*/
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useThree } from '@react-three/fiber';
import * as THREE from 'three';
// Default colors for different element types
const DEFAULT_COLORS = {
plintheBas: '#8b7355',
fileur: '#8b7355',
bandeauHaut: '#8b7355',
champs: '#6b5344',
caisse: '#c4a574',
cotesDoublage: '#c4a574',
cotes: '#b09060',
dessus: '#c4a574',
fond: '#d4c4a4',
dos: '#d4c4a4',
portes: '#a08060',
panneauxInterieurs: '#d4c4a4',
separateurs: '#6b5344',
etageres: '#d4c4a4',
tiroirs: '#c4a574',
penderie: '#8b7355'
};
// Element labels for display
const ELEMENT_LABELS = {
plintheBas: 'Plinthe bas',
fileur: 'Fileur',
bandeauHaut: 'Bandeau haut',
champs: 'Champs',
caisse: 'Caisse',
cotesDoublage: 'Côtés de doublage',
cotes: 'Côtés',
dessus: 'Dessus',
fond: 'Fond',
dos: 'Dos',
portes: 'Portes',
panneauxInterieurs: 'Panneaux intérieurs',
separateurs: 'Séparateurs',
etageres: 'Étagères',
tiroirs: 'Tiroirs',
penderie: 'Tringle penderie'
};
/**
* Get color from config or use default
*/
function getElementColor(elementType, colors) {
if (colors && colors[elementType]) {
if (colors[elementType].hexColor) {
return colors[elementType].hexColor;
}
}
return DEFAULT_COLORS[elementType] || '#cccccc';
}
/**
* Get image URL for element texture (if available)
*/
function getElementImageUrl(elementType, colors) {
if (colors && colors[elementType] && colors[elementType].imageUrl) {
return colors[elementType].imageUrl;
}
return null;
}
/**
* Global cache: image URL → extracted dominant color (hex string)
* Avoids re-extracting colors for the same image URL
*/
const colorCache = new Map();
/**
* Extract the dominant color from the center of a swatch image.
* The Formex swatch images have black borders and watermarks,
* so we sample only the center 40% to get the actual material color.
* Returns a promise that resolves to a hex color string.
*/
function extractDominantColor(imageUrl) {
if (colorCache.has(imageUrl)) {
return colorCache.get(imageUrl);
}
const promise = new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Sample center 40% of the image (avoids border + watermark)
const cropFraction = 0.4;
const sx = Math.floor(img.width * (1 - cropFraction) / 2);
const sy = Math.floor(img.height * (1 - cropFraction) / 2);
const sw = Math.floor(img.width * cropFraction);
const sh = Math.floor(img.height * cropFraction);
// Draw cropped region to small canvas for fast sampling
canvas.width = 16;
canvas.height = 16;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, 16, 16);
const data = ctx.getImageData(0, 0, 16, 16).data;
let r = 0, g = 0, b = 0, count = 0;
for (let i = 0; i < data.length; i += 4) {
// Skip very dark pixels (might be border remnants)
if (data[i] < 15 && data[i+1] < 15 && data[i+2] < 15) continue;
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
if (count === 0) count = 1;
r = Math.round(r / count);
g = Math.round(g / count);
b = Math.round(b / count);
const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
resolve(hex);
} catch {
resolve(null);
}
};
img.onerror = () => resolve(null);
img.src = imageUrl;
});
colorCache.set(imageUrl, promise);
return promise;
}
/**
* Hook to extract the dominant color from a material swatch image URL.
* Returns a hex color string or null while loading.
*/
function useExtractedColor(imageUrl) {
const [extractedColor, setExtractedColor] = useState(null);
useEffect(() => {
if (!imageUrl) {
setExtractedColor(null);
return;
}
let cancelled = false;
extractDominantColor(imageUrl).then((hex) => {
if (!cancelled && hex) {
setExtractedColor(hex);
}
});
return () => { cancelled = true; };
}, [imageUrl]);
return extractedColor;
}
/**
* Global texture cache — stores cropped CanvasTextures (borders removed)
*/
const textureCache = new Map();
/**
* Load a material swatch image, crop out the borders/watermark,
* and create a Three.js CanvasTexture from the center region.
* Returns a cache entry { texture, error, loading }.
*/
function loadCroppedTexture(url) {
if (textureCache.has(url)) {
return textureCache.get(url);
}
const entry = { texture: null, error: false, loading: true };
textureCache.set(url, entry);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Crop center 60% to remove black border + FORMEX watermark
const cropFraction = 0.6;
const sx = Math.floor(img.width * (1 - cropFraction) / 2);
const sy = Math.floor(img.height * (1 - cropFraction) / 2);
const sw = Math.floor(img.width * cropFraction);
const sh = Math.floor(img.height * cropFraction);
canvas.width = sw;
canvas.height = sh;
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.needsUpdate = true;
entry.texture = tex;
entry.loading = false;
} catch {
entry.error = true;
entry.loading = false;
}
};
img.onerror = () => {
entry.error = true;
entry.loading = false;
};
img.src = url;
return entry;
}
/**
* Hook to load a cropped texture from a swatch image URL.
* Returns a cloned texture with proper repeat settings, or null.
*/
function useCroppedTexture(imageUrl, geometryArgs, geometryType) {
const [texture, setTexture] = useState(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
useEffect(() => {
if (!imageUrl) {
setTexture((prev) => { if (prev) prev.dispose(); return null; });
return;
}
const entry = loadCroppedTexture(imageUrl);
if (entry.texture) {
const tex = entry.texture.clone();
configureTextureRepeat(tex, geometryArgs, geometryType);
setTexture((prev) => { if (prev) prev.dispose(); return tex; });
return;
}
if (entry.error) {
setTexture((prev) => { if (prev) prev.dispose(); return null; });
return;
}
// Poll until loaded
const interval = setInterval(() => {
if (!mountedRef.current) { clearInterval(interval); return; }
if (entry.texture) {
const tex = entry.texture.clone();
configureTextureRepeat(tex, geometryArgs, geometryType);
setTexture((prev) => { if (prev) prev.dispose(); return tex; });
clearInterval(interval);
} else if (entry.error) {
setTexture((prev) => { if (prev) prev.dispose(); return null; });
clearInterval(interval);
}
}, 50);
return () => clearInterval(interval);
}, [imageUrl, geometryType, geometryArgs]);
// Update repeat when geometry changes (but same texture)
useEffect(() => {
if (texture) {
configureTextureRepeat(texture, geometryArgs, geometryType);
}
}, [geometryArgs, geometryType]);
return texture;
}
/**
* Configure texture repeat based on geometry dimensions.
* The cropped swatch represents approximately 0.3m x 0.3m of real material.
*/
function configureTextureRepeat(tex, geometryArgs, geometryType) {
if (!tex || !geometryArgs) return;
const TEXTURE_PATCH_SIZE = 0.3; // meters
let widthM, heightM;
switch (geometryType) {
case 'box':
widthM = geometryArgs[0] || 1;
heightM = geometryArgs[1] || 1;
break;
case 'plane':
widthM = geometryArgs[0] || 1;
heightM = geometryArgs[1] || 1;
break;
case 'cylinder':
widthM = 0.3;
heightM = 0.3;
break;
default:
widthM = 1;
heightM = 1;
}
tex.repeat.set(
Math.max(1, widthM / TEXTURE_PATCH_SIZE),
Math.max(1, heightM / TEXTURE_PATCH_SIZE)
);
tex.needsUpdate = true;
}
/**
* WardrobeElement - A single clickable 3D mesh with optional texture
*/
export default function WardrobeElement({
elementType,
geometryType = 'box',
geometryArgs = [1, 1, 1],
position = [0, 0, 0],
rotation = [0, 0, 0],
colors = {},
onElementClick,
castShadow = true,
receiveShadow = true,
transparent = false,
opacity = 1,
materialProps = {}
}) {
const [hovered, setHovered] = useState(false);
const { gl } = useThree();
const pointerDownPos = useRef(null);
const materialRef = useRef();
const color = useMemo(() =>
getElementColor(elementType, colors),
[elementType, colors]
);
const imageUrl = useMemo(() =>
getElementImageUrl(elementType, colors),
[elementType, colors]
);
// Extract dominant color from swatch image (immediate visual feedback)
const extractedColor = useExtractedColor(imageUrl);
// Load cropped texture (for materials with visible grain/pattern)
const texture = useCroppedTexture(imageUrl, geometryArgs, geometryType);
// Determine the display color: extracted > hex > default
const baseColor = extractedColor || color;
// Hover effect - slightly brighter color
const hoverColor = useMemo(() => {
if (!hovered) return baseColor;
const hex = baseColor.replace('#', '');
const r = Math.min(255, parseInt(hex.slice(0, 2), 16) + 30);
const g = Math.min(255, parseInt(hex.slice(2, 4), 16) + 30);
const b = Math.min(255, parseInt(hex.slice(4, 6), 16) + 30);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}, [baseColor, hovered]);
const handlePointerOver = useCallback((e) => {
e.stopPropagation();
setHovered(true);
gl.domElement.style.cursor = 'pointer';
}, [gl]);
const handlePointerOut = useCallback((e) => {
e.stopPropagation();
setHovered(false);
gl.domElement.style.cursor = 'auto';
}, [gl]);
// Track pointer position on down to distinguish clicks from drags
const handlePointerDown = useCallback((e) => {
e.stopPropagation();
pointerDownPos.current = { x: e.clientX, y: e.clientY };
}, []);
const handleClick = useCallback((e) => {
e.stopPropagation();
if (!pointerDownPos.current) return;
const dx = e.clientX - pointerDownPos.current.x;
const dy = e.clientY - pointerDownPos.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
pointerDownPos.current = null;
if (distance > 5) return; // Was a drag, not a click
if (onElementClick) {
onElementClick(elementType, ELEMENT_LABELS[elementType] || elementType);
}
}, [elementType, onElementClick]);
const geometry = useMemo(() => {
switch (geometryType) {
case 'plane':
return <planeGeometry args={geometryArgs} />;
case 'cylinder':
return <cylinderGeometry args={geometryArgs} />;
case 'box':
default:
return <boxGeometry args={geometryArgs} />;
}
}, [geometryType, geometryArgs]);
// Imperatively force material update when the texture map reference changes
// (R3F sometimes misses null↔Texture transitions without a shader recompile)
useEffect(() => {
const mat = materialRef.current;
if (!mat) return;
mat.map = texture || null;
mat.needsUpdate = true;
}, [texture]);
// Determine current color for the material
const displayColor = texture ? (hovered ? '#e8e8e8' : '#ffffff') : hoverColor;
return (
<mesh
position={position}
rotation={rotation}
onPointerDown={handlePointerDown}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onClick={handleClick}
castShadow={castShadow}
receiveShadow={receiveShadow}
>
{geometry}
<meshStandardMaterial
ref={materialRef}
key={imageUrl || 'flat'}
transparent={transparent}
opacity={opacity}
roughness={materialProps.roughness ?? 0.6}
metalness={materialProps.metalness ?? 0.05}
color={displayColor}
map={texture || null}
/>
</mesh>
);
}
// Export helper functions and constants
export { DEFAULT_COLORS, ELEMENT_LABELS, getElementColor };
+801
View File
@@ -0,0 +1,801 @@
import { useRef, Suspense, useMemo, useState, useCallback, forwardRef, useImperativeHandle } from 'react';
import { Canvas, useThree } from '@react-three/fiber';
import { OrbitControls, Html } from '@react-three/drei';
import * as THREE from 'three';
import Room3D, { calculateWardrobeTransform } from './Room3D';
import WardrobeElement from './WardrobeElement';
import styles from './WardrobePreview.module.css';
const SCALE = 1 / 1000;
const PANEL_THICKNESS = 0.019; // 19mm in meters
/* ===================================================================
ResizableSeparator - a draggable handle between two modules
=================================================================== */
function ResizableSeparator({
position,
height,
depth,
separatorIndex,
colors,
onElementClick,
isResizing,
onResizePointerDown,
}) {
const [hovered, setHovered] = useState(false);
const { gl } = useThree();
const meshRef = useRef();
const handlePointerOver = useCallback((e) => {
e.stopPropagation();
setHovered(true);
gl.domElement.style.cursor = 'col-resize';
}, [gl]);
const handlePointerOut = useCallback((e) => {
e.stopPropagation();
if (!isResizing) {
setHovered(false);
gl.domElement.style.cursor = 'auto';
}
}, [gl, isResizing]);
const handlePointerDown = useCallback((e) => {
e.stopPropagation();
if (onResizePointerDown) {
onResizePointerDown(separatorIndex, e);
}
}, [separatorIndex, onResizePointerDown]);
const baseColor = hovered || isResizing ? '#4f46e5' : '#d4c4a4';
return (
<group position={position}>
{/* Visual panel (thin) */}
<WardrobeElement
elementType="panneauxInterieurs"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, height, depth]}
position={[0, 0, PANEL_THICKNESS / 2]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Invisible wider hit area for grabbing */}
<mesh
ref={meshRef}
onPointerOver={handlePointerOver}
onPointerOut={handlePointerOut}
onPointerDown={handlePointerDown}
>
<boxGeometry args={[PANEL_THICKNESS * 6, height, depth]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
{/* Highlight overlay when hovered/resizing */}
{(hovered || isResizing) && (
<mesh>
<boxGeometry args={[PANEL_THICKNESS * 1.5, height * 0.95, depth * 0.95]} />
<meshBasicMaterial color={baseColor} transparent opacity={0.35} />
</mesh>
)}
</group>
);
}
/* ===================================================================
WidthLabel - floating HTML label showing module width in mm
=================================================================== */
function WidthLabel({ position, widthMm }) {
return (
<Html position={position} center style={{ pointerEvents: 'none' }}>
<div style={{
background: 'rgba(79, 70, 229, 0.9)',
color: '#fff',
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
whiteSpace: 'nowrap',
userSelect: 'none',
}}>
{Math.round(widthMm)}mm
</div>
</Html>
);
}
/* ===================================================================
DrawerBox - renders drawer front faces stacked vertically
=================================================================== */
function DrawerBox({ moduleCenter, moduleWidth, bodyH, plinthH, drawersCount, colors, onElementClick }) {
const drawerHeight = 0.15 * SCALE * 1000; // ~150mm in scene units
const gap = 0.008; // gap between drawers
const totalDrawerZone = Math.min(bodyH * 0.6, drawersCount * (drawerHeight + gap));
const actualDrawerH = (totalDrawerZone - gap * (drawersCount - 1)) / drawersCount;
const startY = -bodyH / 2 + PANEL_THICKNESS;
return (
<>
{Array.from({ length: drawersCount }).map((_, di) => {
const y = startY + actualDrawerH / 2 + di * (actualDrawerH + gap);
return (
<WardrobeElement
key={`drawer-${di}`}
elementType="tiroirs"
geometryType="box"
geometryArgs={[moduleWidth * 0.92, actualDrawerH, PANEL_THICKNESS]}
position={[moduleCenter, plinthH / 2 + y, moduleWidth > 0 ? 0.28 : 0]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.5, metalness: 0.05 }}
/>
);
})}
</>
);
}
/* ===================================================================
WardrobeMesh - Full wardrobe model with drag/resize/drawers
=================================================================== */
function WardrobeMesh({
config,
colors = {},
onElementClick,
onModuleClick,
selectedModuleIndex,
modules = [],
showDoors = true,
onResizeModules,
}) {
const { gl, camera, raycaster } = useThree();
const useModules = modules && modules.length > 0;
// --- Dimensions ---
const totalWidth = useModules
? modules.reduce((s, m) => s + (m.widthMm || 0), 0)
: config.widthMm || 2400;
const width = totalWidth * SCALE;
const height = (config.heightMm || 2400) * SCALE;
const depth = (config.depthMm || 600) * SCALE;
const plinthH = (config.plinthHeightMm || 100) * SCALE;
const bodyH = height - plinthH;
const isNiche = config.installationType === 'niche';
const isSliding = config.doorType === 'sliding';
// --- Module positions ---
const modulePositions = useMemo(() => {
const items = useModules ? modules : [];
const result = [];
let offset = 0;
items.forEach((item) => {
const w = (item.widthMm || 0) * SCALE;
result.push({ x0: offset, width: w, center: -width / 2 + offset + w / 2 });
offset += w;
});
return result;
}, [useModules, modules, width]);
// --- Separator positions (between modules) ---
const separatorPositions = useMemo(() => {
const items = useModules ? modules : [];
return items.slice(0, -1).map((item, i) => {
const x = modulePositions[i].x0 + modulePositions[i].width;
return { x: -width / 2 + x, index: i };
});
}, [modulePositions, width, useModules, modules]);
// --- Hover state for width labels ---
const [hoveredModule, setHoveredModule] = useState(null);
const [resizeState, setResizeState] = useState(null); // { separatorIndex, startX }
const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));
const intersectPoint = useRef(new THREE.Vector3());
// --- Helpers to project pointer to world X ---
const getWorldX = useCallback((e) => {
const rect = gl.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1
);
raycaster.setFromCamera(mouse, camera);
planeRef.current.set(new THREE.Vector3(0, 1, 0), -height / 2);
if (raycaster.ray.intersectPlane(planeRef.current, intersectPoint.current)) {
return intersectPoint.current.x;
}
return null;
}, [gl, camera, raycaster, height]);
// --- Resize pointer down ---
const handleResizePointerDown = useCallback((separatorIndex, e) => {
e.stopPropagation();
const worldX = getWorldX(e);
if (worldX === null) return;
setResizeState({ separatorIndex, startX: worldX });
}, [getWorldX]);
// Attach window-level pointer events for resize
const onPointerMoveRef = useRef(null);
const onPointerUpRef = useRef(null);
useMemo(() => {
const handleMove = (e) => {
if (resizeState) {
const worldX = getWorldX(e);
if (worldX !== null) {
const deltaX = worldX - resizeState.startX;
const deltaXmm = deltaX / SCALE;
if (onResizeModules && Math.abs(deltaXmm) > 1) {
onResizeModules(resizeState.separatorIndex, deltaXmm);
setResizeState(prev => prev ? { ...prev, startX: worldX } : null);
}
}
}
};
const handleUp = () => {
if (resizeState) {
setResizeState(null);
gl.domElement.style.cursor = 'auto';
}
};
onPointerMoveRef.current = handleMove;
onPointerUpRef.current = handleUp;
}, [resizeState, getWorldX, onResizeModules, gl]);
// Attach/detach event listeners
useMemo(() => {
const el = gl.domElement;
const move = (e) => onPointerMoveRef.current?.(e);
const up = (e) => onPointerUpRef.current?.(e);
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
return () => {
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
};
}, [gl]);
// Disable OrbitControls during resize
const interactionActive = resizeState !== null;
return (
<group position={[0, height / 2, 0]}>
{/* Plinthe bas */}
<WardrobeElement
elementType="plintheBas"
geometryType="box"
geometryArgs={[width, plinthH, depth]}
position={[0, -height / 2 + plinthH / 2, 0]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.7, metalness: 0.1 }}
/>
{/* Bandeau haut */}
<WardrobeElement
elementType="bandeauHaut"
geometryType="box"
geometryArgs={[width, PANEL_THICKNESS, depth]}
position={[0, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS / 2, 0]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Dessus (top panel) */}
<WardrobeElement
elementType="dessus"
geometryType="box"
geometryArgs={[width - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
position={[0, bodyH / 2 + plinthH / 2 - PANEL_THICKNESS * 1.5, PANEL_THICKNESS / 2]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Fond (bottom panel) */}
<WardrobeElement
elementType="fond"
geometryType="box"
geometryArgs={[width - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS]}
position={[0, -height / 2 + plinthH + PANEL_THICKNESS / 2, PANEL_THICKNESS / 2]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Côté gauche */}
<WardrobeElement
elementType="cotes"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, bodyH, depth]}
position={[-width / 2 + PANEL_THICKNESS / 2, plinthH / 2, 0]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Côté droit */}
<WardrobeElement
elementType="cotes"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, bodyH, depth]}
position={[width / 2 - PANEL_THICKNESS / 2, plinthH / 2, 0]}
colors={colors}
onElementClick={onElementClick}
/>
{/* Dos (back panel) */}
<WardrobeElement
elementType="dos"
geometryType="box"
geometryArgs={[width - PANEL_THICKNESS * 2, bodyH - PANEL_THICKNESS * 2, PANEL_THICKNESS / 2]}
position={[0, plinthH / 2, -depth / 2 + PANEL_THICKNESS / 4]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.8 }}
/>
{/* Fileurs (si encastré) */}
{isNiche && (
<>
<WardrobeElement
elementType="fileur"
geometryType="box"
geometryArgs={[PANEL_THICKNESS / 2, bodyH, depth]}
position={[-width / 2 - PANEL_THICKNESS / 4, plinthH / 2, 0]}
colors={colors}
onElementClick={onElementClick}
/>
<WardrobeElement
elementType="fileur"
geometryType="box"
geometryArgs={[PANEL_THICKNESS / 2, bodyH, depth]}
position={[width / 2 + PANEL_THICKNESS / 4, plinthH / 2, 0]}
colors={colors}
onElementClick={onElementClick}
/>
</>
)}
{/* Separators between modules (resizable) */}
{useModules && onResizeModules
? separatorPositions.map((pos, i) => (
<ResizableSeparator
key={`sep-resize-${i}`}
position={[pos.x, plinthH / 2, 0]}
height={bodyH - PANEL_THICKNESS * 2}
depth={depth - PANEL_THICKNESS}
separatorIndex={pos.index}
colors={colors}
onElementClick={onElementClick}
isResizing={resizeState?.separatorIndex === pos.index}
onResizePointerDown={handleResizePointerDown}
/>
))
: separatorPositions.map((pos, i) => (
<WardrobeElement
key={`sep-${i}`}
elementType="panneauxInterieurs"
geometryType="box"
geometryArgs={[PANEL_THICKNESS, bodyH - PANEL_THICKNESS * 2, depth - PANEL_THICKNESS]}
position={[pos.x, plinthH / 2, PANEL_THICKNESS / 2]}
colors={colors}
onElementClick={onElementClick}
/>
))
}
{/* Width labels -- show during resize or hover */}
{modules.length > 0 && (resizeState !== null || hoveredModule !== null) && (
<>
{resizeState !== null && (
<>
{modules[resizeState.separatorIndex] && (
<WidthLabel
position={[modulePositions[resizeState.separatorIndex]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
widthMm={modules[resizeState.separatorIndex].widthMm}
/>
)}
{modules[resizeState.separatorIndex + 1] && (
<WidthLabel
position={[modulePositions[resizeState.separatorIndex + 1]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
widthMm={modules[resizeState.separatorIndex + 1].widthMm}
/>
)}
</>
)}
{resizeState === null && hoveredModule !== null && modules[hoveredModule] && (
<WidthLabel
position={[modulePositions[hoveredModule]?.center || 0, bodyH / 2 + plinthH / 2 + 0.06, depth / 2]}
widthMm={modules[hoveredModule].widthMm}
/>
)}
</>
)}
{/* Module contents: doors, shelves, rods, drawers + click-to-select */}
{useModules && modules.map((module, moduleIndex) => {
const pos = modulePositions[moduleIndex];
if (!pos) return null;
const moduleCenter = pos.center;
const moduleWidth = pos.width;
const shelfCount = module.shelves || 0;
const drawersCount = module.drawers || 0;
const isSelected = selectedModuleIndex === moduleIndex;
return (
<group key={`mod-${module.id}`}>
{/* Invisible click/hover area for module selection */}
<mesh
position={[moduleCenter, plinthH / 2, 0]}
onPointerOver={() => setHoveredModule(moduleIndex)}
onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)}
onClick={(e) => {
e.stopPropagation();
if (onModuleClick) onModuleClick(moduleIndex);
}}
>
<boxGeometry args={[moduleWidth - PANEL_THICKNESS * 2, bodyH - PANEL_THICKNESS * 2, depth * 0.5]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
{/* Selection highlight */}
{isSelected && (
<mesh position={[moduleCenter, plinthH / 2, depth / 2 + 0.005]}>
<planeGeometry args={[moduleWidth * 0.98, bodyH * 0.98]} />
<meshBasicMaterial color="#4f46e5" transparent opacity={0.08} />
</mesh>
)}
{/* Portes battantes per module */}
{showDoors && !isSliding && (
<WardrobeElement
elementType="portes"
geometryType="plane"
geometryArgs={[moduleWidth * 0.96, bodyH * 0.96]}
position={[moduleCenter, plinthH / 2, depth / 2 + 0.002]}
colors={colors}
onElementClick={onElementClick}
transparent={true}
opacity={0.9}
materialProps={{ roughness: 0.5 }}
/>
)}
{/* Shelves */}
{Array.from({ length: shelfCount }).map((_, shelfIndex) => {
const shelfY = -height / 2 + plinthH + PANEL_THICKNESS +
((bodyH - PANEL_THICKNESS * 2) / (shelfCount + 1)) * (shelfIndex + 1);
return (
<WardrobeElement
key={`shelf-${moduleIndex}-${shelfIndex}`}
elementType="panneauxInterieurs"
geometryType="box"
geometryArgs={[moduleWidth - PANEL_THICKNESS * 2, PANEL_THICKNESS, depth - PANEL_THICKNESS * 2]}
position={[moduleCenter, shelfY, 0]}
colors={colors}
onElementClick={onElementClick}
/>
);
})}
{/* Drawers */}
{drawersCount > 0 && (
<DrawerBox
moduleCenter={moduleCenter}
moduleWidth={moduleWidth}
bodyH={bodyH}
plinthH={plinthH}
drawersCount={drawersCount}
colors={colors}
onElementClick={onElementClick}
/>
)}
{/* Rod (penderie) */}
{module.hasRod && (
<WardrobeElement
elementType="penderie"
geometryType="cylinder"
geometryArgs={[0.012, 0.012, moduleWidth - 0.06, 16]}
position={[moduleCenter, height / 2 - plinthH / 2 - 0.15, 0]}
rotation={[0, 0, Math.PI / 2]}
colors={colors}
onElementClick={onElementClick}
materialProps={{ roughness: 0.3, metalness: 0.8 }}
/>
)}
</group>
);
})}
{/* Portes coulissantes (sliding) */}
{showDoors && isSliding && (
<WardrobeElement
elementType="portes"
geometryType="plane"
geometryArgs={[width * 0.98, bodyH * 0.98]}
position={[0, plinthH / 2, depth / 2 + 0.002]}
colors={colors}
onElementClick={onElementClick}
transparent={true}
opacity={0.85}
materialProps={{ roughness: 0.5 }}
/>
)}
{/* Module content is rendered above in the module loop */}
{/* Non-module door rendering is also above */}
{/* Pass interaction state to parent for OrbitControls toggling */}
<InteractionBlocker active={interactionActive} />
</group>
);
}
/* ===================================================================
InteractionBlocker - disables OrbitControls during drag/resize
Uses useThree to access the controls
=================================================================== */
function InteractionBlocker({ active }) {
const { controls } = useThree();
useMemo(() => {
if (controls) {
controls.enabled = !active;
}
}, [active, controls]);
return null;
}
/* ===================================================================
CameraController - exposes zoom/reset via ref
=================================================================== */
const CameraController = forwardRef(({ roomConfig, config }, ref) => {
const { camera, gl } = useThree();
const controlsRef = useRef();
const minDistance = roomConfig ? 1 : 0.5;
const maxDistance = roomConfig ? 15 : 8;
const cameraTarget = useMemo(() => {
if (roomConfig) {
return [0, roomConfig.height / 2, 0];
}
return [0, (config.heightMm || 2400) * SCALE * 0.5, 0];
}, [roomConfig, config]);
useImperativeHandle(ref, () => ({
zoomIn: () => {
if (controlsRef.current) {
const currentDistance = camera.position.distanceTo(controlsRef.current.target);
const newDistance = Math.max(minDistance, currentDistance * 0.8);
const direction = camera.position.clone().sub(controlsRef.current.target).normalize();
camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance));
controlsRef.current.update();
}
},
zoomOut: () => {
if (controlsRef.current) {
const currentDistance = camera.position.distanceTo(controlsRef.current.target);
const newDistance = Math.min(maxDistance, currentDistance * 1.25);
const direction = camera.position.clone().sub(controlsRef.current.target).normalize();
camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance));
controlsRef.current.update();
}
},
resetView: () => {
if (controlsRef.current) {
const defaultDist = roomConfig ? Math.max(roomConfig.width, roomConfig.length) * 1.2 : 3;
camera.position.set(defaultDist * 0.7, defaultDist * 0.5, defaultDist * 0.7);
controlsRef.current.update();
}
}
}), [camera, minDistance, maxDistance, roomConfig]);
return (
<OrbitControls
ref={controlsRef}
enablePan={true}
enableZoom={true}
minDistance={minDistance}
maxDistance={maxDistance}
target={cameraTarget}
makeDefault
/>
);
});
/* ===================================================================
Scene - assembles everything
=================================================================== */
function Scene({ config, roomConfig, controllerRef, colors, modules, onElementClick, onModuleClick, selectedModuleIndex, showDoors, onResizeModules }) {
const wardrobeTransform = useMemo(() => {
if (roomConfig) {
return calculateWardrobeTransform(roomConfig, config);
}
return { position: [0, 0, 0], rotation: [0, 0, 0] };
}, [roomConfig, config]);
return (
<>
<ambientLight intensity={roomConfig ? 0.5 : 0.6} />
<directionalLight
position={[4, 6, 5]}
intensity={roomConfig ? 1.0 : 1.2}
castShadow
shadow-mapSize={[1024, 1024]}
shadow-camera-far={20}
shadow-camera-left={-5}
shadow-camera-right={5}
shadow-camera-top={5}
shadow-camera-bottom={-5}
/>
<directionalLight position={[-2, 4, 3]} intensity={0.4} />
{roomConfig && (
<Room3D roomConfig={roomConfig} wardrobeConfig={config} />
)}
<group
position={wardrobeTransform.position}
rotation={wardrobeTransform.rotation}
>
<WardrobeMesh
config={config}
colors={colors}
modules={modules}
onElementClick={onElementClick}
onModuleClick={onModuleClick}
selectedModuleIndex={selectedModuleIndex}
showDoors={showDoors}
onResizeModules={onResizeModules}
/>
</group>
<CameraController ref={controllerRef} roomConfig={roomConfig} config={config} />
</>
);
}
/* ===================================================================
WardrobePreview - main exported component
=================================================================== */
export default function WardrobePreview({
config,
roomConfig,
colors = {},
modules = [],
onElementClick,
onModuleClick,
onResizeModules,
selectedModuleIndex,
}) {
const containerRef = useRef(null);
const controllerRef = useRef(null);
const [selectedElement, setSelectedElement] = useState(null);
const [showDoors, setShowDoors] = useState(true);
const cameraPosition = useMemo(() => {
if (roomConfig) {
const roomSize = Math.max(roomConfig.width, roomConfig.length);
return [roomSize * 0.8, roomSize * 0.5, roomSize * 0.8];
}
return [2.5, 1.5, 2.5];
}, [roomConfig]);
const handleZoomIn = useCallback(() => {
controllerRef.current?.zoomIn();
}, []);
const handleZoomOut = useCallback(() => {
controllerRef.current?.zoomOut();
}, []);
const handleResetView = useCallback(() => {
controllerRef.current?.resetView();
}, []);
const handleToggleDoors = useCallback(() => {
setShowDoors(prev => !prev);
}, []);
const handleElementClick = useCallback((elementType, label) => {
setSelectedElement({ type: elementType, label });
if (onElementClick) {
onElementClick(elementType, label);
}
}, [onElementClick]);
// All element types that can be color-picked (including back panel)
const PICKABLE_ELEMENTS = useMemo(() => [
{ type: 'portes', label: 'Portes' },
{ type: 'cotes', label: 'Côtés' },
{ type: 'dessus', label: 'Dessus' },
{ type: 'fond', label: 'Fond (bas)' },
{ type: 'dos', label: 'Dos (arrière)' },
{ type: 'plintheBas', label: 'Plinthe' },
{ type: 'bandeauHaut', label: 'Bandeau haut' },
{ type: 'panneauxInterieurs', label: 'Panneaux int.' },
{ type: 'separateurs', label: 'Séparateurs' },
{ type: 'etageres', label: 'Étagères' },
{ type: 'tiroirs', label: 'Tiroirs' },
{ type: 'penderie', label: 'Tringle' },
], []);
const handleElementSelect = useCallback((e) => {
const val = e.target.value;
if (!val) return;
const item = PICKABLE_ELEMENTS.find(p => p.type === val);
if (item) {
handleElementClick(item.type, item.label);
}
// Reset select to placeholder
e.target.value = '';
}, [PICKABLE_ELEMENTS, handleElementClick]);
return (
<div ref={containerRef} className={styles.container} style={{ background: roomConfig ? '#87CEEB' : '#e8e4df' }}>
{selectedElement && (
<div className={styles.selectedElement}>
Élément sélectionné: <strong>{selectedElement.label}</strong>
</div>
)}
{/* Zoom Controls */}
<div className={styles.controls}>
<button type="button" className={styles.controlBtn} onClick={handleZoomIn} title="Zoom avant">+</button>
<button type="button" className={styles.controlBtn} onClick={handleZoomOut} title="Zoom arrière">&minus;</button>
<button type="button" className={styles.controlBtn} onClick={handleResetView} title="Réinitialiser la vue">&loz;</button>
<div className={styles.controlDivider} />
<button
type="button"
className={`${styles.controlBtn} ${!showDoors ? styles.controlBtnActive : ''}`}
onClick={handleToggleDoors}
title={showDoors ? 'Masquer les portes' : 'Afficher les portes'}
>
{showDoors ? '\u{1F6AA}' : '\u{1F441}\uFE0F'}
</button>
<div className={styles.controlDivider} />
<select
className={styles.elementSelect}
onChange={handleElementSelect}
defaultValue=""
title="Choisir un élément pour changer sa couleur"
>
<option value="" disabled>🎨 Couleur…</option>
{PICKABLE_ELEMENTS.map(el => (
<option key={el.type} value={el.type}>{el.label}</option>
))}
</select>
</div>
<Suspense fallback={<div className={styles.loading}>Chargement 3D…</div>}>
<Canvas
shadows
camera={{ position: cameraPosition, fov: roomConfig ? 60 : 45 }}
gl={{ antialias: true, alpha: false }}
style={{ width: '100%', height: '100%', display: 'block' }}
>
<Scene
config={config}
roomConfig={roomConfig}
controllerRef={controllerRef}
colors={colors}
modules={modules}
onElementClick={handleElementClick}
onModuleClick={onModuleClick}
selectedModuleIndex={selectedModuleIndex}
showDoors={showDoors}
onResizeModules={onResizeModules}
/>
</Canvas>
</Suspense>
{/* Hint */}
<div className={styles.hint}>
Cliquer un module = configurer {'\u2022'} Cliquer un {'\u00e9'}l{'\u00e9'}ment = couleur {'\u2022'} Tirer s{'\u00e9'}parateurs = redimensionner
</div>
</div>
);
}
@@ -0,0 +1,123 @@
.container {
position: relative;
width: 100%;
height: 100%;
min-height: 280px;
border-radius: 8px;
overflow: hidden;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
display: flex;
flex-direction: column;
gap: 6px;
z-index: 10;
}
.controlBtn {
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.9);
color: #333;
font-size: 1.25rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.2s;
}
.controlBtn:hover {
background: #fff;
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.controlBtn:active {
transform: scale(0.95);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 0.875rem;
}
.hint {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
padding: 4px 12px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 0.75rem;
border-radius: 12px;
white-space: nowrap;
pointer-events: none;
opacity: 0.7;
}
.selectedElement {
position: absolute;
top: 10px;
left: 10px;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.95);
color: #333;
font-size: 0.85rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.selectedElement strong {
color: var(--action);
}
.controlDivider {
width: 24px;
height: 1px;
background: rgba(0, 0, 0, 0.15);
margin: 4px 6px;
}
.controlBtnActive {
background: var(--action);
color: #fff;
}
.controlBtnActive:hover {
background: var(--action-hover);
}
.elementSelect {
font-size: 0.75rem;
padding: 4px 6px;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 6px;
background: rgba(255, 255, 255, 0.95);
color: #333;
cursor: pointer;
max-width: 110px;
outline: none;
}
.elementSelect:hover {
border-color: var(--action);
}
.elementSelect:focus {
border-color: var(--action);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
}
+38
View File
@@ -0,0 +1,38 @@
import { createContext, useContext, useState, useEffect } from 'react';
const STORAGE_KEY = 'admin';
const ADMIN_PASSWORD = 'ketimapex';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [isAdmin, setIsAdmin] = useState(() => typeof window !== 'undefined' && sessionStorage.getItem(STORAGE_KEY) === 'true');
useEffect(() => {
if (typeof window === 'undefined') return;
if (isAdmin) sessionStorage.setItem(STORAGE_KEY, 'true');
else sessionStorage.removeItem(STORAGE_KEY);
}, [isAdmin]);
const login = (password) => {
if (password === ADMIN_PASSWORD) {
setIsAdmin(true);
return true;
}
return false;
};
const logout = () => setIsAdmin(false);
return (
<AuthContext.Provider value={{ isAdmin, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+51
View File
@@ -0,0 +1,51 @@
:root {
--bg: #fafaf9;
--bg-card: #ffffff;
--bg-subtle: #f9fafb;
--text: #374151;
--text-muted: #6b7280;
--accent: #78716c;
--wood: #a8a29e;
--border: #e7e5e4;
--border-light: #e5e7eb;
--primary: #1c1917;
--action: #4f46e5;
--action-hover: #4338ca;
--success: #059669;
--warning: #f59e0b;
--danger: #dc2626;
--font: 'DM Sans', system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font);
font-size: 16px;
line-height: 1.5;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
a {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
font-family: inherit;
cursor: pointer;
}
input, select {
font-family: inherit;
font-size: 1rem;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+240
View File
@@ -0,0 +1,240 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { fetchOrders, fetchSyncLogs, getFormexSyncStreamUrl } from '../api/client';
import styles from './Admin.module.css';
export default function Admin() {
const { isAdmin, logout } = useAuth();
const navigate = useNavigate();
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Formex sync state
const [syncLogs, setSyncLogs] = useState([]);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
// SSE live progress state
const [liveMessages, setLiveMessages] = useState([]);
const [progress, setProgress] = useState({ current: 0, total: 0, collection: '' });
const logsEndRef = useRef(null);
useEffect(() => {
if (!isAdmin) {
navigate('/login?next=/admin', { replace: true });
return;
}
// Fetch orders and sync logs in parallel
Promise.all([fetchOrders(), fetchSyncLogs(10)])
.then(([ordersData, logsData]) => {
setOrders(ordersData);
setSyncLogs(logsData);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [isAdmin, navigate]);
// Auto-scroll to bottom of live logs
useEffect(() => {
if (logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [liveMessages]);
const handleLogout = () => {
logout();
navigate('/', { replace: true });
};
const handleFormexSync = () => {
setSyncing(true);
setSyncResult(null);
setLiveMessages([]);
setProgress({ current: 0, total: 0, collection: '' });
const eventSource = new EventSource(getFormexSyncStreamUrl());
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'log') {
setLiveMessages(prev => [...prev, data.message]);
} else if (data.type === 'progress') {
setProgress({
current: data.current,
total: data.total,
collection: data.collection || ''
});
} else if (data.type === 'complete') {
setSyncResult(data.result);
setSyncing(false);
eventSource.close();
// Refresh sync logs
fetchSyncLogs(10).then(setSyncLogs).catch(() => {});
} else if (data.type === 'error') {
setSyncResult({ success: false, error: data.error });
setSyncing(false);
eventSource.close();
}
} catch (e) {
console.error('SSE parse error:', e);
}
};
eventSource.onerror = () => {
if (syncing) {
setSyncResult({ success: false, error: 'Connexion perdue' });
setSyncing(false);
}
eventSource.close();
};
};
const progressPercent = progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0;
if (!isAdmin) return null;
return (
<div className={styles.page}>
<div className={styles.header}>
<h1>Administration</h1>
<button type="button" onClick={handleLogout} className={styles.logout}>
Déconnexion
</button>
</div>
{loading && <p className={styles.muted}>Chargement…</p>}
{error && <p className={styles.error}>{error}</p>}
{/* Formex Sync Section */}
{!loading && !error && (
<section className={styles.section}>
<h2>Synchronisation Formex</h2>
<div className={styles.syncActions}>
<button
type="button"
onClick={handleFormexSync}
disabled={syncing}
className={styles.syncBtn}
>
{syncing ? 'Synchronisation en cours…' : 'Synchroniser Formex'}
</button>
{syncResult && !syncing && (
<span className={syncResult.success ? styles.success : styles.error}>
{syncResult.success
? `${syncResult.itemsCount} produits synchronisés (${syncResult.duration}s)`
: `Erreur: ${syncResult.error}`}
</span>
)}
</div>
{/* Progress bar and live logs during sync */}
{syncing && (
<div className={styles.liveSync}>
<div className={styles.progressWrap}>
<div className={styles.progressInfo}>
<span>{progress.collection || 'Initialisation…'}</span>
<span>{progressPercent}%</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className={styles.progressStats}>
{progress.current} / {progress.total} collections
</div>
</div>
<div className={styles.liveLogsPanel}>
<div className={styles.liveLogsContent}>
{liveMessages.map((msg, idx) => (
<div key={idx} className={styles.logLine}>{msg}</div>
))}
<div ref={logsEndRef} />
</div>
</div>
</div>
)}
{syncLogs.length > 0 && !syncing && (
<div className={styles.logsWrap}>
<h3>Historique des synchronisations</h3>
<table className={styles.logsTable}>
<thead>
<tr>
<th>Date</th>
<th>Source</th>
<th>Statut</th>
<th>Produits</th>
<th>Erreur</th>
</tr>
</thead>
<tbody>
{syncLogs.map((log) => (
<tr key={log.id}>
<td>{log.created_at ? new Date(log.created_at).toLocaleString('fr-CH') : '—'}</td>
<td>{log.source}</td>
<td className={log.status === 'success' ? styles.success : styles.error}>
{log.status === 'success' ? 'Succès' : 'Erreur'}
</td>
<td>{log.items_count ?? '—'}</td>
<td>{log.error || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{/* Orders Section */}
{!loading && !error && (
<section className={styles.section}>
<h2>Commandes</h2>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>ID</th>
<th>Date</th>
<th>Total CHF</th>
<th>Nom</th>
<th>Email</th>
<th>Téléphone</th>
<th>Remarques</th>
</tr>
</thead>
<tbody>
{orders.length === 0 ? (
<tr>
<td colSpan={7} className={styles.empty}>
Aucune commande.
</td>
</tr>
) : (
orders.map((o) => (
<tr key={o.id}>
<td>{o.id}</td>
<td>{o.created_at ? new Date(o.created_at).toLocaleString('fr-CH') : '—'}</td>
<td>{o.total_chf != null ? Number(o.total_chf).toLocaleString('fr-CH') : '—'}</td>
<td>{o.name || '—'}</td>
<td>{o.email || '—'}</td>
<td>{o.phone || '—'}</td>
<td>{o.notes || '—'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
.page {
padding: 1.5rem 0 4rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--primary);
}
.logout {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
font-size: 0.9375rem;
cursor: pointer;
}
.logout:hover {
border-color: var(--accent);
color: var(--primary);
}
.muted {
color: var(--text-muted);
font-size: 0.9375rem;
margin: 0;
}
.error {
color: #b91c1c;
font-size: 0.9375rem;
margin: 0 0 1rem;
}
.tableWrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
background: var(--bg);
font-weight: 600;
color: var(--primary);
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table tbody tr:hover {
background: var(--bg);
}
.empty {
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
/* Section styling */
.section {
margin-bottom: 2.5rem;
}
.section h2 {
font-size: 1.125rem;
font-weight: 600;
color: var(--primary);
margin: 0 0 1rem;
}
.section h3 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-muted);
margin: 1.5rem 0 0.75rem;
}
/* Sync actions */
.syncActions {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.syncBtn {
padding: 0.625rem 1.25rem;
background: var(--accent);
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.syncBtn:hover:not(:disabled) {
background: var(--primary);
}
.syncBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.success {
color: #16a34a;
}
/* Sync logs table */
.logsWrap {
margin-top: 1rem;
}
.logsTable {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.logsTable th,
.logsTable td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.logsTable th {
background: var(--bg);
font-weight: 600;
color: var(--text-muted);
}
.logsTable tbody tr:last-child td {
border-bottom: none;
}
/* Live sync progress */
.liveSync {
margin-top: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.progressWrap {
padding: 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.progressInfo {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.progressInfo span:first-child {
font-weight: 500;
color: var(--primary);
}
.progressInfo span:last-child {
color: var(--text-muted);
}
.progressBar {
width: 100%;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.progressFill {
height: 100%;
background: var(--accent);
border-radius: 4px;
transition: width 0.3s ease;
}
.progressStats {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-muted);
text-align: right;
}
.liveLogsPanel {
max-height: 300px;
overflow-y: auto;
background: #1e1e1e;
}
.liveLogsContent {
padding: 0.75rem 1rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.75rem;
line-height: 1.5;
}
.logLine {
color: #d4d4d4;
white-space: pre-wrap;
word-break: break-word;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
import { Link } from 'react-router-dom';
import styles from './Home.module.css';
export default function Home() {
return (
<div className={styles.home}>
<section className={styles.hero}>
<h1>Armoire sur mesure. Prix clair. Kit prêt à monter.</h1>
<p className={styles.subtitle}>
Conçue par un menuisier suisse. Configurée en ligne, sans devis ni surprise.
</p>
<Link to="/configurateur" className={styles.cta}>Configurer mon armoire</Link>
</section>
<section className={styles.section}>
<h2>Pourquoi SIGNATURE</h2>
<ul className={styles.features}>
<li><strong>Blocs modulaires</strong> — Penderie, rayonnage, mixtes ; assemblez selon vos besoins.</li>
<li><strong>Prix transparent</strong> — Tarif affiché en direct, sans surprise.</li>
<li><strong>Services en option</strong> — Prise de mesure, montage, sur demande.</li>
</ul>
</section>
<section className={styles.section}>
<h2>Comment ça marche</h2>
<ol className={styles.steps}>
<li>Configurez en ligne (dimensions, blocs, portes, finitions).</li>
<li>Voyez le prix en CHF en temps réel.</li>
<li>Passez commande ; livraison en kit prêt à monter.</li>
<li>Option : prise de mesure et montage par un pro.</li>
</ol>
</section>
<section className={styles.section}>
<h2>Le système modulaire</h2>
<p className={styles.muted}>
Armoire composée de blocs juxtaposés : penderie, rayonnage, blocs mixtes. Chaque bloc est paramétrable en largeur. Portes battantes ou coulissantes selon la largeur.
</p>
</section>
<section className={styles.section}>
<h2>Tarifs</h2>
<p className={styles.price}>À partir de CHF 3'900.–</p>
<p className={styles.muted}>Prix indicatif pour une configuration type. Le configurateur affiche le prix exact selon vos choix.</p>
</section>
<section className={styles.section}>
<h2>Services optionnels</h2>
<ul className={styles.features}>
<li>Prise de mesure sur site</li>
<li>Montage par un menuisier</li>
</ul>
<p className={styles.muted}>Tarifs au forfait ou à l'heure ; sélectionnables dans le configurateur.</p>
</section>
<section className={styles.ctaSection}>
<Link to="/configurateur" className={styles.cta}>Configurer mon armoire</Link>
</section>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
.home {
padding-bottom: 4rem;
}
.hero {
padding: 4rem 0;
text-align: center;
}
.hero h1 {
font-size: clamp(1.75rem, 4vw, 2.5rem);
font-weight: 700;
color: var(--primary);
margin: 0 0 1rem;
line-height: 1.2;
}
.subtitle {
font-size: 1.125rem;
color: var(--text-muted);
margin: 0 0 2rem;
max-width: 36rem;
margin-left: auto;
margin-right: auto;
}
.cta {
display: inline-block;
padding: 0.875rem 1.75rem;
background: var(--primary);
color: var(--bg) !important;
font-weight: 600;
border-radius: 6px;
text-decoration: none;
transition: background 0.2s;
}
.cta:hover {
background: var(--accent);
text-decoration: none;
}
.section {
padding: 2.5rem 0;
border-top: 1px solid var(--border);
}
.section h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--primary);
margin: 0 0 1rem;
}
.features, .steps {
margin: 0;
padding-left: 1.25rem;
color: var(--text);
}
.features li, .steps li {
margin-bottom: 0.5rem;
}
.muted {
color: var(--text-muted);
font-size: 0.9375rem;
margin: 0.5rem 0 0;
}
.price {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary);
margin: 0;
}
.ctaSection {
padding: 3rem 0;
text-align: center;
border-top: 1px solid var(--border);
}
+45
View File
@@ -0,0 +1,45 @@
import { useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import styles from './Login.module.css';
export default function Login() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const next = searchParams.get('next') || '/';
const handleSubmit = (e) => {
e.preventDefault();
setError('');
if (login(password)) {
navigate(next, { replace: true });
} else {
setError('Mot de passe incorrect.');
}
};
return (
<div className={styles.page}>
<div className={styles.card}>
<h1>Connexion admin</h1>
<form onSubmit={handleSubmit} className={styles.form}>
<label>
Mot de passe
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
autoComplete="current-password"
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<button type="submit">Se connecter</button>
</form>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
.page {
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2rem;
width: 100%;
max-width: 360px;
}
.card h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 1.5rem;
color: var(--primary);
}
.form label {
display: block;
font-size: 0.9375rem;
color: var(--text);
margin-bottom: 0.5rem;
}
.form input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
margin-bottom: 1rem;
}
.form button {
width: 100%;
padding: 0.75rem;
background: var(--primary);
color: var(--bg);
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
}
.form button:hover {
background: var(--accent);
}
.error {
color: #b91c1c;
font-size: 0.875rem;
margin: 0 0 1rem;
}
+269
View File
@@ -0,0 +1,269 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import {
fetchFormexMaterials,
fetchFormexCollections,
deleteFormexMaterial
} from '../api/client';
import MaterialModal from '../components/MaterialModal';
import styles from './Materials.module.css';
export default function Materials() {
const { isAdmin } = useAuth();
const navigate = useNavigate();
// Data state
const [materials, setMaterials] = useState([]);
const [collections, setCollections] = useState([]);
const [pagination, setPagination] = useState({ page: 1, limit: 25, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Filter state
const [search, setSearch] = useState('');
const [collection, setCollection] = useState('');
const [searchInput, setSearchInput] = useState('');
// Modal state
const [modalOpen, setModalOpen] = useState(false);
const [editingMaterial, setEditingMaterial] = useState(null);
// Delete confirmation
const [deletingId, setDeletingId] = useState(null);
const loadMaterials = useCallback(async (page = 1) => {
setLoading(true);
setError('');
try {
const result = await fetchFormexMaterials({ search, collection, page, limit: 25 });
setMaterials(result.materials);
setPagination(result.pagination);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [search, collection]);
useEffect(() => {
if (!isAdmin) {
navigate('/login?next=/materiaux', { replace: true });
return;
}
// Load collections for filter dropdown
fetchFormexCollections()
.then(setCollections)
.catch(() => {});
// Load materials
loadMaterials(1);
}, [isAdmin, navigate, loadMaterials]);
const handleSearch = (e) => {
e.preventDefault();
setSearch(searchInput);
};
const handleCollectionChange = (e) => {
setCollection(e.target.value);
};
// Reload when filters change
useEffect(() => {
loadMaterials(1);
}, [search, collection, loadMaterials]);
const handlePageChange = (newPage) => {
loadMaterials(newPage);
};
const handleCreate = () => {
setEditingMaterial(null);
setModalOpen(true);
};
const handleEdit = (material) => {
setEditingMaterial(material);
setModalOpen(true);
};
const handleModalClose = () => {
setModalOpen(false);
setEditingMaterial(null);
};
const handleModalSave = () => {
setModalOpen(false);
setEditingMaterial(null);
loadMaterials(pagination.page);
};
const handleDeleteClick = (id) => {
setDeletingId(id);
};
const handleDeleteConfirm = async () => {
if (!deletingId) return;
try {
await deleteFormexMaterial(deletingId);
setDeletingId(null);
loadMaterials(pagination.page);
} catch (err) {
setError(err.message);
setDeletingId(null);
}
};
const handleDeleteCancel = () => {
setDeletingId(null);
};
if (!isAdmin) return null;
return (
<div className={styles.page}>
<div className={styles.header}>
<h1>Matériaux Formex</h1>
<button type="button" onClick={handleCreate} className={styles.createBtn}>
+ Nouveau matériau
</button>
</div>
{/* Filters */}
<div className={styles.filters}>
<form onSubmit={handleSearch} className={styles.searchForm}>
<input
type="text"
placeholder="Rechercher par nom ou code…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className={styles.searchInput}
/>
<button type="submit" className={styles.searchBtn}>Rechercher</button>
</form>
<select
value={collection}
onChange={handleCollectionChange}
className={styles.collectionSelect}
>
<option value="">Toutes les collections</option>
{collections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{error && <p className={styles.error}>{error}</p>}
{loading ? (
<p className={styles.muted}>Chargement…</p>
) : (
<>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Code</th>
<th>Nom</th>
<th>Collection</th>
<th>Dimensions (mm)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{materials.length === 0 ? (
<tr>
<td colSpan={5} className={styles.empty}>
Aucun matériau trouvé.
</td>
</tr>
) : (
materials.map((m) => (
<tr key={m.id}>
<td className={styles.code}>{m.formex_code}</td>
<td>{m.name}</td>
<td>{m.collection}</td>
<td>
{m.width_mm && m.height_mm && m.thickness_mm
? `${m.width_mm} × ${m.height_mm} × ${m.thickness_mm}`
: '—'}
</td>
<td className={styles.actions}>
<button
type="button"
onClick={() => handleEdit(m)}
className={styles.editBtn}
>
Modifier
</button>
<button
type="button"
onClick={() => handleDeleteClick(m.id)}
className={styles.deleteBtn}
>
Supprimer
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className={styles.pagination}>
<button
type="button"
onClick={() => handlePageChange(pagination.page - 1)}
disabled={pagination.page <= 1}
className={styles.pageBtn}
>
Précédent
</button>
<span className={styles.pageInfo}>
Page {pagination.page} sur {pagination.totalPages} ({pagination.total} résultats)
</span>
<button
type="button"
onClick={() => handlePageChange(pagination.page + 1)}
disabled={pagination.page >= pagination.totalPages}
className={styles.pageBtn}
>
Suivant
</button>
</div>
)}
</>
)}
{/* Delete confirmation dialog */}
{deletingId && (
<div className={styles.overlay}>
<div className={styles.dialog}>
<p>Voulez-vous vraiment supprimer ce matériau ?</p>
<div className={styles.dialogActions}>
<button type="button" onClick={handleDeleteCancel} className={styles.cancelBtn}>
Annuler
</button>
<button type="button" onClick={handleDeleteConfirm} className={styles.confirmDeleteBtn}>
Supprimer
</button>
</div>
</div>
</div>
)}
{/* Create/Edit Modal */}
{modalOpen && (
<MaterialModal
material={editingMaterial}
collections={collections}
onClose={handleModalClose}
onSave={handleModalSave}
/>
)}
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
.page {
padding: 1.5rem 0 4rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--primary);
}
.createBtn {
padding: 0.5rem 1rem;
background: var(--accent);
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.createBtn:hover {
background: var(--primary);
}
/* Filters */
.filters {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.searchForm {
display: flex;
gap: 0.5rem;
flex: 1;
min-width: 200px;
max-width: 400px;
}
.searchInput {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
}
.searchInput:focus {
outline: none;
border-color: var(--accent);
}
.searchBtn {
padding: 0.5rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
.searchBtn:hover {
border-color: var(--accent);
}
.collectionSelect {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
background: #fff;
min-width: 180px;
}
.collectionSelect:focus {
outline: none;
border-color: var(--accent);
}
/* Table */
.tableWrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
background: var(--bg);
font-weight: 600;
color: var(--primary);
}
.table tbody tr:last-child td {
border-bottom: none;
}
.table tbody tr:hover {
background: var(--bg);
}
.code {
font-family: 'Consolas', 'Monaco', monospace;
font-weight: 500;
}
.actions {
display: flex;
gap: 0.5rem;
}
.editBtn,
.deleteBtn {
padding: 0.25rem 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
background: #fff;
}
.editBtn:hover {
border-color: var(--accent);
color: var(--accent);
}
.deleteBtn {
color: #b91c1c;
border-color: #fecaca;
}
.deleteBtn:hover {
background: #fef2f2;
border-color: #b91c1c;
}
.empty {
color: var(--text-muted);
text-align: center;
padding: 2rem;
}
.muted {
color: var(--text-muted);
font-size: 0.9375rem;
}
.error {
color: #b91c1c;
font-size: 0.9375rem;
margin: 0 0 1rem;
}
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
}
.pageBtn {
padding: 0.5rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
.pageBtn:hover:not(:disabled) {
border-color: var(--accent);
}
.pageBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pageInfo {
font-size: 0.875rem;
color: var(--text-muted);
}
/* Delete confirmation dialog */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: #fff;
padding: 1.5rem;
border-radius: 8px;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.dialog p {
margin: 0 0 1.5rem;
font-size: 0.9375rem;
}
.dialogActions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.cancelBtn {
padding: 0.5rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
.cancelBtn:hover {
border-color: var(--accent);
}
.confirmDeleteBtn {
padding: 0.5rem 1rem;
background: #b91c1c;
border: none;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
cursor: pointer;
}
.confirmDeleteBtn:hover {
background: #991b1b;
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
});
+58
View File
@@ -0,0 +1,58 @@
# Build sur Windows — Dépannage
Le client utilise **Vite**, qui s’appuie sur **esbuild** (binaire natif fourni pour Windows : `@esbuild/win32-x64/esbuild.exe`). Aucune compilation C++ n’est nécessaire : ce sont des exécutables précompilés.
## Vous avez désinstallé « Windows Development » / « Windows Developer » (~2 Go)
Le logiciel d’environ **2 Go** dont le nom contient **Windows Development** ou **Windows Developer** est en général :
- **Visual Studio Build Tools** (outils de génération Visual Studio), ou
- **Windows SDK** (Software Development Kit), ou
- un composant **« Développement Windows »** de Visual Studio.
Pour ce projet (Vite + esbuild), **vous n’avez pas besoin de réinstaller tout ça**. Il suffit d’installer le **runtime** qui va avec, beaucoup plus léger.
### 1. Réinstaller uniquement le Visual C++ Redistributable (souvent suffisant)
Après la désinstallation du gros paquet « Windows Development », il peut manquer le **Visual C++ Redistributable** (quelques dizaines de Mo). C’est lui qui permet d’exécuter des .exe natifs comme `esbuild.exe`.
- Télécharger : [Microsoft Visual C++ Redistributable (dernière version)](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist)
- Installer la version **x64** (et éventuellement x86 si vous utilisez du 32 bits).
- Redémarrer le terminal, puis dans `client/` : `npm run build`.
Pas besoin de réinstaller les 2 Go de « Windows Development » pour faire tourner le build.
### 2. Réinstaller les dépendances du client
```powershell
cd client
Remove-Item -Recurse -Force node_modules
Remove-Item -Force package-lock.json
npm install
npm run build
```
### 3. Antivirus / Windows Defender
Parfois l’antivirus ou Windows Defender bloque l’exécution de `esbuild.exe`.
- Ajouter une exclusion pour le dossier du projet (ou au minimum `client\node_modules`) dans Windows Defender / antivirus.
- Relancer `npm run build`.
### 4. Droits / exécution
- Lancer **PowerShell ou CMD en tant qu’administrateur** une fois, aller dans `client/` et lancer `npm run build` pour vérifier si le problème vient des droits.
- Vérifier que le disque n’est pas en lecture seule et que le dossier n’est pas synchronisé par OneDrive/Dropbox au moment du build.
### 5. Node et npm à jour
```powershell
node -v # 18+ recommandé
npm -v
```
Si besoin, réinstaller Node depuis [nodejs.org](https://nodejs.org/) (version LTS).
---
En résumé : après avoir désinstallé des outils de dev Windows, réinstaller le **Visual C++ Redistributable** et refaire un `npm install` dans `client/` règle souvent le problème.
+6
View File
@@ -0,0 +1,6 @@
{
"name": "apex",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "configurateur-armoires",
"version": "1.0.0",
"private": true,
"description": "Configurateur d'armoires sur mesure — SIGNATURE",
"scripts": {
"install:all": "npm install --prefix server && npm install --prefix client",
"db:init": "npm run db:init --prefix server",
"db:seed": "npm run db:seed --prefix server",
"server": "npm start --prefix server",
"client": "npm run dev --prefix client",
"build": "npm run build --prefix client",
"start": "npm run build --prefix client && npm start --prefix server"
}
}
+11
View File
@@ -0,0 +1,11 @@
# Chemin du fichier SQLite (optionnel, défaut : server/data/configurateur.db)
# DATABASE_PATH=./data/configurateur.db
PORT=3001
# Formex sync schedule (cron expression, default: 3:00 AM daily)
#FORMEX_SYNC_CRON=0 3 * * *
# Delay between requests in ms (to avoid rate limiting)
#FORMEX_CRAWL_DELAY_MS=500
# OpenAI API Key for room photo analysis (GPT-4 Vision)
OPENAI_API_KEY=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
+62
View File
@@ -0,0 +1,62 @@
import initSqlJs from 'sql.js';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
let db = null;
function toSqlitePlaceholders(sql) {
return sql.replace(/\$(\d+)/g, '?');
}
function save() {
if (db && dbPath) {
const data = db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
}
}
async function getDb() {
if (db) return db;
const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
let buffer;
if (fs.existsSync(dbPath)) {
buffer = fs.readFileSync(dbPath);
}
db = new SQL.Database(buffer || null);
return db;
}
export const dbReady = getDb();
export const query = async (sql, params = []) => {
const database = await getDb();
const sqliteSql = toSqlitePlaceholders(sql);
const stmt = database.prepare(sqliteSql);
stmt.bind(Array.isArray(params) ? params : []);
const rows = [];
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
return { rows };
};
export const run = async (sql, params = []) => {
const database = await getDb();
const sqliteSql = toSqlitePlaceholders(sql);
database.run(sqliteSql, Array.isArray(params) ? params : []);
save();
return {};
};
const pool = {
query: (sql, params) => query(sql, params)
};
export default pool;
+19
View File
@@ -0,0 +1,19 @@
import initSqlJs from 'sql.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
const db = new SQL.Database();
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
db.exec(schema);
fs.writeFileSync(dbPath, Buffer.from(db.export()));
db.close();
console.log('Schema applied.');
+56
View File
@@ -0,0 +1,56 @@
/**
* Run pending migrations on an existing database
*/
import initSqlJs from 'sql.js';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.join(__dirname, '..', 'data');
const dbPath = process.env.DATABASE_PATH || path.join(dataDir, 'configurateur.db');
const migrationsDir = path.join(__dirname, 'migrations');
async function migrate() {
if (!fs.existsSync(dbPath)) {
console.error(`Database not found at ${dbPath}. Run db:init first.`);
process.exit(1);
}
const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
const buffer = fs.readFileSync(dbPath);
const db = new SQL.Database(buffer);
// Get migration files
const migrationFiles = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
console.log(`Found ${migrationFiles.length} migration(s)`);
for (const file of migrationFiles) {
console.log(`Running migration: ${file}`);
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
try {
db.exec(sql);
console.log(` ✓ ${file} applied`);
} catch (err) {
// IF NOT EXISTS should prevent errors on re-run
console.log(` ✓ ${file} (already applied or no changes)`);
}
}
// Save database
fs.writeFileSync(dbPath, Buffer.from(db.export()));
db.close();
console.log('Migrations complete.');
}
migrate().catch(err => {
console.error('Migration failed:', err);
process.exit(1);
});
@@ -0,0 +1,25 @@
-- Migration: Add Formex materials and sync logs tables
-- Run this on existing databases to add crawler support
-- Matériaux Formex (données scrapées)
CREATE TABLE IF NOT EXISTS formex_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formex_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
collection TEXT NOT NULL,
width_mm INTEGER,
height_mm INTEGER,
thickness_mm INTEGER,
formex_url TEXT,
last_synced_at TEXT DEFAULT (datetime('now'))
);
-- Journal de synchronisation
CREATE TABLE IF NOT EXISTS sync_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
status TEXT NOT NULL,
items_count INTEGER,
error TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
@@ -0,0 +1,34 @@
-- Migration: Enhanced Formex materials with dimensions, variants, images
-- Adds support for multiple dimension formats and color variants per product
-- Add new columns to formex_materials
ALTER TABLE formex_materials ADD COLUMN category TEXT;
ALTER TABLE formex_materials ADD COLUMN image_url TEXT;
ALTER TABLE formex_materials ADD COLUMN price_chf REAL;
ALTER TABLE formex_materials ADD COLUMN active INTEGER DEFAULT 1;
-- Dimension variants (multiple dimensions per material)
CREATE TABLE IF NOT EXISTS formex_dimensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
width_mm INTEGER NOT NULL,
height_mm INTEGER NOT NULL,
thickness_mm INTEGER NOT NULL,
is_default INTEGER DEFAULT 0
);
-- Color/decor variants (linked products)
CREATE TABLE IF NOT EXISTS formex_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
variant_code TEXT NOT NULL,
variant_name TEXT NOT NULL,
variant_url TEXT,
image_url TEXT
);
-- Create indexes for better query performance
CREATE INDEX IF NOT EXISTS idx_formex_dimensions_material ON formex_dimensions(material_id);
CREATE INDEX IF NOT EXISTS idx_formex_variants_material ON formex_variants(material_id);
CREATE INDEX IF NOT EXISTS idx_formex_materials_active ON formex_materials(active);
CREATE INDEX IF NOT EXISTS idx_formex_materials_collection ON formex_materials(collection);
+12
View File
@@ -0,0 +1,12 @@
-- Migration: Room configurations table for AI-analyzed room layouts
-- Stores room analysis results linked to orders
CREATE TABLE IF NOT EXISTS room_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER REFERENCES orders(id),
image_path TEXT,
analysis_json TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_room_configs_order ON room_configs(order_id);
+72
View File
@@ -0,0 +1,72 @@
-- Migration: Modules, hardware, and variants for V2 configurator
-- Catalogue de modules standards
CREATE TABLE IF NOT EXISTS modules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
category TEXT NOT NULL, -- 'penderie', 'rayonnage', 'tiroirs', 'mixte'
width_mm INTEGER NOT NULL,
height_mm INTEGER,
depth_mm INTEGER,
shelves_count INTEGER DEFAULT 0,
drawers_count INTEGER DEFAULT 0,
has_rod INTEGER DEFAULT 0, -- penderie
customizable INTEGER DEFAULT 1,
base_price_chf REAL NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
-- Quincaillerie Blum
CREATE TABLE IF NOT EXISTS blum_hardware (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'drawer_system', 'hinge', 'lift_system', 'runner'
variant TEXT, -- 'legrabox', 'tandembox', 'clip_top', etc.
price_chf REAL NOT NULL,
specs_json TEXT,
image_url TEXT,
active INTEGER DEFAULT 1
);
-- Variantes poignées
CREATE TABLE IF NOT EXISTS handle_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
style TEXT, -- 'moderne', 'classique', 'minimaliste', etc.
length_mm INTEGER,
price_chf REAL NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1
);
-- Variantes charnières
CREATE TABLE IF NOT EXISTS hinge_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'vissee', 'plantee'
brand TEXT DEFAULT 'Blum',
price_chf REAL NOT NULL,
active INTEGER DEFAULT 1
);
-- Configuration couleurs par élément (pour sauvegarder les configs)
CREATE TABLE IF NOT EXISTS element_colors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
element_type TEXT NOT NULL, -- 'plintheBas', 'portes', 'cotes', etc.
finish_id INTEGER NOT NULL,
finish_label TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category);
CREATE INDEX IF NOT EXISTS idx_modules_active ON modules(active);
CREATE INDEX IF NOT EXISTS idx_blum_hardware_type ON blum_hardware(type);
CREATE INDEX IF NOT EXISTS idx_handle_variants_style ON handle_variants(style);
CREATE INDEX IF NOT EXISTS idx_hinge_variants_type ON hinge_variants(type);
CREATE INDEX IF NOT EXISTS idx_element_colors_order ON element_colors(order_id);
@@ -0,0 +1,5 @@
-- Add dominant_color column to formex_materials for 3D preview color representation
ALTER TABLE formex_materials ADD COLUMN dominant_color TEXT;
-- Create index for quick lookups
CREATE INDEX IF NOT EXISTS idx_formex_materials_dominant_color ON formex_materials(dominant_color);
+202
View File
@@ -0,0 +1,202 @@
-- Configurateur armoires sur mesure — Schéma V1 (SQLite)
-- Paramètres globaux (forfait kit, plinthe, épaisseur panneaux, etc.)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Produits / gammes (ex. SIGNATURE)
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
-- Types de blocs (penderie, rayonnage, mixte vertical, mixte horizontal)
CREATE TABLE IF NOT EXISTS block_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
base_price_chf REAL NOT NULL,
width_min_mm INTEGER NOT NULL,
width_max_mm INTEGER NOT NULL,
active INTEGER DEFAULT 1
);
-- Formats de panneaux (par finition : dimensions + prix panneau entier)
CREATE TABLE IF NOT EXISTS panel_formats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
width_mm INTEGER NOT NULL,
height_mm INTEGER NOT NULL,
price_chf REAL NOT NULL
);
-- Finitions visibles (stratifié décor, couleur, effet bois, etc.)
CREATE TABLE IF NOT EXISTS finishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
panel_format_id INTEGER NOT NULL REFERENCES panel_formats(id),
active INTEGER DEFAULT 1
);
-- Options de service (prise de mesure, montage)
CREATE TABLE IF NOT EXISTS service_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
label TEXT NOT NULL,
price_chf REAL NOT NULL,
price_type TEXT NOT NULL CHECK (price_type IN ('fixed', 'per_hour', 'per_block')),
active INTEGER DEFAULT 1
);
-- Commandes / leads (config snapshot + coordonnées)
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
config TEXT NOT NULL,
total_chf REAL,
email TEXT,
name TEXT,
phone TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- Matériaux Formex (données scrapées)
CREATE TABLE IF NOT EXISTS formex_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formex_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
collection TEXT NOT NULL,
category TEXT,
width_mm INTEGER,
height_mm INTEGER,
thickness_mm INTEGER,
image_url TEXT,
formex_url TEXT,
price_chf REAL,
dominant_color TEXT,
active INTEGER DEFAULT 1,
last_synced_at TEXT DEFAULT (datetime('now'))
);
-- Dimension variants (multiple dimensions per material)
CREATE TABLE IF NOT EXISTS formex_dimensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
width_mm INTEGER NOT NULL,
height_mm INTEGER NOT NULL,
thickness_mm INTEGER NOT NULL,
is_default INTEGER DEFAULT 0
);
-- Color/decor variants (linked products)
CREATE TABLE IF NOT EXISTS formex_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_id INTEGER NOT NULL REFERENCES formex_materials(id) ON DELETE CASCADE,
variant_code TEXT NOT NULL,
variant_name TEXT NOT NULL,
variant_url TEXT,
image_url TEXT
);
CREATE INDEX IF NOT EXISTS idx_formex_dimensions_material ON formex_dimensions(material_id);
CREATE INDEX IF NOT EXISTS idx_formex_variants_material ON formex_variants(material_id);
CREATE INDEX IF NOT EXISTS idx_formex_materials_active ON formex_materials(active);
CREATE INDEX IF NOT EXISTS idx_formex_materials_collection ON formex_materials(collection);
-- Journal de synchronisation
CREATE TABLE IF NOT EXISTS sync_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
status TEXT NOT NULL,
items_count INTEGER,
error TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- Room configurations (AI-analyzed room layouts)
CREATE TABLE IF NOT EXISTS room_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER REFERENCES orders(id),
image_path TEXT,
analysis_json TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_room_configs_order ON room_configs(order_id);
-- Catalogue de modules standards
CREATE TABLE IF NOT EXISTS modules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
category TEXT NOT NULL, -- 'penderie', 'rayonnage', 'tiroirs', 'mixte'
width_mm INTEGER NOT NULL,
height_mm INTEGER,
depth_mm INTEGER,
shelves_count INTEGER DEFAULT 0,
drawers_count INTEGER DEFAULT 0,
has_rod INTEGER DEFAULT 0, -- penderie
customizable INTEGER DEFAULT 1,
base_price_chf REAL NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
-- Quincaillerie Blum
CREATE TABLE IF NOT EXISTS blum_hardware (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'drawer_system', 'hinge', 'lift_system', 'runner'
variant TEXT, -- 'legrabox', 'tandembox', 'clip_top', etc.
price_chf REAL NOT NULL,
specs_json TEXT,
image_url TEXT,
active INTEGER DEFAULT 1
);
-- Variantes poignées
CREATE TABLE IF NOT EXISTS handle_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
style TEXT, -- 'moderne', 'classique', 'minimaliste', etc.
length_mm INTEGER,
price_chf REAL NOT NULL,
image_url TEXT,
active INTEGER DEFAULT 1
);
-- Variantes charnières
CREATE TABLE IF NOT EXISTS hinge_variants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'vissee', 'plantee'
brand TEXT DEFAULT 'Blum',
price_chf REAL NOT NULL,
active INTEGER DEFAULT 1
);
-- Configuration couleurs par élément (pour sauvegarder les configs)
CREATE TABLE IF NOT EXISTS element_colors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER REFERENCES orders(id) ON DELETE CASCADE,
element_type TEXT NOT NULL, -- 'plintheBas', 'portes', 'cotes', etc.
finish_id INTEGER NOT NULL,
finish_label TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category);
CREATE INDEX IF NOT EXISTS idx_modules_active ON modules(active);
CREATE INDEX IF NOT EXISTS idx_blum_hardware_type ON blum_hardware(type);
CREATE INDEX IF NOT EXISTS idx_handle_variants_style ON handle_variants(style);
CREATE INDEX IF NOT EXISTS idx_hinge_variants_type ON hinge_variants(type);
CREATE INDEX IF NOT EXISTS idx_element_colors_order ON element_colors(order_id);
+23
View File
@@ -0,0 +1,23 @@
import initSqlJs from 'sql.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '..', 'data', 'configurateur.db');
if (!fs.existsSync(dbPath)) {
console.error('Database file not found. Run npm run db:init first.');
process.exit(1);
}
const SQL = await initSqlJs({
locateFile: (file) => path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', file)
});
const buffer = fs.readFileSync(dbPath);
const db = new SQL.Database(buffer);
const seedSql = fs.readFileSync(path.join(__dirname, 'seeds.sql'), 'utf8');
db.exec(seedSql);
fs.writeFileSync(dbPath, Buffer.from(db.export()));
db.close();
console.log('Seeds applied.');
+75
View File
@@ -0,0 +1,75 @@
-- Données initiales V1 (SQLite)
INSERT OR REPLACE INTO settings (key, value) VALUES
('kit_preparation_fee_chf', '350'),
('panel_thickness_mm', '19'),
('plinth_height_min_mm', '60'),
('plinth_height_max_mm', '160'),
('niche_clearance_multiplier', '2'),
('min_clearance_mm', '20'),
('sliding_doors_min_width_mm', '1200');
INSERT OR IGNORE INTO products (code, label, active) VALUES
('SIGNATURE', 'Armoire SIGNATURE', 1);
INSERT OR IGNORE INTO block_types (code, label, base_price_chf, width_min_mm, width_max_mm) VALUES
('penderie', 'Penderie', 550, 400, 1200),
('rayonnage', 'Rayonnage', 550, 400, 1200),
('mixte_vertical', 'Mixte vertical', 550, 400, 1200),
('mixte_horizontal', 'Mixte horizontal', 550, 400, 1200);
INSERT INTO panel_formats (name, width_mm, height_mm, price_chf) VALUES
('Stratifié standard', 2800, 2070, 85),
('Stratifié couleur', 2800, 2070, 95),
('Stratifié effet bois', 2800, 2070, 110),
('Stratifié vieux bois', 2800, 2070, 120);
INSERT OR IGNORE INTO finishes (code, label, panel_format_id) VALUES
('stratifie_decor', 'Stratifié décor', (SELECT id FROM panel_formats WHERE name = 'Stratifié standard' LIMIT 1)),
('stratifie_couleur', 'Stratifié couleur', (SELECT id FROM panel_formats WHERE name = 'Stratifié couleur' LIMIT 1)),
('stratifie_effet_bois', 'Stratifié effet bois', (SELECT id FROM panel_formats WHERE name = 'Stratifié effet bois' LIMIT 1)),
('stratifie_vieux_bois', 'Stratifié vieux bois', (SELECT id FROM panel_formats WHERE name = 'Stratifié vieux bois' LIMIT 1));
INSERT OR IGNORE INTO service_options (code, label, price_chf, price_type) VALUES
('prise_mesure', 'Prise de mesure', 95, 'per_hour'),
('montage', 'Montage', 95, 'per_hour');
-- Modules standards V2
INSERT OR IGNORE INTO modules (code, name, category, width_mm, height_mm, depth_mm, shelves_count, drawers_count, has_rod, customizable, base_price_chf) VALUES
('penderie_simple', 'Penderie simple', 'penderie', 600, NULL, 580, 1, 0, 1, 1, 450),
('penderie_double', 'Penderie double', 'penderie', 900, NULL, 580, 2, 0, 1, 1, 550),
('penderie_large', 'Penderie large', 'penderie', 1200, NULL, 580, 2, 0, 1, 1, 650),
('rayonnage_4', 'Rayonnage 4 étagères', 'rayonnage', 600, NULL, 580, 4, 0, 0, 1, 380),
('rayonnage_5', 'Rayonnage 5 étagères', 'rayonnage', 600, NULL, 580, 5, 0, 0, 1, 420),
('rayonnage_6', 'Rayonnage 6 étagères', 'rayonnage', 600, NULL, 580, 6, 0, 0, 1, 460),
('tiroirs_3', 'Bloc 3 tiroirs', 'tiroirs', 600, 600, 580, 0, 3, 0, 0, 520),
('tiroirs_4', 'Bloc 4 tiroirs', 'tiroirs', 600, 800, 580, 0, 4, 0, 0, 620),
('tiroirs_5', 'Bloc 5 tiroirs', 'tiroirs', 600, 1000, 580, 0, 5, 0, 0, 720),
('mixte_penderie_tiroirs', 'Penderie + tiroirs', 'mixte', 600, NULL, 580, 0, 2, 1, 1, 580),
('mixte_rayonnage_tiroirs', 'Rayonnage + tiroirs', 'mixte', 600, NULL, 580, 3, 2, 0, 1, 550),
('mixte_complet', 'Module complet', 'mixte', 900, NULL, 580, 2, 2, 1, 1, 720);
-- Quincaillerie Blum
INSERT OR IGNORE INTO blum_hardware (code, name, type, variant, price_chf, specs_json) VALUES
('legrabox_pure', 'LEGRABOX pure', 'drawer_system', 'legrabox', 85, '{"height_mm": 177, "load_kg": 70, "soft_close": true}'),
('legrabox_free', 'LEGRABOX free', 'drawer_system', 'legrabox', 95, '{"height_mm": 177, "load_kg": 70, "soft_close": true, "tip_on": true}'),
('tandembox_antaro', 'TANDEMBOX antaro', 'drawer_system', 'tandembox', 65, '{"height_mm": 83, "load_kg": 65, "soft_close": true}'),
('tandembox_intivo', 'TANDEMBOX intivo', 'drawer_system', 'tandembox', 75, '{"height_mm": 83, "load_kg": 65, "soft_close": true}'),
('movento_runner', 'MOVENTO coulisse', 'runner', 'movento', 45, '{"load_kg": 60, "soft_close": true}'),
('clip_top_soft', 'CLIP top BLUMOTION', 'hinge', 'clip_top', 12, '{"opening_angle": 110, "soft_close": true}'),
('clip_top_standard', 'CLIP top Standard', 'hinge', 'clip_top', 8, '{"opening_angle": 110, "soft_close": false}'),
('aventos_hf', 'AVENTOS HF', 'lift_system', 'aventos', 180, '{"type": "bi-fold", "soft_close": true}'),
('aventos_hl', 'AVENTOS HL', 'lift_system', 'aventos', 150, '{"type": "lift_up", "soft_close": true}');
-- Variantes poignées
INSERT OR IGNORE INTO handle_variants (code, name, style, length_mm, price_chf) VALUES
('barre_moderne', 'Barre moderne', 'moderne', 160, 25),
('barre_longue', 'Barre longue', 'moderne', 320, 35),
('bouton_rond', 'Bouton rond', 'classique', 35, 15),
('coquille', 'Coquille encastrée', 'minimaliste', 100, 28),
('profil_alu', 'Profil aluminium', 'minimaliste', NULL, 45);
-- Variantes charnières
INSERT OR IGNORE INTO hinge_variants (code, name, type, brand, price_chf) VALUES
('blum_vissee', 'Blum vissée', 'vissee', 'Blum', 12),
('blum_plantee', 'Blum plantée (INSERTA)', 'plantee', 'Blum', 18);
+163
View File
@@ -0,0 +1,163 @@
import dotenv from 'dotenv';
import express from 'express';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import cors from 'cors';
import { dbReady } from './db/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.join(__dirname, '.env') });
import { getProducts } from './routes/products.js';
import { getBlockTypes } from './routes/blockTypes.js';
import { getFinishes } from './routes/finishes.js';
import { getServiceOptions } from './routes/serviceOptions.js';
import { getSettings } from './routes/settings.js';
import { postPrice } from './routes/price.js';
import { getOrders, postOrder } from './routes/orders.js';
import {
triggerFormexSync,
streamFormexSync,
getLogs,
getStatus,
getFormexMaterials,
getFormexMaterial,
createFormexMaterial,
updateFormexMaterial,
deleteFormexMaterial,
getFormexCollections,
getFormexCategories
} from './routes/sync.js';
import {
uploadMiddleware,
analyzeRoom,
saveRoomConfig,
getRoomConfig,
checkApiStatus
} from './routes/room.js';
import {
optimizeLayoutRoute,
quickOptimizeRoute,
suggestLayoutRoute
} from './routes/layout.js';
import {
getModules,
getModule,
getModuleCategories,
getHardware,
getHardwareTypes,
getHandles,
getHinges
} from './routes/modules.js';
import {
generatePlansRoute,
generateCuttingListRoute,
exportCuttingListText,
exportPDFHtml
} from './routes/plans.js';
import {
designTipsRoute,
recommendMaterialsRoute,
renderRoute
} from './routes/ai.js';
import { initScheduler } from './services/scheduler.js';
const clientDist = path.join(__dirname, '..', 'client', 'dist');
const hasClientBuild = fs.existsSync(clientDist);
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors({ origin: true }));
app.use(express.json());
app.get('/api/products', getProducts);
app.get('/api/block-types', getBlockTypes);
app.get('/api/finishes', getFinishes);
app.get('/api/service-options', getServiceOptions);
app.get('/api/settings', getSettings);
app.post('/api/price', postPrice);
app.get('/api/orders', getOrders);
app.post('/api/orders', postOrder);
// Sync routes (Formex crawler)
app.post('/api/sync/formex', triggerFormexSync);
app.get('/api/sync/formex/stream', streamFormexSync);
app.get('/api/sync/logs', getLogs);
app.get('/api/sync/status', getStatus);
// Formex materials CRUD
app.get('/api/formex/materials', getFormexMaterials);
app.get('/api/formex/materials/:id', getFormexMaterial);
app.post('/api/formex/materials', createFormexMaterial);
app.put('/api/formex/materials/:id', updateFormexMaterial);
app.delete('/api/formex/materials/:id', deleteFormexMaterial);
app.get('/api/formex/collections', getFormexCollections);
app.get('/api/formex/categories', getFormexCategories);
// Room analysis routes (AI-powered)
app.post('/api/room/analyze', uploadMiddleware, analyzeRoom);
app.post('/api/room/save', saveRoomConfig);
app.get('/api/room/config/:orderId', getRoomConfig);
app.get('/api/room/status', checkApiStatus);
// Layout optimization routes (AI-powered)
app.post('/api/layout/optimize', optimizeLayoutRoute);
app.post('/api/layout/quick-optimize', quickOptimizeRoute);
app.get('/api/layout/suggest', suggestLayoutRoute);
// Modules catalog routes
app.get('/api/modules', getModules);
app.get('/api/modules/categories', getModuleCategories);
app.get('/api/modules/:id', getModule);
// Hardware routes
app.get('/api/hardware', getHardware);
app.get('/api/hardware/types', getHardwareTypes);
app.get('/api/handles', getHandles);
app.get('/api/hinges', getHinges);
// AI design assistant routes
app.post('/api/ai/design-tips', designTipsRoute);
app.post('/api/ai/recommend-materials', recommendMaterialsRoute);
app.post('/api/ai/render', renderRoute);
// Plans and cutting list routes
app.post('/api/plans/generate', generatePlansRoute);
app.post('/api/plans/cutting-list', generateCuttingListRoute);
app.post('/api/plans/export-text', exportCuttingListText);
app.post('/api/plans/export-pdf', exportPDFHtml);
if (hasClientBuild) {
app.use(express.static(clientDist));
app.get('*', (req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
} else {
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Configurateur</title></head>
<body style="font-family:sans-serif;padding:2rem;">
<h1>Application non construite</h1>
<p>Depuis la racine du projet, exécutez&nbsp;:</p>
<pre>npm run build</pre>
<p>Puis rechargez cette page. Ou lancez le client en mode dev&nbsp;:</p>
<pre>npm run client</pre>
<p>et ouvrez <a href="http://localhost:5173">http://localhost:5173</a>.</p>
</body></html>
`);
});
}
await dbReady;
// Initialize the scheduler for automated sync
initScheduler();
app.listen(PORT, () => {
console.log(`API configurateur armoires — http://localhost:${PORT}`);
if (!hasClientBuild) {
console.log('(Exécutez "npm run build" pour servir l\'app sur ce port)');
}
});
+1623
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "configurateur-armoires-server",
"version": "1.0.0",
"description": "API Node/Express pour le configurateur d'armoires sur mesure",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js",
"db:init": "node db/init.js",
"db:seed": "node db/seed.js",
"db:migrate": "node db/migrate.js"
},
"dependencies": {
"cheerio": "^1.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"openai": "^4.28.0",
"sql.js": "^1.11.0"
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* AI Design Assistant routes
* - POST /api/ai/design-tips → contextual design advice
* - POST /api/ai/recommend-materials → material/color recommendations
* - POST /api/ai/render → DALL-E 3 photorealistic render
*/
import { getDesignTip, recommendMaterials, generateRender } from '../services/designAdvisor.js';
/**
* POST /api/ai/design-tips
* Body: { config: { width, height, depth, columns, materialName, ... } }
*/
export async function designTipsRoute(req, res) {
try {
const { config } = req.body;
if (!config) {
return res.status(400).json({ error: 'Missing config in body' });
}
const result = await getDesignTip(config);
res.json(result);
} catch (err) {
console.error('[AI design-tips]', err.message);
if (err.message.includes('OPENAI_API_KEY')) {
return res.status(503).json({ error: 'AI service not configured' });
}
res.status(500).json({ error: 'Failed to generate design tip' });
}
}
/**
* POST /api/ai/recommend-materials
* Body: { config, finishes: [{ id, name, collection }], roomAnalysis? }
*/
export async function recommendMaterialsRoute(req, res) {
try {
const { config, finishes, roomAnalysis } = req.body;
if (!config || !finishes) {
return res.status(400).json({ error: 'Missing config or finishes in body' });
}
const result = await recommendMaterials(config, finishes, roomAnalysis);
res.json(result);
} catch (err) {
console.error('[AI recommend-materials]', err.message);
if (err.message.includes('OPENAI_API_KEY')) {
return res.status(503).json({ error: 'AI service not configured' });
}
res.status(500).json({ error: 'Failed to generate recommendations' });
}
}
/**
* POST /api/ai/render
* Body: { config, roomContext? }
*/
export async function renderRoute(req, res) {
try {
const { config, roomContext } = req.body;
if (!config) {
return res.status(400).json({ error: 'Missing config in body' });
}
const result = await generateRender(config, roomContext);
res.json(result);
} catch (err) {
console.error('[AI render]', err.message);
if (err.message.includes('OPENAI_API_KEY')) {
return res.status(503).json({ error: 'AI service not configured' });
}
if (err.message.includes('billing') || err.message.includes('quota')) {
return res.status(402).json({ error: 'AI quota exceeded' });
}
res.status(500).json({ error: 'Failed to generate render' });
}
}
+12
View File
@@ -0,0 +1,12 @@
import pool from '../db/index.js';
export async function getBlockTypes(req, res) {
try {
const { rows } = await pool.query(
'SELECT id, code, label, base_price_chf, width_min_mm, width_max_mm FROM block_types WHERE active = true ORDER BY id'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+67
View File
@@ -0,0 +1,67 @@
import pool from '../db/index.js';
/**
* GET /api/finishes
* Returns active Formex materials as finishes for the configurator
* Falls back to legacy finishes if no Formex materials exist
*/
export async function getFinishes(req, res) {
try {
// Check if we have active Formex materials
const formexResult = await pool.query(
`SELECT COUNT(*) as count FROM formex_materials WHERE active = 1`
);
const hasFormexMaterials = (formexResult.rows[0]?.count || 0) > 0;
if (hasFormexMaterials) {
// Return Formex materials as finishes
const { rows } = await pool.query(`
SELECT
id,
formex_code as code,
name as label,
name,
collection,
category,
width_mm,
height_mm,
thickness_mm,
image_url,
formex_url,
price_chf,
dominant_color as hexColor,
'formex' as source
FROM formex_materials
WHERE active = 1
ORDER BY collection, name
`);
// For each material, get its dimension variants
for (const material of rows) {
const dims = await pool.query(
`SELECT id, width_mm, height_mm, thickness_mm, is_default
FROM formex_dimensions WHERE material_id = ?
ORDER BY is_default DESC, width_mm`,
[material.id]
);
material.dimensions = dims.rows;
}
res.json(rows);
} else {
// Fallback to legacy finishes
const { rows } = await pool.query(`
SELECT f.id, f.code, f.label, f.panel_format_id,
pf.width_mm, pf.height_mm, pf.price_chf,
'legacy' as source
FROM finishes f
JOIN panel_formats pf ON pf.id = f.panel_format_id
WHERE f.active = 1
ORDER BY f.id
`);
res.json(rows);
}
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Layout optimization routes
*/
import { optimizeLayout, quickOptimize, suggestInitialLayout } from '../services/layoutOptimizer.js';
import pool from '../db/index.js';
/**
* POST /api/layout/optimize
* Optimize module layout using AI
*/
export async function optimizeLayoutRoute(req, res) {
try {
const { config, lockedModuleIds = [] } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
// Fetch available modules from catalog
const { rows: availableModules } = await pool.query(
'SELECT * FROM modules WHERE active = 1 ORDER BY category, width_mm'
);
if (availableModules.length === 0) {
return res.status(400).json({ error: 'Aucun module disponible dans le catalogue' });
}
const result = await optimizeLayout(config, availableModules, lockedModuleIds);
res.json(result);
} catch (error) {
console.error('Layout optimization error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'optimisation' });
}
}
/**
* POST /api/layout/quick-optimize
* Quick local optimization without AI
*/
export async function quickOptimizeRoute(req, res) {
try {
const { config, lockedModuleIds = [] } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const result = quickOptimize(config, lockedModuleIds);
res.json(result);
} catch (error) {
console.error('Quick optimization error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'optimisation' });
}
}
/**
* GET /api/layout/suggest?width=2400
* Suggest initial layout for given width
*/
export async function suggestLayoutRoute(req, res) {
try {
const totalWidth = parseInt(req.query.width) || 2400;
// Fetch available modules
const { rows: availableModules } = await pool.query(
'SELECT * FROM modules WHERE active = 1 ORDER BY category, width_mm'
);
if (availableModules.length === 0) {
return res.status(400).json({ error: 'Aucun module disponible' });
}
const suggestions = suggestInitialLayout(totalWidth, availableModules);
res.json({ success: true, modules: suggestions, totalWidth });
} catch (error) {
console.error('Layout suggestion error:', error);
res.status(500).json({ error: error.message || 'Erreur de suggestion' });
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Modules catalog and hardware routes
*/
import pool from '../db/index.js';
/**
* GET /api/modules
* Get all modules from catalog
*/
export async function getModules(req, res) {
try {
const { category, active } = req.query;
let sql = 'SELECT * FROM modules WHERE 1=1';
const params = [];
if (category) {
sql += ' AND category = ?';
params.push(category);
}
if (active !== undefined) {
sql += ' AND active = ?';
params.push(active === 'true' || active === '1' ? 1 : 0);
}
sql += ' ORDER BY category, width_mm, name';
const { rows } = await pool.query(sql, params);
res.json(rows);
} catch (error) {
console.error('Get modules error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/modules/:id
* Get single module by ID
*/
export async function getModule(req, res) {
try {
const { id } = req.params;
const { rows } = await pool.query('SELECT * FROM modules WHERE id = ?', [id]);
if (rows.length === 0) {
return res.status(404).json({ error: 'Module non trouvé' });
}
res.json(rows[0]);
} catch (error) {
console.error('Get module error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/modules/categories
* Get distinct module categories
*/
export async function getModuleCategories(req, res) {
try {
const { rows } = await pool.query(
'SELECT DISTINCT category FROM modules WHERE active = 1 ORDER BY category'
);
res.json(rows.map(r => r.category));
} catch (error) {
console.error('Get categories error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/hardware
* Get all Blum hardware
*/
export async function getHardware(req, res) {
try {
const { type } = req.query;
let sql = 'SELECT * FROM blum_hardware WHERE active = 1';
const params = [];
if (type) {
sql += ' AND type = ?';
params.push(type);
}
sql += ' ORDER BY type, name';
const { rows } = await pool.query(sql, params);
// Parse specs_json for each item
const hardware = rows.map(h => ({
...h,
specs: h.specs_json ? JSON.parse(h.specs_json) : null
}));
res.json(hardware);
} catch (error) {
console.error('Get hardware error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/hardware/types
* Get distinct hardware types
*/
export async function getHardwareTypes(req, res) {
try {
const { rows } = await pool.query(
'SELECT DISTINCT type FROM blum_hardware WHERE active = 1 ORDER BY type'
);
res.json(rows.map(r => r.type));
} catch (error) {
console.error('Get hardware types error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/handles
* Get all handle variants
*/
export async function getHandles(req, res) {
try {
const { style } = req.query;
let sql = 'SELECT * FROM handle_variants WHERE active = 1';
const params = [];
if (style) {
sql += ' AND style = ?';
params.push(style);
}
sql += ' ORDER BY style, name';
const { rows } = await pool.query(sql, params);
res.json(rows);
} catch (error) {
console.error('Get handles error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/hinges
* Get all hinge variants
*/
export async function getHinges(req, res) {
try {
const { type } = req.query;
let sql = 'SELECT * FROM hinge_variants WHERE active = 1';
const params = [];
if (type) {
sql += ' AND type = ?';
params.push(type);
}
sql += ' ORDER BY type, name';
const { rows } = await pool.query(sql, params);
res.json(rows);
} catch (error) {
console.error('Get hinges error:', error);
res.status(500).json({ error: error.message });
}
}
+27
View File
@@ -0,0 +1,27 @@
import pool, { run } from '../db/index.js';
export async function getOrders(req, res) {
try {
const { rows } = await pool.query(
'SELECT id, config, total_chf, email, name, phone, notes, created_at FROM orders ORDER BY created_at DESC'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
export async function postOrder(req, res) {
try {
const { config, totalChf, email, name, phone, notes } = req.body || {};
await run(
`INSERT INTO orders (config, total_chf, email, name, phone, notes)
VALUES ($1, $2, $3, $4, $5, $6)`,
[JSON.stringify(config || {}), totalChf ?? null, email ?? null, name ?? null, phone ?? null, notes ?? null]
);
const { rows } = await pool.query('SELECT id, created_at FROM orders ORDER BY id DESC LIMIT 1');
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Plans and cutting list routes
*/
import { generateAllPlans, generatePDFHtml } from '../services/planGenerator.js';
import { generateCuttingList, formatCuttingListForExport } from '../services/cuttingEngine.js';
/**
* POST /api/plans/generate
* Generate all 2D plans for a configuration
*/
export async function generatePlansRoute(req, res) {
try {
const { config, modules = [], colors = {} } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const plans = generateAllPlans(config, modules, colors);
res.json({ success: true, plans });
} catch (error) {
console.error('Plan generation error:', error);
res.status(500).json({ error: error.message || 'Erreur de génération des plans' });
}
}
/**
* POST /api/plans/cutting-list
* Generate cutting list for a configuration
*/
export async function generateCuttingListRoute(req, res) {
try {
const { config, modules = [], colors = {} } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const cuttingList = generateCuttingList(config, colors, modules);
res.json({ success: true, cuttingList });
} catch (error) {
console.error('Cutting list error:', error);
res.status(500).json({ error: error.message || 'Erreur de génération de la liste de débitage' });
}
}
/**
* POST /api/plans/export-text
* Export cutting list as text file
*/
export async function exportCuttingListText(req, res) {
try {
const { config, modules = [], colors = {} } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const cuttingList = generateCuttingList(config, colors, modules);
const textContent = formatCuttingListForExport(cuttingList);
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="liste-debitage.txt"');
res.send(textContent);
} catch (error) {
console.error('Export text error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export' });
}
}
/**
* POST /api/plans/export-pdf
* Export plans and cutting list as HTML (for PDF conversion)
*/
export async function exportPDFHtml(req, res) {
try {
const { config, modules = [], colors = {} } = req.body;
if (!config) {
return res.status(400).json({ error: 'Configuration requise' });
}
const cuttingList = generateCuttingList(config, colors, modules);
const html = generatePDFHtml(config, modules, cuttingList);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(html);
} catch (error) {
console.error('Export PDF error:', error);
res.status(500).json({ error: error.message || 'Erreur d\'export PDF' });
}
}
+79
View File
@@ -0,0 +1,79 @@
import pool from '../db/index.js';
import { calculatePrice } from '../services/priceEngine.js';
export async function postPrice(req, res) {
try {
const config = req.body || {};
// Fetch all required data
const [
products,
blockTypes,
legacyFinishes,
formexMaterials,
serviceOptions,
settingsRows,
modulesData,
handlesData,
hingesData,
blumData
] = await Promise.all([
pool.query('SELECT id, code, label FROM products WHERE active = true'),
pool.query('SELECT id, code, label, base_price_chf, width_min_mm, width_max_mm FROM block_types WHERE active = true'),
pool.query(`
SELECT f.id, f.code, f.label, pf.width_mm, pf.height_mm, pf.price_chf
FROM finishes f JOIN panel_formats pf ON pf.id = f.panel_format_id WHERE f.active = true
`),
pool.query(`
SELECT id, formex_code as code, name as label, name, collection, category,
width_mm, height_mm, thickness_mm, image_url, price_chf,
dominant_color as hexColor
FROM formex_materials WHERE active = 1
`),
pool.query('SELECT id, code, label, price_chf, price_type FROM service_options WHERE active = true'),
pool.query('SELECT key, value FROM settings'),
pool.query('SELECT id, code, name, category, width_mm, shelves_count, drawers_count, has_rod, base_price_chf FROM modules WHERE active = 1'),
pool.query('SELECT id, code, name, style, price_chf FROM handle_variants WHERE active = 1'),
pool.query('SELECT id, code, name, type, price_chf FROM hinge_variants WHERE active = 1'),
pool.query('SELECT id, code, name, type, variant, price_chf, specs_json FROM blum_hardware WHERE active = 1')
]);
const settings = settingsRows.rows.map((r) => ({ key: r.key, value: r.value }));
// Use Formex materials if available, otherwise fall back to legacy finishes
let finishes = legacyFinishes.rows;
if (formexMaterials.rows.length > 0) {
// Fetch dimension variants for Formex materials
const materialsWithDims = await Promise.all(
formexMaterials.rows.map(async (mat) => {
const dims = await pool.query(
'SELECT id, width_mm, height_mm, thickness_mm, is_default FROM formex_dimensions WHERE material_id = ?',
[mat.id]
);
return {
...mat,
dimensions: dims.rows
};
})
);
finishes = materialsWithDims;
}
const data = {
settings,
blockTypes: blockTypes.rows,
finishes,
serviceOptions: serviceOptions.rows,
modules: modulesData.rows,
handles: handlesData.rows,
hinges: hingesData.rows,
blumHardware: blumData.rows
};
const result = calculatePrice(config, data);
res.json(result);
} catch (err) {
console.error('Price calculation error:', err);
res.status(500).json({ error: err.message });
}
}
+12
View File
@@ -0,0 +1,12 @@
import pool from '../db/index.js';
export async function getProducts(req, res) {
try {
const { rows } = await pool.query(
'SELECT id, code, label, active FROM products WHERE active = true ORDER BY id'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Room analysis API routes
* Handles image upload and OpenAI Vision analysis
*/
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { analyzeRoomPhoto, analysisTo3DConfig } from '../services/roomAnalyzer.js';
import pool, { run } from '../db/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Configure multer for image uploads
const storage = multer.memoryStorage();
const upload = multer({
storage,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB max
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Type de fichier non supporté. Utilisez JPEG, PNG ou WebP.'));
}
}
});
// Directory for storing uploaded room photos
const UPLOADS_DIR = path.join(__dirname, '..', '..', 'client', 'public', 'rooms');
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
/**
* Middleware for single image upload
*/
export const uploadMiddleware = upload.single('image');
/**
* POST /api/room/analyze
* Upload a room photo and analyze it with GPT-4 Vision
*/
export async function analyzeRoom(req, res) {
try {
if (!req.file) {
return res.status(400).json({ error: 'Aucune image fournie' });
}
// Convert buffer to base64
const imageBase64 = req.file.buffer.toString('base64');
const mimeType = req.file.mimetype;
// Analyze with OpenAI
console.log('Analyzing room photo with GPT-4 Vision...');
const result = await analyzeRoomPhoto(imageBase64, mimeType);
if (!result.success) {
return res.status(500).json({ error: 'Échec de l\'analyse' });
}
// Save the image for future reference
const filename = `room_${Date.now()}.${mimeType.split('/')[1]}`;
const filepath = path.join(UPLOADS_DIR, filename);
fs.writeFileSync(filepath, req.file.buffer);
// Convert to 3D config
const room3DConfig = analysisTo3DConfig(result.analysis);
res.json({
success: true,
imagePath: `/rooms/${filename}`,
analysis: result.analysis,
room3DConfig,
tokensUsed: result.tokensUsed
});
} catch (error) {
console.error('Room analysis error:', error);
res.status(500).json({
error: error.message || 'Erreur lors de l\'analyse de la pièce'
});
}
}
/**
* POST /api/room/save
* Save room configuration to an order
*/
export async function saveRoomConfig(req, res) {
try {
const { orderId, imagePath, analysis, room3DConfig } = req.body;
if (!analysis) {
return res.status(400).json({ error: 'Configuration de pièce requise' });
}
const analysisJson = JSON.stringify({ analysis, room3DConfig });
if (orderId) {
// Update existing order
await run(
`INSERT INTO room_configs (order_id, image_path, analysis_json)
VALUES (?, ?, ?)`,
[orderId, imagePath || null, analysisJson]
);
}
// Return the config ID for reference
const { rows } = await pool.query(
'SELECT last_insert_rowid() as id'
);
res.json({
success: true,
roomConfigId: rows[0]?.id || null
});
} catch (error) {
console.error('Save room config error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* GET /api/room/config/:orderId
* Get room configuration for an order
*/
export async function getRoomConfig(req, res) {
try {
const { orderId } = req.params;
const { rows } = await pool.query(
'SELECT * FROM room_configs WHERE order_id = ? ORDER BY created_at DESC LIMIT 1',
[orderId]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Configuration non trouvée' });
}
const config = rows[0];
const parsed = JSON.parse(config.analysis_json || '{}');
res.json({
id: config.id,
orderId: config.order_id,
imagePath: config.image_path,
analysis: parsed.analysis,
room3DConfig: parsed.room3DConfig,
createdAt: config.created_at
});
} catch (error) {
console.error('Get room config error:', error);
res.status(500).json({ error: error.message });
}
}
/**
* Check if OpenAI API is configured
*/
export function checkApiStatus(req, res) {
const isConfigured = !!process.env.OPENAI_API_KEY;
res.json({
openaiConfigured: isConfigured,
features: {
roomAnalysis: isConfigured
}
});
}
+12
View File
@@ -0,0 +1,12 @@
import pool from '../db/index.js';
export async function getServiceOptions(req, res) {
try {
const { rows } = await pool.query(
'SELECT id, code, label, price_chf, price_type FROM service_options WHERE active = true ORDER BY id'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+16
View File
@@ -0,0 +1,16 @@
import pool from '../db/index.js';
export async function getSettings(req, res) {
try {
const { rows } = await pool.query('SELECT key, value FROM settings');
const obj = {};
for (const r of rows) {
let v = r.value;
if (typeof v === 'string' && /^\d+$/.test(v)) v = Number(v);
obj[r.key] = v;
}
res.json(obj);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+348
View File
@@ -0,0 +1,348 @@
/**
* Sync routes for Formex crawler
*/
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
import { getSchedulerStatus } from '../services/scheduler.js';
import pool, { run } from '../db/index.js';
/**
* POST /api/sync/formex
* Trigger a manual Formex sync (non-streaming)
*/
export async function triggerFormexSync(req, res) {
try {
// Run sync (this may take a while)
const result = await sync();
if (result.success) {
res.json({
success: true,
message: `Synchronisation terminée: ${result.itemsCount} produits`,
itemsCount: result.itemsCount,
duration: result.duration
});
} else {
res.status(500).json({
success: false,
error: result.error,
itemsCount: result.itemsCount
});
}
} catch (err) {
res.status(500).json({
success: false,
error: err.message
});
}
}
/**
* GET /api/sync/formex/stream
* SSE endpoint for real-time sync progress
*/
export async function streamFormexSync(req, res) {
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
res.flushHeaders();
// Emitter function that sends SSE events
const emit = (data) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Handle client disconnect
req.on('close', () => {
// Client disconnected, cleanup if needed
});
try {
// Run sync with emitter
await sync(emit);
} catch (err) {
emit({ type: 'error', error: err.message });
}
// Close connection
res.end();
}
/**
* GET /api/sync/logs
* Get sync history
*/
export async function getLogs(req, res) {
try {
const limit = parseInt(req.query.limit || '20', 10);
const logs = await getSyncLogs(limit);
res.json(logs);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/sync/status
* Get scheduler status
*/
export function getStatus(req, res) {
const status = getSchedulerStatus();
res.json(status);
}
/**
* GET /api/formex/materials
* Get Formex materials with filtering and pagination
*/
export async function getFormexMaterials(req, res) {
try {
const { search, collection, category, active, page = '1', limit = '25' } = req.query;
const pageNum = Math.max(1, parseInt(page, 10));
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10)));
const offset = (pageNum - 1) * limitNum;
// Build query with optional filters
let whereClauses = [];
let params = [];
if (search) {
whereClauses.push('(name LIKE ? OR formex_code LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (collection) {
whereClauses.push('collection = ?');
params.push(collection);
}
if (category) {
whereClauses.push('category = ?');
params.push(category);
}
// For configurator: only active materials
if (active === '1' || active === 'true') {
whereClauses.push('active = 1');
}
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
// Get total count
const countResult = await pool.query(
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
params
);
const total = countResult.rows[0]?.total || 0;
// Get paginated results
const { rows } = await pool.query(
`SELECT * FROM formex_materials ${whereSQL} ORDER BY collection, name LIMIT ? OFFSET ?`,
[...params, limitNum, offset]
);
res.json({
materials: rows,
pagination: {
page: pageNum,
limit: limitNum,
total,
totalPages: Math.ceil(total / limitNum)
}
});
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/formex/materials/:id
* Get a single material by ID with dimensions and variants
*/
export async function getFormexMaterial(req, res) {
try {
const { id } = req.params;
// Get main material
const { rows } = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
const material = rows[0];
// Get dimension variants
const dimensions = await pool.query(
'SELECT id, width_mm, height_mm, thickness_mm, is_default FROM formex_dimensions WHERE material_id = ? ORDER BY is_default DESC, width_mm',
[id]
);
material.dimensions = dimensions.rows;
// Get color/decor variants
const variants = await pool.query(
'SELECT id, variant_code, variant_name, variant_url, image_url FROM formex_variants WHERE material_id = ? ORDER BY variant_name',
[id]
);
material.variants = variants.rows;
res.json(material);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* POST /api/formex/materials
* Create a new material
*/
export async function createFormexMaterial(req, res) {
try {
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
// Validation
if (!formex_code || !name || !collection) {
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
}
// Check for duplicate code
const existing = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[formex_code]
);
if (existing.rows.length > 0) {
return res.status(409).json({ error: 'Un matériau avec ce code existe déjà' });
}
await run(
`INSERT INTO formex_materials (formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1]
);
// Get the created material
const { rows } = await pool.query(
'SELECT * FROM formex_materials WHERE formex_code = ?',
[formex_code]
);
res.status(201).json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* PUT /api/formex/materials/:id
* Update a material
*/
export async function updateFormexMaterial(req, res) {
try {
const { id } = req.params;
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
// Check if material exists
const existing = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
// Validation
if (!formex_code || !name || !collection) {
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
}
// Check for duplicate code (if changed)
if (formex_code !== existing.rows[0].formex_code) {
const duplicate = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ? AND id != ?',
[formex_code, id]
);
if (duplicate.rows.length > 0) {
return res.status(409).json({ error: 'Un matériau avec ce code existe déjà' });
}
}
await run(
`UPDATE formex_materials
SET formex_code = ?, name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
image_url = ?, formex_url = ?, price_chf = ?, active = ?
WHERE id = ?`,
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1, id]
);
// Get the updated material
const { rows } = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
);
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* DELETE /api/formex/materials/:id
* Delete a material
*/
export async function deleteFormexMaterial(req, res) {
try {
const { id } = req.params;
// Check if material exists
const existing = await pool.query(
'SELECT * FROM formex_materials WHERE id = ?',
[id]
);
if (existing.rows.length === 0) {
return res.status(404).json({ error: 'Matériau non trouvé' });
}
await run('DELETE FROM formex_materials WHERE id = ?', [id]);
res.json({ success: true, message: 'Matériau supprimé' });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/formex/collections
* Get distinct collection names for filter dropdown
*/
export async function getFormexCollections(req, res) {
try {
const { rows } = await pool.query(
'SELECT DISTINCT collection FROM formex_materials ORDER BY collection'
);
res.json(rows.map(r => r.collection));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
/**
* GET /api/formex/categories
* Get distinct category names for filter dropdown
*/
export async function getFormexCategories(req, res) {
try {
const { rows } = await pool.query(
'SELECT DISTINCT category FROM formex_materials WHERE category IS NOT NULL ORDER BY category'
);
res.json(rows.map(r => r.category));
} catch (err) {
res.status(500).json({ error: err.message });
}
}
+343
View File
@@ -0,0 +1,343 @@
/**
* Cutting Engine - Generate cutting list (liste de débitage) for wardrobe production
* Calculates all panel dimensions based on configuration and color assignments
*/
// Panel thickness in mm
const PANEL_THICKNESS = 19;
const BACK_PANEL_THICKNESS = 8;
// Edge banding settings
const EDGE_BANDING = {
visible: 2, // 2mm visible edge banding
hidden: 0.5 // 0.5mm thin edge banding
};
/**
* Element dimensions calculator
* Returns the dimensions for each element type based on wardrobe config
*/
function getElementDimensions(elementType, config, modules = []) {
const { widthMm, heightMm, depthMm, plinthHeightMm, installationType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const innerWidth = widthMm - (PANEL_THICKNESS * 2);
const innerDepth = depthMm - PANEL_THICKNESS; // Back panel space
const isNiche = installationType === 'niche';
switch (elementType) {
case 'plintheBas':
return {
width: widthMm,
height: plinthHeightMm,
depth: depthMm,
quantity: 1,
description: 'Plinthe de base'
};
case 'bandeauHaut':
return {
width: widthMm,
height: PANEL_THICKNESS,
depth: depthMm,
quantity: 1,
description: 'Bandeau supérieur'
};
case 'dessus':
return {
width: innerWidth,
height: PANEL_THICKNESS,
depth: innerDepth,
quantity: 1,
description: 'Panneau du dessus'
};
case 'fond':
return {
width: innerWidth,
height: PANEL_THICKNESS,
depth: innerDepth,
quantity: 1,
description: 'Panneau du fond (bas)'
};
case 'cotes':
return {
width: PANEL_THICKNESS,
height: bodyHeight,
depth: depthMm,
quantity: 2,
description: 'Côtés extérieurs (gauche et droit)'
};
case 'cotesDoublage':
// Only for niche installation
if (!isNiche) return null;
return {
width: PANEL_THICKNESS / 2,
height: bodyHeight,
depth: depthMm,
quantity: 2,
description: 'Côtés de doublage (niche)'
};
case 'fileur':
// Only for niche installation
if (!isNiche) return null;
return {
width: PANEL_THICKNESS / 2,
height: bodyHeight,
depth: depthMm,
quantity: 2,
description: 'Fileurs (bouche-trou niche)'
};
case 'dos':
return {
width: innerWidth,
height: bodyHeight - (PANEL_THICKNESS * 2),
depth: BACK_PANEL_THICKNESS,
quantity: 1,
description: 'Dos (panneau arrière)'
};
case 'portes':
const doorCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
const isSliding = config.doorType === 'sliding';
if (isSliding) {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = Math.ceil(widthMm / panelCount) + 50; // overlap
return {
width: panelWidth,
height: bodyHeight - 20, // clearance
depth: PANEL_THICKNESS,
quantity: panelCount,
description: `Portes coulissantes (${panelCount} vantaux)`
};
} else {
// Hinged doors - one per module/block
return {
width: Math.floor(widthMm / doorCount) - 4, // 2mm gap each side
height: bodyHeight - 4,
depth: PANEL_THICKNESS,
quantity: doorCount,
description: `Portes battantes (${doorCount} portes)`
};
}
case 'panneauxInterieurs':
// Vertical separators between modules
const separatorCount = modules.length > 1
? modules.length - 1
: (config.blocks?.length || 1) - 1;
if (separatorCount <= 0) return null;
return {
width: PANEL_THICKNESS,
height: bodyHeight - (PANEL_THICKNESS * 2),
depth: innerDepth,
quantity: separatorCount,
description: `Séparateurs verticaux (${separatorCount})`
};
case 'etageres':
// Count shelves from modules
const totalShelves = modules.reduce((sum, m) => sum + (m.shelves || 0), 0);
if (totalShelves <= 0) return null;
// Average shelf width based on modules
const avgModuleWidth = modules.length > 0
? modules.reduce((sum, m) => sum + (m.widthMm || 0), 0) / modules.length
: innerWidth;
return {
width: avgModuleWidth - (PANEL_THICKNESS * 2),
height: PANEL_THICKNESS,
depth: innerDepth - 20, // setback from front
quantity: totalShelves,
description: `Étagères (${totalShelves})`
};
case 'champs':
// Edge banding - calculated as linear meters
// Include all visible edges
const perimeter = (widthMm * 2) + (heightMm * 4) + (bodyHeight * 4);
return {
width: EDGE_BANDING.visible,
height: perimeter,
depth: 0,
quantity: 1,
unit: 'ml',
description: 'Chant plaqué visible'
};
case 'caisse':
// The carcass/body - this is the assembled unit, not a cut piece
return null;
default:
return null;
}
}
/**
* Get edge banding requirements for an element
*/
function getEdgeBanding(elementType) {
const fullEdges = ['portes', 'cotes', 'dessus', 'plintheBas', 'bandeauHaut'];
const partialEdges = ['etageres', 'panneauxInterieurs', 'fond'];
if (fullEdges.includes(elementType)) {
return { type: 'visible', sides: 4 };
} else if (partialEdges.includes(elementType)) {
return { type: 'visible', sides: 1 }; // front edge only
} else if (elementType === 'dos') {
return { type: 'none', sides: 0 };
}
return { type: 'hidden', sides: 2 };
}
/**
* Generate complete cutting list for a wardrobe configuration
* @param {Object} config - Wardrobe configuration
* @param {Object} colors - Color/finish assignments per element
* @param {Array} modules - Module configurations (optional)
* @returns {Object} Cutting list with pieces grouped by finish
*/
export function generateCuttingList(config, colors = {}, modules = []) {
const pieces = [];
// All element types to process
const elementTypes = [
'plintheBas',
'bandeauHaut',
'dessus',
'fond',
'cotes',
'cotesDoublage',
'fileur',
'dos',
'portes',
'panneauxInterieurs',
'etageres'
];
for (const elementType of elementTypes) {
const dimensions = getElementDimensions(elementType, config, modules);
if (!dimensions) continue;
const finish = colors[elementType] || { finishId: null, label: 'Non défini' };
const edgeBanding = getEdgeBanding(elementType);
pieces.push({
id: `piece-${elementType}`,
elementType,
description: dimensions.description,
finishId: finish.finishId,
finishLabel: finish.label,
widthMm: Math.round(dimensions.width),
heightMm: Math.round(dimensions.height),
thicknessMm: dimensions.depth <= PANEL_THICKNESS ? dimensions.depth : PANEL_THICKNESS,
quantity: dimensions.quantity,
unit: dimensions.unit || 'pcs',
edgeBanding: edgeBanding,
surfaceM2: calculateSurface(dimensions.width, dimensions.height, dimensions.quantity)
});
}
// Calculate totals by finish
const byFinish = {};
for (const piece of pieces) {
const key = piece.finishLabel || 'Non défini';
if (!byFinish[key]) {
byFinish[key] = {
finishId: piece.finishId,
finishLabel: key,
pieces: [],
totalSurfaceM2: 0,
pieceCount: 0
};
}
byFinish[key].pieces.push(piece);
byFinish[key].totalSurfaceM2 += piece.surfaceM2;
byFinish[key].pieceCount += piece.quantity;
}
// Calculate edge banding totals
const edgeBandingMl = pieces.reduce((total, piece) => {
if (piece.edgeBanding.type !== 'none') {
const perimeter = (piece.widthMm + piece.heightMm) * 2 * piece.quantity / 1000; // convert to meters
return total + (perimeter * piece.edgeBanding.sides / 4);
}
return total;
}, 0);
return {
pieces,
byFinish: Object.values(byFinish),
summary: {
totalPieces: pieces.reduce((sum, p) => sum + p.quantity, 0),
totalSurfaceM2: pieces.reduce((sum, p) => sum + p.surfaceM2, 0),
edgeBandingMl: Math.round(edgeBandingMl * 10) / 10,
finishCount: Object.keys(byFinish).length
},
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
installationType: config.installationType,
doorType: config.doorType
}
};
}
/**
* Calculate surface area in m²
*/
function calculateSurface(widthMm, heightMm, quantity = 1) {
return Math.round((widthMm * heightMm * quantity) / 1000000 * 1000) / 1000;
}
/**
* Format cutting list for display or export
*/
export function formatCuttingListForExport(cuttingList) {
const lines = [];
lines.push('LISTE DE DÉBITAGE');
lines.push('=================');
lines.push('');
lines.push(`Configuration: ${cuttingList.config.widthMm}mm x ${cuttingList.config.heightMm}mm x ${cuttingList.config.depthMm}mm`);
lines.push(`Type: ${cuttingList.config.installationType === 'niche' ? 'Encastrée' : 'Libre'}`);
lines.push(`Portes: ${cuttingList.config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}`);
lines.push('');
lines.push('RÉSUMÉ');
lines.push('------');
lines.push(`Nombre total de pièces: ${cuttingList.summary.totalPieces}`);
lines.push(`Surface totale: ${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²`);
lines.push(`Chant plaqué: ${cuttingList.summary.edgeBandingMl} ml`);
lines.push(`Finitions différentes: ${cuttingList.summary.finishCount}`);
lines.push('');
for (const group of cuttingList.byFinish) {
lines.push(`FINITION: ${group.finishLabel}`);
lines.push('-'.repeat(40));
lines.push('Pièce | Dim. (LxH) | Ép. | Qté | Surface');
for (const piece of group.pieces) {
lines.push(
`${piece.description.padEnd(30)} | ${piece.widthMm}x${piece.heightMm} | ${piece.thicknessMm}mm | x${piece.quantity} | ${piece.surfaceM2.toFixed(3)}m²`
);
}
lines.push(`Sous-total: ${group.pieceCount} pièces, ${group.totalSurfaceM2.toFixed(2)} m²`);
lines.push('');
}
return lines.join('\n');
}
export default { generateCuttingList, formatCuttingListForExport };
+213
View File
@@ -0,0 +1,213 @@
/**
* AI Design Advisor — GPT-4o powered design tips, material recommendations, and DALL-E 3 renders
* Helps customers feel confident about their wardrobe design choices.
*/
import OpenAI from 'openai';
import crypto from 'crypto';
let openaiClient = null;
function getOpenAIClient() {
if (!openaiClient) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
}
openaiClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
return openaiClient;
}
/* -------------------------------------------------------------------------- */
/* 1. Design Tips (GPT-4o text) */
/* -------------------------------------------------------------------------- */
const DESIGN_TIP_PROMPT = `Tu es un designer d'intérieur suisse spécialisé dans les armoires sur mesure haut de gamme.
À partir de la configuration ci-dessous, donne UN SEUL conseil ou commentaire positif en 1-2 phrases maximum.
Le ton doit être chaleureux, professionnel et encourageant — le client doit se sentir confiant dans son choix.
Varie tes réponses : parfois un compliment sur la combinaison, parfois un conseil pratique, parfois une info sur les tendances.
Réponds UNIQUEMENT en JSON :
{ "tip": "…votre conseil en français…", "category": "compliment" | "conseil" | "tendance" }`;
/**
* Generate a design tip for the current configuration
* @param {object} config - { width, height, depth, columns (array of {type, shelves, drawers, hasRod}), material, installationType }
* @returns {{ tip: string, category: string }}
*/
export async function getDesignTip(config) {
const client = getOpenAIClient();
const configSummary = buildConfigSummary(config);
const response = await client.chat.completions.create({
model: 'gpt-4o',
temperature: 0.9,
max_tokens: 200,
messages: [
{ role: 'system', content: DESIGN_TIP_PROMPT },
{ role: 'user', content: configSummary }
],
response_format: { type: 'json_object' }
});
const raw = response.choices[0]?.message?.content;
try {
return JSON.parse(raw);
} catch {
return { tip: raw, category: 'conseil' };
}
}
/* -------------------------------------------------------------------------- */
/* 2. Material Recommendations (GPT-4o) */
/* -------------------------------------------------------------------------- */
const MATERIAL_REC_PROMPT = `Tu es un conseiller en matériaux et couleurs pour armoires sur mesure suisses haut de gamme.
À partir de la configuration actuelle et de la liste de matériaux disponibles, recommande 3 à 5 matériaux qui iraient parfaitement ensemble.
Critères de recommandation :
- Harmonie des couleurs avec les matériaux déjà sélectionnés
- Cohérence avec le style d'armoire (dimensions, configuration)
- Tendances actuelles en design d'intérieur suisse
- Si un contexte de pièce est fourni, s'accorder avec le style de la pièce
Réponds UNIQUEMENT en JSON :
{
"recommendations": [
{ "finishId": <number>, "reason": "…raison courte en français…" }
]
}`;
/**
* Recommend materials that complement the current configuration
* @param {object} config - Current wardrobe configuration
* @param {Array} availableFinishes - [{ id, name, collection, code }]
* @param {object} [roomAnalysis] - Optional room photo analysis
* @returns {{ recommendations: Array<{ finishId: number, reason: string }> }}
*/
export async function recommendMaterials(config, availableFinishes, roomAnalysis) {
const client = getOpenAIClient();
const configSummary = buildConfigSummary(config);
// Compact finish list (only names + ids to save tokens)
const finishList = availableFinishes
.slice(0, 80) // limit to avoid token overflow
.map(f => `${f.id}: ${f.name || f.label} (${f.collection || '?'})`)
.join('\n');
let userMessage = `CONFIGURATION ACTUELLE:\n${configSummary}\n\nMATÉRIAUX DISPONIBLES:\n${finishList}`;
if (roomAnalysis) {
userMessage += `\n\nCONTEXTE PIÈCE:\n${JSON.stringify(roomAnalysis, null, 1)}`;
}
const response = await client.chat.completions.create({
model: 'gpt-4o',
temperature: 0.7,
max_tokens: 400,
messages: [
{ role: 'system', content: MATERIAL_REC_PROMPT },
{ role: 'user', content: userMessage }
],
response_format: { type: 'json_object' }
});
const raw = response.choices[0]?.message?.content;
try {
const parsed = JSON.parse(raw);
return { recommendations: (parsed.recommendations || []).slice(0, 5) };
} catch {
return { recommendations: [] };
}
}
/* -------------------------------------------------------------------------- */
/* 3. Photorealistic Render (DALL-E 3) */
/* -------------------------------------------------------------------------- */
// Simple in-memory render cache (config hash → image URL)
const renderCache = new Map();
/**
* Generate a photorealistic render of the configured wardrobe using DALL-E 3
* @param {object} config - Current wardrobe configuration
* @param {object} [roomContext] - Optional room analysis for context
* @returns {{ imageUrl: string, cached: boolean }}
*/
export async function generateRender(config, roomContext) {
const client = getOpenAIClient();
// Cache by config hash
const hash = crypto.createHash('md5').update(JSON.stringify(config)).digest('hex');
if (renderCache.has(hash)) {
return { imageUrl: renderCache.get(hash), cached: true };
}
const prompt = buildRenderPrompt(config, roomContext);
const response = await client.images.generate({
model: 'dall-e-3',
prompt,
n: 1,
size: '1792x1024',
quality: 'hd',
style: 'natural'
});
const imageUrl = response.data[0]?.url;
if (imageUrl) {
renderCache.set(hash, imageUrl);
// Auto-evict after 30 minutes (DALL-E URLs expire after ~1h)
setTimeout(() => renderCache.delete(hash), 30 * 60 * 1000);
}
return { imageUrl, cached: false };
}
/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */
function buildConfigSummary(config) {
const columns = (config.columns || config.modules || [])
.map((c, i) => ` Colonne ${i + 1}: ${c.type || c.moduleCode || '?'} — ${c.width || '?'}mm, ${c.shelves || 0} étagères, ${c.drawers || 0} tiroirs${c.hasRod ? ', tringle' : ''}`)
.join('\n');
return `Armoire sur mesure SIGNATURE
Dimensions: ${config.width || '?'}mm × ${config.height || '?'}mm × ${config.depth || '?'}mm
Installation: ${config.installationType || 'libre'}
Colonnes:\n${columns}
Matériau principal: ${config.materialName || config.finishLabel || 'non défini'}
Portes: ${config.doorType || 'battantes'}`;
}
function buildRenderPrompt(config, roomContext) {
const cols = (config.columns || config.modules || []);
const colDesc = cols.map((c, i) => {
const type = c.type || c.moduleCode || 'standard';
const parts = [];
if (c.hasRod) parts.push('a hanging rod');
if (c.shelves > 0) parts.push(`${c.shelves} shelves`);
if (c.drawers > 0) parts.push(`${c.drawers} drawers`);
return `column ${i + 1}: ${type} with ${parts.join(', ') || 'empty space'}`;
}).join('; ');
const widthM = ((config.width || 2400) / 1000).toFixed(1);
const heightM = ((config.height || 2400) / 1000).toFixed(1);
let roomDesc = 'a modern, well-lit bedroom with neutral walls';
if (roomContext) {
const walls = roomContext.walls || [];
const wallColors = walls.filter(w => w.color).map(w => w.color).join(', ');
roomDesc = `a ${roomContext.roomType || 'bedroom'} with ${wallColors || 'neutral'} walls`;
}
return `Professional interior design photograph of a premium Swiss-made custom wardrobe in ${roomDesc}.
The wardrobe is ${widthM}m wide and ${heightM}m tall, built-in style, with ${cols.length} columns: ${colDesc}.
Material: ${config.materialName || 'light oak wood finish'}. Doors are ${config.doorType === 'coulissantes' ? 'sliding' : 'hinged'}, partially open to reveal the interior.
Style: clean Scandinavian-Swiss aesthetic, warm natural lighting, photorealistic quality. Shot with a wide-angle lens at eye level.
The wardrobe should look premium, well-crafted, and perfectly integrated into the room.`;
}
+638
View File
@@ -0,0 +1,638 @@
/**
* Formex.ch web crawler service
* Scrapes panel/material data from the Formex catalog
*/
import * as cheerio from 'cheerio';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import pool, { run } from '../db/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASE_URL = 'https://www.formex.ch';
const ASSORTIMENT_URL = `${BASE_URL}/fr/assortiment`;
const CRAWL_DELAY = parseInt(process.env.FORMEX_CRAWL_DELAY_MS || '500', 10);
// Directory for storing downloaded material images
const IMAGES_DIR = path.join(__dirname, '..', '..', 'client', 'public', 'materials');
// Ensure images directory exists
if (!fs.existsSync(IMAGES_DIR)) {
fs.mkdirSync(IMAGES_DIR, { recursive: true });
}
/**
* Sleep helper for rate limiting
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Fetch a URL with error handling
*/
async function fetchPage(url) {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ConfigurateurArmoires/1.0)',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'fr-CH,fr;q=0.9'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
}
/**
* Download an image and save it locally
* @param {string} url - Image URL to download
* @param {string} filename - Local filename (e.g., "12345.jpg")
* @returns {string|null} - Local path or null if failed
*/
async function downloadImage(url, filename) {
try {
if (!url) return null;
// Ensure absolute URL
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
const response = await fetch(fullUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; ConfigurateurArmoires/1.0)',
'Accept': 'image/*'
}
});
if (!response.ok) {
console.warn(`Failed to download image ${fullUrl}: ${response.status}`);
return null;
}
const contentType = response.headers.get('content-type') || '';
let ext = '.jpg';
if (contentType.includes('png')) ext = '.png';
else if (contentType.includes('webp')) ext = '.webp';
// Ensure filename has correct extension
const finalFilename = filename.replace(/\.[^.]+$/, '') + ext;
const filepath = path.join(IMAGES_DIR, finalFilename);
const buffer = Buffer.from(await response.arrayBuffer());
fs.writeFileSync(filepath, buffer);
// Return the public path (relative to client/public)
return `/materials/${finalFilename}`;
} catch (err) {
console.warn(`Error downloading image ${url}:`, err.message);
return null;
}
}
/**
* Extract collection URLs from the assortiment page
*/
async function getCollections() {
const html = await fetchPage(ASSORTIMENT_URL);
const $ = cheerio.load(html);
const collections = [];
// Find collection links in the "Collections" section
$('a[href*="/fr/shop/"]').each((_, el) => {
const href = $(el).attr('href');
const name = $(el).text().trim();
// Only include category pages (contain -c followed by numbers)
if (href && href.match(/-c\d+$/) && name) {
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
// Avoid duplicates
if (!collections.find(c => c.url === fullUrl)) {
collections.push({ name, url: fullUrl });
}
}
});
return collections;
}
/**
* Parse dimensions from a string like "2800mm x 2070mm x 19mm"
*/
function parseDimensions(dimStr) {
if (!dimStr) return { width: null, height: null, thickness: null };
const matches = dimStr.match(/(\d+)\s*mm\s*x\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm/i);
if (matches) {
return {
width: parseInt(matches[1], 10),
height: parseInt(matches[2], 10),
thickness: parseInt(matches[3], 10)
};
}
return { width: null, height: null, thickness: null };
}
/**
* Extract decor code from product name or page content
* e.g., "Panneaux mélaminés Duplex Plus 10000 Tilia" -> "10000"
*/
function extractDecorCode(text) {
// Look for a 4-5 digit code
const match = text.match(/\b(\d{4,5})\b/);
return match ? match[1] : null;
}
/**
* Map of common color keywords found in Formex product names to representative hex colors
*/
const COLOR_KEYWORDS = {
// Whites and off-whites
'blanc': '#f5f5f5', 'white': '#f5f5f5', 'weiss': '#f5f5f5', 'bianco': '#f5f5f5',
'crème': '#fffdd0', 'cream': '#fffdd0', 'ivoire': '#fffff0', 'ivory': '#fffff0',
'magnolia': '#f8f4f0', 'perle': '#eae0c8',
// Greys
'gris': '#909090', 'grey': '#909090', 'gray': '#909090', 'grau': '#909090',
'anthracite': '#383838', 'anthrazit': '#383838', 'graphite': '#4a4a4a',
'platine': '#e5e4e2', 'argent': '#c0c0c0', 'silver': '#c0c0c0',
'titane': '#878681',
// Blacks
'noir': '#1a1a1a', 'black': '#1a1a1a', 'schwarz': '#1a1a1a',
// Browns and woods
'chêne': '#b5895b', 'oak': '#b5895b', 'eiche': '#b5895b',
'noyer': '#6f4e37', 'walnut': '#6f4e37', 'nussbaum': '#6f4e37',
'hêtre': '#c9a46c', 'beech': '#c9a46c', 'buche': '#c9a46c',
'érable': '#d4b572', 'maple': '#d4b572', 'ahorn': '#d4b572',
'cerisier': '#9c3b17', 'cherry': '#9c3b17', 'kirsch': '#9c3b17',
'teck': '#b08444', 'teak': '#b08444',
'pin': '#dcc8a0', 'pine': '#dcc8a0',
'orme': '#a07850', 'elm': '#a07850',
'bouleau': '#e0c99c', 'birch': '#e0c99c',
'frêne': '#c8b88a', 'ash': '#c8b88a',
'acacia': '#b89244',
'wenge': '#4a3728', 'wengé': '#4a3728',
'mélèze': '#c4a06c', 'larch': '#c4a06c',
'aulne': '#b07c4c', 'alder': '#b07c4c',
'brun': '#8b4513', 'brown': '#8b4513', 'braun': '#8b4513',
'marron': '#6d3e1c', 'chocolat': '#4a2c14',
'cognac': '#9f381d', 'caramel': '#a67040',
'noisette': '#ab7746',
// Beiges and sandstones
'beige': '#d9c8a9', 'sable': '#c2b280', 'sand': '#c2b280',
'sahara': '#c4a661', 'champagne': '#f7e7ce',
'vanille': '#f3e5ab', 'vanilla': '#f3e5ab',
'cappuccino': '#a0785a', 'moka': '#6f4e37',
// Reds
'rouge': '#cc3333', 'red': '#cc3333', 'rot': '#cc3333',
'bordeaux': '#722f37', 'burgundy': '#722f37',
'terracotta': '#c65d3e', 'rouille': '#b7410e',
// Blues
'bleu': '#4169e1', 'blue': '#4169e1', 'blau': '#4169e1',
'marine': '#000080', 'navy': '#000080',
'ciel': '#87ceeb', 'azur': '#007fff',
'cobalt': '#0047ab', 'indigo': '#4b0082',
// Greens
'vert': '#4caf50', 'green': '#4caf50', 'grün': '#4caf50',
'olive': '#808000', 'sauge': '#9caf88', 'sage': '#9caf88',
'emeraude': '#50c878', 'mousse': '#738276',
// Yellows and golds
'jaune': '#f0c040', 'yellow': '#f0c040',
'or': '#d4af37', 'gold': '#d4af37',
'miel': '#cd8032', 'honey': '#cd8032',
// Stone and concrete
'béton': '#a0a0a0', 'beton': '#a0a0a0', 'concrete': '#a0a0a0',
'pierre': '#b0a898', 'stone': '#b0a898', 'stein': '#b0a898',
'ardoise': '#5a5a5a', 'slate': '#5a5a5a',
'marbre': '#e0d8d0', 'marble': '#e0d8d0',
'granit': '#808080', 'granite': '#808080',
'travertin': '#d1c6b0',
// Others
'rouille': '#b7410e', 'rust': '#b7410e',
'cuivre': '#b87333', 'copper': '#b87333',
'linen': '#e0d5c0', 'lin': '#e0d5c0',
'taupe': '#8b7d6b',
'mauve': '#e0b0ff', 'lilas': '#c8a2c8',
'rose': '#ffc0cb', 'pink': '#ffc0cb', 'rosa': '#ffc0cb'
};
/**
* Extract dominant color from product name by matching color keywords
* @param {string} name - Product name
* @returns {string|null} - Hex color code or null
*/
function extractDominantColor(name) {
if (!name) return null;
const lower = name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const nameLower = name.toLowerCase();
// Try exact keyword matching (longest match first for compound words)
const sortedKeywords = Object.entries(COLOR_KEYWORDS)
.sort((a, b) => b[0].length - a[0].length);
for (const [keyword, hex] of sortedKeywords) {
// Check the raw name (preserving accents) and the normalized name
if (nameLower.includes(keyword) || lower.includes(keyword.normalize('NFD').replace(/[\u0300-\u036f]/g, ''))) {
return hex;
}
}
return null;
}
/**
* Get product details from a product detail page
* Enhanced to capture category, images, dimension variants, and color variants
*/
async function getProductDetails(productUrl) {
try {
const html = await fetchPage(productUrl);
const $ = cheerio.load(html);
// Get product name from h1
const name = $('h1').first().text().trim();
// Get decor code - look for "N° du décor:" or extract from name
let decorCode = null;
$('*').each((_, el) => {
const text = $(el).text();
const match = text.match(/N°\s*du\s*décor\s*:\s*(\d+)/i);
if (match) {
decorCode = match[1];
return false; // break
}
});
if (!decorCode) {
decorCode = extractDecorCode(name);
}
// Get category from breadcrumb (second-to-last item usually)
let category = null;
const breadcrumbLinks = $('a[href*="/fr/shop/"]').filter((_, el) => {
const href = $(el).attr('href') || '';
return href.match(/-c\d+$/); // Collection links
});
if (breadcrumbLinks.length > 0) {
category = $(breadcrumbLinks.last()).text().trim();
}
// Get main product image
let imageUrl = null;
// Look for product image in various selectors
$('img').each((_, el) => {
const src = $(el).attr('src') || '';
if (src.includes('/dekorbilder/') || src.includes('/sortiment/')) {
imageUrl = src.startsWith('http') ? src : `${BASE_URL}${src}`;
return false;
}
});
// Try data-src for lazy-loaded images
if (!imageUrl) {
$('img[data-src]').each((_, el) => {
const src = $(el).attr('data-src') || '';
if (src.includes('/dekorbilder/') || src.includes('/sortiment/')) {
imageUrl = src.startsWith('http') ? src : `${BASE_URL}${src}`;
return false;
}
});
}
// Get ALL dimension variants (multiple formats like "3650mm x 600mm x 29mm")
const dimensionVariants = [];
const seenDimensions = new Set();
$('a, button, span, div').each((_, el) => {
const text = $(el).text().trim();
const match = text.match(/^(\d+)\s*mm\s*x\s*(\d+)\s*mm\s*x\s*(\d+)\s*mm$/i);
if (match) {
const key = `${match[1]}-${match[2]}-${match[3]}`;
if (!seenDimensions.has(key)) {
seenDimensions.add(key);
dimensionVariants.push({
width: parseInt(match[1], 10),
height: parseInt(match[2], 10),
thickness: parseInt(match[3], 10)
});
}
}
});
// Default dimensions (first variant or from legacy parsing)
let dimensions = dimensionVariants[0] || { width: null, height: null, thickness: null };
if (!dimensions.width) {
$('a, span, div').each((_, el) => {
const text = $(el).text();
if (text.match(/\d+\s*mm\s*x\s*\d+\s*mm\s*x\s*\d+\s*mm/i)) {
dimensions = parseDimensions(text);
return false;
}
});
}
// Get color/decor variants (from "Choisir la couleur" section)
const colorVariants = [];
$('a[href*="/fr/shop/"]').each((_, el) => {
const href = $(el).attr('href') || '';
// Look for product links that are variants (not the current product)
if (href.match(/-p\d+$/) && !href.includes(productUrl.split('/').pop())) {
const variantUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
const variantName = $(el).attr('title') || $(el).text().trim();
const variantCode = extractDecorCode(variantName) || extractDecorCode(href);
// Look for variant thumbnail image
let variantImage = null;
const img = $(el).find('img').first();
if (img.length) {
variantImage = img.attr('src') || img.attr('data-src');
if (variantImage && !variantImage.startsWith('http')) {
variantImage = `${BASE_URL}${variantImage}`;
}
}
if (variantCode && variantName && !colorVariants.find(v => v.code === variantCode)) {
colorVariants.push({
code: variantCode,
name: variantName,
url: variantUrl,
imageUrl: variantImage
});
}
}
});
// Download main product image locally
let localImageUrl = null;
if (imageUrl && decorCode) {
localImageUrl = await downloadImage(imageUrl, `${decorCode}.jpg`);
}
// Download variant thumbnails
for (const variant of colorVariants) {
if (variant.imageUrl && variant.code) {
const localPath = await downloadImage(variant.imageUrl, `${variant.code}_thumb.jpg`);
if (localPath) {
variant.imageUrl = localPath;
}
}
}
// Extract a representative dominant color from the product name
const dominantColor = extractDominantColor(name);
return {
name,
decorCode,
dimensions,
dimensionVariants,
colorVariants,
category,
imageUrl: localImageUrl || imageUrl, // Prefer local path, fallback to URL
dominantColor
};
} catch (err) {
console.error(`Error fetching product details from ${productUrl}:`, err.message);
return null;
}
}
/**
* Get products from a collection page
* @param {string} collectionUrl
* @param {string} collectionName
* @param {Function} emit - Optional emitter for progress events
*/
async function getProductsFromCollection(collectionUrl, collectionName, emit = () => {}) {
const products = [];
const html = await fetchPage(collectionUrl);
const $ = cheerio.load(html);
// Find product links
const productLinks = new Set();
$('a[href*="/fr/shop/"]').each((_, el) => {
const href = $(el).attr('href');
// Product pages contain -p followed by numbers
if (href && href.match(/-p\d+$/)) {
const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`;
productLinks.add(fullUrl);
}
});
const productCount = productLinks.size;
console.log(` Found ${productCount} products in ${collectionName}`);
emit({ type: 'log', message: ` ${productCount} produits trouvés dans ${collectionName}` });
// Fetch each product's details
let productIndex = 0;
for (const productUrl of productLinks) {
await sleep(CRAWL_DELAY);
const details = await getProductDetails(productUrl);
productIndex++;
if (details && details.decorCode) {
products.push({
formex_code: details.decorCode,
name: details.name,
collection: collectionName,
category: details.category || collectionName,
width_mm: details.dimensions.width,
height_mm: details.dimensions.height,
thickness_mm: details.dimensions.thickness,
image_url: details.imageUrl,
formex_url: productUrl,
dominant_color: details.dominantColor,
dimensionVariants: details.dimensionVariants || [],
colorVariants: details.colorVariants || []
});
const dimCount = details.dimensionVariants?.length || 0;
const varCount = details.colorVariants?.length || 0;
emit({ type: 'log', message: ` [${productIndex}/${productCount}] ${details.name} (${dimCount} dims, ${varCount} variants)` });
}
}
return products;
}
/**
* Upsert a material into the database with enhanced data
*/
async function upsertMaterial(material) {
// Try to update first
const { rows } = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[material.formex_code]
);
let materialId;
if (rows.length > 0) {
materialId = rows[0].id;
// Update existing
await run(
`UPDATE formex_materials
SET name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
image_url = ?, formex_url = ?, dominant_color = COALESCE(?, dominant_color), last_synced_at = datetime('now')
WHERE formex_code = ?`,
[material.name, material.collection, material.category || null,
material.width_mm, material.height_mm, material.thickness_mm,
material.image_url || null, material.formex_url, material.dominant_color || null, material.formex_code]
);
} else {
// Insert new
await run(
`INSERT INTO formex_materials
(formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, dominant_color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[material.formex_code, material.name, material.collection, material.category || null,
material.width_mm, material.height_mm, material.thickness_mm,
material.image_url || null, material.formex_url, material.dominant_color || null]
);
// Get the inserted ID
const inserted = await pool.query(
'SELECT id FROM formex_materials WHERE formex_code = ?',
[material.formex_code]
);
materialId = inserted.rows[0]?.id;
}
// Update dimension variants if we have a materialId
if (materialId && material.dimensionVariants && material.dimensionVariants.length > 0) {
// Delete existing dimensions
await run('DELETE FROM formex_dimensions WHERE material_id = ?', [materialId]);
// Insert new dimensions
for (let i = 0; i < material.dimensionVariants.length; i++) {
const dim = material.dimensionVariants[i];
await run(
`INSERT INTO formex_dimensions (material_id, width_mm, height_mm, thickness_mm, is_default)
VALUES (?, ?, ?, ?, ?)`,
[materialId, dim.width, dim.height, dim.thickness, i === 0 ? 1 : 0]
);
}
}
// Update color variants if we have a materialId
if (materialId && material.colorVariants && material.colorVariants.length > 0) {
// Delete existing variants
await run('DELETE FROM formex_variants WHERE material_id = ?', [materialId]);
// Insert new variants
for (const variant of material.colorVariants) {
await run(
`INSERT INTO formex_variants (material_id, variant_code, variant_name, variant_url, image_url)
VALUES (?, ?, ?, ?, ?)`,
[materialId, variant.code, variant.name, variant.url || null, variant.imageUrl || null]
);
}
}
return materialId;
}
/**
* Log sync result to database
*/
async function logSync(source, status, itemsCount, error = null) {
await run(
`INSERT INTO sync_logs (source, status, items_count, error) VALUES (?, ?, ?, ?)`,
[source, status, itemsCount, error]
);
}
/**
* Main sync function - crawls Formex and updates database
* @param {Function} emit - Optional emitter callback for SSE progress events
* Called with { type: 'log'|'progress'|'complete'|'error', ... }
*/
export async function sync(emit = () => {}) {
console.log('Starting Formex sync...');
emit({ type: 'log', message: 'Démarrage de la synchronisation Formex...' });
const startTime = Date.now();
let totalProducts = 0;
try {
// Get all collections
console.log('Fetching collections...');
emit({ type: 'log', message: 'Récupération des collections...' });
const collections = await getCollections();
const totalCollections = collections.length;
console.log(`Found ${totalCollections} collections`);
emit({ type: 'log', message: `${totalCollections} collections trouvées` });
emit({ type: 'progress', current: 0, total: totalCollections, collection: '' });
// Process each collection
for (let i = 0; i < collections.length; i++) {
const collection = collections[i];
console.log(`Processing: ${collection.name}`);
emit({ type: 'log', message: `Traitement: ${collection.name}` });
emit({ type: 'progress', current: i, total: totalCollections, collection: collection.name });
await sleep(CRAWL_DELAY);
try {
const products = await getProductsFromCollection(collection.url, collection.name, emit);
// Upsert each product
for (const product of products) {
await upsertMaterial(product);
totalProducts++;
}
emit({ type: 'log', message: ` ${products.length} produits enregistrés pour ${collection.name}` });
} catch (err) {
console.error(` Error processing ${collection.name}:`, err.message);
emit({ type: 'log', message: ` Erreur: ${err.message}` });
}
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`Sync complete: ${totalProducts} products in ${elapsed}s`);
await logSync('formex', 'success', totalProducts);
const result = {
success: true,
itemsCount: totalProducts,
duration: elapsed
};
emit({ type: 'progress', current: totalCollections, total: totalCollections, collection: '' });
emit({ type: 'complete', result });
return result;
} catch (err) {
console.error('Sync failed:', err.message);
await logSync('formex', 'error', totalProducts, err.message);
const result = {
success: false,
error: err.message,
itemsCount: totalProducts
};
emit({ type: 'error', error: err.message });
return result;
}
}
/**
* Get sync logs from database
*/
export async function getSyncLogs(limit = 20) {
const { rows } = await pool.query(
'SELECT * FROM sync_logs ORDER BY created_at DESC LIMIT ?',
[limit]
);
return rows;
}
/**
* Get all scraped materials
*/
export async function getMaterials() {
const { rows } = await pool.query(
'SELECT * FROM formex_materials ORDER BY collection, name'
);
return rows;
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Layout Optimizer using OpenAI GPT-4
* Proposes optimal module arrangement within available width,
* respecting locked modules and optimizing for functionality and aesthetics
*/
import OpenAI from 'openai';
// Lazy initialization to avoid errors when API key is not set
let openaiClient = null;
function getOpenAIClient() {
if (!openaiClient) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
}
openaiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
}
return openaiClient;
}
/**
* Prompt template for layout optimization
*/
const LAYOUT_OPTIMIZATION_PROMPT = `Tu es un expert en aménagement d'armoires sur mesure avec 20 ans d'expérience.
Propose un layout optimal pour les modules dans l'espace disponible.
CONTRAINTES:
- Largeur totale disponible: {totalWidth}mm
- Hauteur armoire: {height}mm
- Profondeur: {depth}mm
- Type d'installation: {installationType}
MODULES DISPONIBLES DANS LE CATALOGUE:
{availableModules}
MODULES ACTUELLEMENT PLACÉS:
{currentModules}
MODULES FIGÉS (ne pas déplacer, garder leur position exacte):
{lockedModules}
RÈGLES D'OPTIMISATION:
1. La somme des largeurs des modules doit égaler exactement la largeur totale
2. Les modules figés (locked) doivent rester à leur position exacte
3. Favoriser l'équilibre visuel (penderies aux extrémités, tiroirs au centre)
4. Regrouper les éléments de même type si possible
5. Pour une penderie principale, prévoir au moins 600mm de largeur
6. Les tiroirs sont plus pratiques à hauteur accessible (partie basse)
7. Laisser au moins un module rayonnage pour le linge plié
Retourne un JSON valide avec cette structure:
{
"layout": [
{
"moduleCode": "<code du module du catalogue>",
"moduleId": "<id unique du module dans la config, ou null si nouveau>",
"position": <index de position 0, 1, 2...>,
"widthMm": <largeur en mm>,
"locked": <true si figé, false sinon>,
"customizations": {
"shelves": <nombre d'étagères si personnalisable>,
"drawers": <nombre de tiroirs si applicable>,
"hasRod": <true/false pour tringle penderie>
}
}
],
"totalWidth": <somme des largeurs en mm>,
"reasoning": "<Explication en français du choix d'agencement>",
"alternatives": [
{
"description": "<description courte>",
"layout": [<même structure que layout principal>]
}
],
"warnings": ["<avertissements éventuels>"]
}
IMPORTANT: Retourne UNIQUEMENT du JSON valide, sans texte supplémentaire.`;
/**
* Build the prompt with actual data
*/
function buildPrompt(config, availableModules, lockedModuleIds = []) {
const currentModules = config.modules || [];
const lockedModules = currentModules.filter(m => lockedModuleIds.includes(m.id) || m.locked);
const unlockedModules = currentModules.filter(m => !lockedModuleIds.includes(m.id) && !m.locked);
const modulesCatalog = availableModules.map(m =>
`- ${m.code}: ${m.name} (${m.category}, largeur: ${m.width_mm}mm, ${m.shelves_count} étagères, ${m.drawers_count} tiroirs, penderie: ${m.has_rod ? 'oui' : 'non'}, prix: ${m.base_price_chf} CHF)`
).join('\n');
const currentModulesStr = currentModules.map((m, i) =>
`Position ${i}: ${m.moduleCode || 'module'} - ${m.widthMm}mm${m.locked ? ' [FIGÉ]' : ''}`
).join('\n') || 'Aucun module placé';
const lockedModulesStr = lockedModules.map((m, i) =>
`Position ${currentModules.indexOf(m)}: ${m.moduleCode || 'module'} - ${m.widthMm}mm`
).join('\n') || 'Aucun module figé';
return LAYOUT_OPTIMIZATION_PROMPT
.replace('{totalWidth}', config.widthMm || 2400)
.replace('{height}', config.heightMm || 2400)
.replace('{depth}', config.depthMm || 600)
.replace('{installationType}', config.installationType || 'free')
.replace('{availableModules}', modulesCatalog)
.replace('{currentModules}', currentModulesStr)
.replace('{lockedModules}', lockedModulesStr);
}
/**
* Optimize layout using GPT-4
* @param {Object} config - Current wardrobe configuration
* @param {Array} availableModules - List of available modules from catalog
* @param {Array} lockedModuleIds - IDs of modules that should not be moved
* @returns {Object} Optimized layout proposal
*/
export async function optimizeLayout(config, availableModules, lockedModuleIds = []) {
const openai = getOpenAIClient();
const prompt = buildPrompt(config, availableModules, lockedModuleIds);
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: prompt
}
],
max_tokens: 2000,
response_format: { type: 'json_object' }
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
const result = JSON.parse(content);
// Validate response structure
if (!result.layout || !Array.isArray(result.layout)) {
throw new Error('Invalid layout response structure');
}
// Validate total width matches
const totalLayoutWidth = result.layout.reduce((sum, m) => sum + (m.widthMm || 0), 0);
if (Math.abs(totalLayoutWidth - (config.widthMm || 2400)) > 10) {
result.warnings = result.warnings || [];
result.warnings.push(`La largeur totale (${totalLayoutWidth}mm) ne correspond pas exactement à la largeur demandée (${config.widthMm}mm)`);
}
return {
success: true,
...result,
tokensUsed: response.usage?.total_tokens || 0
};
} catch (error) {
console.error('Layout optimization error:', error);
if (error.code === 'invalid_api_key') {
throw new Error('Invalid OpenAI API key');
}
if (error.code === 'rate_limit_exceeded') {
throw new Error('API rate limit exceeded. Please try again later.');
}
throw error;
}
}
/**
* Quick local optimization without AI (for simple cases)
* Distributes remaining space among unlocked modules
* @param {Object} config - Current configuration
* @param {Array} lockedModuleIds - IDs of locked modules
* @returns {Object} Optimized layout
*/
export function quickOptimize(config, lockedModuleIds = []) {
const modules = [...(config.modules || [])];
const totalWidth = config.widthMm || 2400;
// Calculate locked width
const lockedWidth = modules
.filter(m => lockedModuleIds.includes(m.id) || m.locked)
.reduce((sum, m) => sum + (m.widthMm || 0), 0);
// Get unlocked modules
const unlockedModules = modules.filter(m => !lockedModuleIds.includes(m.id) && !m.locked);
const remainingWidth = totalWidth - lockedWidth;
if (unlockedModules.length === 0) {
return { success: true, layout: modules, reasoning: 'Tous les modules sont figés' };
}
// Distribute remaining width equally among unlocked modules
const widthPerModule = Math.floor(remainingWidth / unlockedModules.length);
const remainder = remainingWidth - (widthPerModule * unlockedModules.length);
let extraAdded = 0;
const optimizedModules = modules.map(m => {
if (lockedModuleIds.includes(m.id) || m.locked) {
return m;
}
const extra = extraAdded < remainder ? 1 : 0;
extraAdded += extra;
return {
...m,
widthMm: widthPerModule + extra
};
});
return {
success: true,
layout: optimizedModules,
totalWidth: totalWidth,
reasoning: 'Répartition équitable de l\'espace entre les modules non-figés'
};
}
/**
* Suggest initial layout for empty configuration
* @param {number} totalWidth - Total available width in mm
* @param {Array} availableModules - Available module catalog
* @returns {Array} Suggested initial modules
*/
export function suggestInitialLayout(totalWidth, availableModules) {
const suggestions = [];
let remainingWidth = totalWidth;
// Standard layout: penderie - rayonnage - penderie for typical wardrobe
const penderie = availableModules.find(m => m.category === 'penderie' && m.width_mm <= remainingWidth / 3);
const rayonnage = availableModules.find(m => m.category === 'rayonnage');
if (penderie && rayonnage) {
// Two penderies on sides
const penderieWidth = Math.min(penderie.width_mm, Math.floor(totalWidth * 0.3));
const rayonnageWidth = totalWidth - (penderieWidth * 2);
suggestions.push({
moduleCode: penderie.code,
moduleTypeId: penderie.id,
widthMm: penderieWidth,
position: 0,
locked: false,
shelves: penderie.shelves_count,
drawers: 0,
hasRod: true
});
suggestions.push({
moduleCode: rayonnage.code,
moduleTypeId: rayonnage.id,
widthMm: rayonnageWidth,
position: 1,
locked: false,
shelves: rayonnage.shelves_count,
drawers: 0,
hasRod: false
});
suggestions.push({
moduleCode: penderie.code,
moduleTypeId: penderie.id,
widthMm: penderieWidth,
position: 2,
locked: false,
shelves: penderie.shelves_count,
drawers: 0,
hasRod: true
});
}
return suggestions;
}
export default { optimizeLayout, quickOptimize, suggestInitialLayout };
+358
View File
@@ -0,0 +1,358 @@
/**
* Plan Generator - Generate 2D assembly and parts plans in SVG format
* Also handles PDF export
*/
// Constants for drawing
const DRAWING_SCALE = 0.1; // 1mm = 0.1px
const MARGIN = 50;
const LABEL_OFFSET = 15;
const ARROW_SIZE = 8;
/**
* Generate SVG for assembly view (front elevation)
*/
export function generateAssemblyPlan(config, modules = []) {
const { widthMm, heightMm, plinthHeightMm, doorType, installationType } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.inner { fill: none; stroke: #666; stroke-width: 0.5; }
.dimension { fill: none; stroke: #0066cc; stroke-width: 0.5; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
.label { font-family: Arial, sans-serif; font-size: 9px; fill: #333; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.door { fill: #d4c4a4; stroke: #333; stroke-width: 1; opacity: 0.8; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
</style>`;
// Title
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de face - Élévation</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${widthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Module separators
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length - 1; i++) {
currentX += modules[i].widthMm * DRAWING_SCALE;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${plinthY}" class="separator" />`;
}
}
// Doors
if (doorType === 'sliding') {
const panelCount = config.slidingPanelsCount || 2;
const panelWidth = (widthMm / panelCount) * DRAWING_SCALE;
for (let i = 0; i < panelCount; i++) {
svg += `<rect x="${offsetX + i * panelWidth * 0.95 + 5}" y="${offsetY + 5}" width="${panelWidth * 0.9}" height="${(bodyHeight - 10) * DRAWING_SCALE}" class="door" />`;
}
} else {
const blockCount = modules.length > 0 ? modules.length : (config.blocks?.length || 2);
let currentX = offsetX;
const items = modules.length > 0 ? modules : (config.blocks || []);
for (let i = 0; i < blockCount; i++) {
const blockWidth = items[i]?.widthMm || (widthMm / blockCount);
svg += `<rect x="${currentX + 3}" y="${offsetY + 3}" width="${blockWidth * DRAWING_SCALE - 6}" height="${(bodyHeight - 6) * DRAWING_SCALE}" class="door" />`;
currentX += blockWidth * DRAWING_SCALE;
}
}
// Dimension lines
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, widthMm * DRAWING_SCALE, `${widthMm} mm`, 'horizontal');
svg += generateDimensionLine(offsetX - 20, offsetY, bodyHeight * DRAWING_SCALE, `${bodyHeight} mm`, 'vertical');
svg += generateDimensionLine(offsetX - 40, offsetY, heightMm * DRAWING_SCALE, `${heightMm} mm (total)`, 'vertical');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for side view
*/
export function generateSidePlan(config) {
const { heightMm, depthMm, plinthHeightMm } = config;
const bodyHeight = heightMm - plinthHeightMm;
const svgWidth = depthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = heightMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.plinth { fill: #e0d0c0; stroke: #333; stroke-width: 1; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.dim-text { font-family: Arial, sans-serif; font-size: 10px; fill: #0066cc; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de côté</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Plinth
const plinthY = offsetY + bodyHeight * DRAWING_SCALE;
svg += `<rect x="${offsetX}" y="${plinthY}" width="${depthMm * DRAWING_SCALE}" height="${plinthHeightMm * DRAWING_SCALE}" class="plinth" />`;
// Body
svg += `<rect x="${offsetX}" y="${offsetY}" width="${depthMm * DRAWING_SCALE}" height="${bodyHeight * DRAWING_SCALE}" class="body" />`;
// Dimension
svg += generateDimensionLine(offsetX, plinthY + plinthHeightMm * DRAWING_SCALE + 20, depthMm * DRAWING_SCALE, `${depthMm} mm`, 'horizontal');
svg += '</svg>';
return svg;
}
/**
* Generate SVG for top view
*/
export function generateTopPlan(config, modules = []) {
const { widthMm, depthMm } = config;
const svgWidth = widthMm * DRAWING_SCALE + MARGIN * 2;
const svgHeight = depthMm * DRAWING_SCALE + MARGIN * 2;
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svgWidth} ${svgHeight}" width="${svgWidth}" height="${svgHeight}">`;
svg += `<style>
.outline { fill: none; stroke: #333; stroke-width: 1.5; }
.body { fill: #f5e6d3; stroke: #333; stroke-width: 1; }
.separator { stroke: #666; stroke-width: 0.5; stroke-dasharray: 4,2; }
.title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #333; }
.label { font-family: Arial, sans-serif; font-size: 8px; fill: #666; }
</style>`;
svg += `<text x="${svgWidth / 2}" y="25" class="title" text-anchor="middle">Vue de dessus</text>`;
const offsetX = MARGIN;
const offsetY = MARGIN;
// Body outline
svg += `<rect x="${offsetX}" y="${offsetY}" width="${widthMm * DRAWING_SCALE}" height="${depthMm * DRAWING_SCALE}" class="body" />`;
// Module separators and labels
if (modules.length > 0) {
let currentX = offsetX;
for (let i = 0; i < modules.length; i++) {
const module = modules[i];
const moduleWidth = module.widthMm * DRAWING_SCALE;
// Label
svg += `<text x="${currentX + moduleWidth / 2}" y="${offsetY + depthMm * DRAWING_SCALE / 2}" class="label" text-anchor="middle">${module.moduleCode || `M${i + 1}`}</text>`;
// Separator
if (i < modules.length - 1) {
currentX += moduleWidth;
svg += `<line x1="${currentX}" y1="${offsetY}" x2="${currentX}" y2="${offsetY + depthMm * DRAWING_SCALE}" class="separator" />`;
}
}
}
svg += '</svg>';
return svg;
}
/**
* Generate a dimension line with arrows and text
*/
function generateDimensionLine(x, y, length, label, direction = 'horizontal') {
let svg = '';
if (direction === 'horizontal') {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x + length}" y2="${y}" class="dimension" />`;
// Arrows
svg += `<polygon points="${x},${y} ${x + ARROW_SIZE},${y - 3} ${x + ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
svg += `<polygon points="${x + length},${y} ${x + length - ARROW_SIZE},${y - 3} ${x + length - ARROW_SIZE},${y + 3}" fill="#0066cc" />`;
// Label
svg += `<text x="${x + length / 2}" y="${y + LABEL_OFFSET}" class="dim-text" text-anchor="middle">${label}</text>`;
} else {
// Line
svg += `<line x1="${x}" y1="${y}" x2="${x}" y2="${y + length}" class="dimension" />`;
// Arrows
svg += `<polygon points="${x},${y} ${x - 3},${y + ARROW_SIZE} ${x + 3},${y + ARROW_SIZE}" fill="#0066cc" />`;
svg += `<polygon points="${x},${y + length} ${x - 3},${y + length - ARROW_SIZE} ${x + 3},${y + length - ARROW_SIZE}" fill="#0066cc" />`;
// Label (rotated)
svg += `<text x="${x - LABEL_OFFSET}" y="${y + length / 2}" class="dim-text" text-anchor="middle" transform="rotate(-90 ${x - LABEL_OFFSET} ${y + length / 2})">${label}</text>`;
}
return svg;
}
/**
* Generate all plans for a configuration
*/
export function generateAllPlans(config, modules = [], colors = {}) {
return {
assembly: generateAssemblyPlan(config, modules),
side: generateSidePlan(config),
top: generateTopPlan(config, modules),
metadata: {
generatedAt: new Date().toISOString(),
config: {
widthMm: config.widthMm,
heightMm: config.heightMm,
depthMm: config.depthMm,
installationType: config.installationType,
doorType: config.doorType,
moduleCount: modules.length
}
}
};
}
/**
* Generate HTML page with all plans for PDF export
*/
export function generatePDFHtml(config, modules = [], cuttingList = null) {
const plans = generateAllPlans(config, modules);
let html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Plans Armoire</title>
<style>
@page { size: A4; margin: 15mm; }
body { font-family: Arial, sans-serif; font-size: 11px; }
.page { page-break-after: always; padding: 20px; }
.header { border-bottom: 2px solid #333; padding-bottom: 10px; margin-bottom: 20px; }
.title { font-size: 20px; font-weight: bold; margin: 0; }
.subtitle { font-size: 12px; color: #666; margin: 5px 0 0; }
.plan-container { margin: 20px 0; text-align: center; }
.plan-title { font-size: 14px; font-weight: bold; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
th { background: #f5f5f5; font-weight: bold; }
.summary { background: #f0f7ff; padding: 15px; border-radius: 8px; margin: 20px 0; }
.summary h3 { margin: 0 0 10px; }
.specs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.spec-item { display: flex; justify-content: space-between; }
</style>
</head>
<body>
<div class="page">
<div class="header">
<h1 class="title">Plans de fabrication - Armoire sur mesure</h1>
<p class="subtitle">Généré le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}</p>
</div>
<div class="summary">
<h3>Spécifications</h3>
<div class="specs">
<div class="spec-item"><span>Largeur:</span><strong>${config.widthMm} mm</strong></div>
<div class="spec-item"><span>Hauteur:</span><strong>${config.heightMm} mm</strong></div>
<div class="spec-item"><span>Profondeur:</span><strong>${config.depthMm} mm</strong></div>
<div class="spec-item"><span>Plinthe:</span><strong>${config.plinthHeightMm} mm</strong></div>
<div class="spec-item"><span>Type:</span><strong>${config.installationType === 'niche' ? 'Encastrée' : 'Libre'}</strong></div>
<div class="spec-item"><span>Portes:</span><strong>${config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'}</strong></div>
</div>
</div>
<div class="plan-container">
<div class="plan-title">Vue de face (Élévation)</div>
${plans.assembly}
</div>
</div>
<div class="page">
<div class="plan-container">
<div class="plan-title">Vue de côté</div>
${plans.side}
</div>
<div class="plan-container">
<div class="plan-title">Vue de dessus</div>
${plans.top}
</div>
</div>`;
// Cutting list page
if (cuttingList) {
html += `
<div class="page">
<h2>Liste de débitage</h2>
<div class="summary">
<h3>Résumé</h3>
<div class="specs">
<div class="spec-item"><span>Nombre de pièces:</span><strong>${cuttingList.summary.totalPieces}</strong></div>
<div class="spec-item"><span>Surface totale:</span><strong>${cuttingList.summary.totalSurfaceM2.toFixed(2)} m²</strong></div>
<div class="spec-item"><span>Chant plaqué:</span><strong>${cuttingList.summary.edgeBandingMl} ml</strong></div>
<div class="spec-item"><span>Finitions:</span><strong>${cuttingList.summary.finishCount}</strong></div>
</div>
</div>`;
for (const group of cuttingList.byFinish) {
html += `
<h3>Finition: ${group.finishLabel}</h3>
<table>
<thead>
<tr>
<th>Description</th>
<th>Largeur</th>
<th>Hauteur</th>
<th>Ép.</th>
<th>Qté</th>
<th>Surface</th>
</tr>
</thead>
<tbody>`;
for (const piece of group.pieces) {
html += `
<tr>
<td>${piece.description}</td>
<td>${piece.widthMm} mm</td>
<td>${piece.heightMm} mm</td>
<td>${piece.thicknessMm} mm</td>
<td>${piece.quantity}</td>
<td>${piece.surfaceM2.toFixed(3)} m²</td>
</tr>`;
}
html += `
</tbody>
<tfoot>
<tr>
<th colspan="4">Sous-total</th>
<th>${group.pieceCount}</th>
<th>${group.totalSurfaceM2.toFixed(2)} m²</th>
</tr>
</tfoot>
</table>`;
}
html += `
</div>`;
}
html += `
</body>
</html>`;
return html;
}
export default {
generateAssemblyPlan,
generateSidePlan,
generateTopPlan,
generateAllPlans,
generatePDFHtml
};
+422
View File
@@ -0,0 +1,422 @@
/**
* Moteur de calcul de prix — configurateur armoires sur mesure.
* Règles : panneaux entiers (chute intégrée), blocs, kit, options.
*/
/**
* Calcule la surface utile des éléments visibles (portes, fileurs, bandeau, plinthe, côtés doublage).
* Épaisseur panneaux 19 mm (référence).
* @param {Object} config - configuration (installation, width, height, depth, plinthHeightMm, blocks, doorType, slidingPanels)
* @param {number} panelThicknessMm - épaisseur panneaux (19)
*/
function calculateVisibleSurface(config, panelThicknessMm = 19) {
const { installationType, widthMm, heightMm, depthMm, plinthHeightMm, blocks = [], doorType, slidingPanelsCount } = config;
const isNiche = installationType === 'niche';
let surface = 0;
// Portes (surface face visible)
if (doorType === 'sliding' && slidingPanelsCount) {
const panelWidth = widthMm / slidingPanelsCount;
surface += slidingPanelsCount * (panelWidth * (heightMm - plinthHeightMm)) * 2; // deux faces par vantail
} else {
const doorsCount = blocks.length <= 1 ? Math.min(2, Math.ceil((blocks[0]?.widthMm || widthMm) / 600)) : blocks.length * 2;
const doorWidth = widthMm / Math.max(1, doorsCount);
surface += doorsCount * (doorWidth * (heightMm - plinthHeightMm)) * 2;
}
// Plinthe (face avant)
surface += widthMm * plinthHeightMm;
// Bandeau et fileurs si niche
if (isNiche) {
surface += widthMm * plinthHeightMm; // bandeau haut
surface += 2 * plinthHeightMm * (heightMm - plinthHeightMm); // fileurs gauche/droite
}
// Côtés de doublage (coulissant)
if (doorType === 'sliding') {
surface += 2 * (depthMm * (heightMm - plinthHeightMm));
}
return surface / 1_000_000; // m²
}
/**
* Nombre de panneaux entiers nécessaires (arrondi supérieur).
* Coût = nombre_panneaux × prix_panneau.
*/
function panelCost(surfaceM2, panelWidthMm, panelHeightMm, pricePerPanelChf) {
const panelSurfaceM2 = (panelWidthMm * panelHeightMm) / 1_000_000;
const count = Math.ceil(surfaceM2 / panelSurfaceM2);
return { panels: Math.max(0, count), costChf: count * pricePerPanelChf };
}
/**
* Find the optimal dimension variant that minimizes cost (fewest panels needed).
* @param {number} surfaceM2 - Required surface in square meters
* @param {Array} dimensions - Array of dimension variants [{width_mm, height_mm, thickness_mm, id}]
* @param {number} pricePerPanel - Price per panel in CHF
* @returns {Object} - Optimal dimension with cost details and alternatives
*/
function findOptimalDimension(surfaceM2, dimensions, pricePerPanel) {
if (!dimensions || dimensions.length === 0) {
return null;
}
const price = pricePerPanel || 0;
const results = [];
for (const dim of dimensions) {
if (!dim.width_mm || !dim.height_mm) continue;
const panelSurfaceM2 = (dim.width_mm * dim.height_mm) / 1_000_000;
const panelsNeeded = Math.ceil(surfaceM2 / panelSurfaceM2);
const totalCost = panelsNeeded * price;
const totalSurface = panelsNeeded * panelSurfaceM2;
const wasteM2 = totalSurface - surfaceM2;
const wastePercent = (wasteM2 / totalSurface) * 100;
results.push({
dimension: {
id: dim.id,
width_mm: dim.width_mm,
height_mm: dim.height_mm,
thickness_mm: dim.thickness_mm
},
panelsNeeded,
costChf: totalCost,
panelSurfaceM2,
wasteM2: Math.round(wasteM2 * 1000) / 1000,
wastePercent: Math.round(wastePercent * 10) / 10
});
}
// Sort by cost (lowest first), then by waste (lowest first)
results.sort((a, b) => {
if (a.costChf !== b.costChf) return a.costChf - b.costChf;
return a.wasteM2 - b.wasteM2;
});
if (results.length === 0) return null;
const optimal = results[0];
const alternatives = results.slice(1);
return {
selectedDimension: optimal.dimension,
panelsNeeded: optimal.panelsNeeded,
costChf: optimal.costChf,
wasteM2: optimal.wasteM2,
wastePercent: optimal.wastePercent,
alternatives: alternatives.map(alt => ({
dimension: alt.dimension,
panelsNeeded: alt.panelsNeeded,
costChf: alt.costChf,
wasteM2: alt.wasteM2,
wastePercent: alt.wastePercent
}))
};
}
/**
* Calculate module costs (V2 mode)
* @param {Array} modules - Array of module configurations
* @param {Array} moduleCatalog - Available modules from database
* @returns {Object} - Module costs breakdown
*/
function calculateModuleCosts(modules, moduleCatalog) {
let totalChf = 0;
const breakdown = [];
for (const module of modules) {
const catalogModule = moduleCatalog.find(m => m.id === module.moduleTypeId || m.code === module.moduleCode);
const basePrice = catalogModule?.base_price_chf || 450;
// Adjust price based on customizations
let adjustedPrice = basePrice;
// Extra shelves cost (beyond default)
const defaultShelves = catalogModule?.shelves_count || 0;
const extraShelves = Math.max(0, (module.shelves || 0) - defaultShelves);
adjustedPrice += extraShelves * 25; // 25 CHF per extra shelf
// Extra drawers cost (beyond default)
const defaultDrawers = catalogModule?.drawers_count || 0;
const extraDrawers = Math.max(0, (module.drawers || 0) - defaultDrawers);
adjustedPrice += extraDrawers * 45; // 45 CHF per extra drawer
// Width adjustment (if different from default)
const defaultWidth = catalogModule?.width_mm || 600;
const widthDiff = (module.widthMm || defaultWidth) - defaultWidth;
if (widthDiff > 0) {
adjustedPrice += widthDiff * 0.15; // 0.15 CHF per extra mm
}
breakdown.push({
moduleCode: module.moduleCode || catalogModule?.code,
moduleName: catalogModule?.name || 'Module',
basePrice,
adjustedPrice: Math.round(adjustedPrice * 100) / 100,
adjustments: {
extraShelves,
extraDrawers,
widthDiff
}
});
totalChf += adjustedPrice;
}
return {
totalChf: Math.round(totalChf * 100) / 100,
breakdown
};
}
/**
* Calculate hardware costs (V2)
* @param {Object} hardware - Hardware selection {handleVariantId, hingeVariantId, drawerSystemId}
* @param {Object} hardwareData - Hardware data from DB {handles, hinges, blum}
* @param {Object} config - Config with door and drawer counts
* @returns {Object} - Hardware costs breakdown
*/
function calculateHardwareCosts(hardware, hardwareData, config) {
const { handles = [], hinges = [], blum = [] } = hardwareData;
const breakdown = [];
let totalChf = 0;
// Handle costs - one per door
if (hardware.handleVariantId) {
const handle = handles.find(h => h.id === hardware.handleVariantId);
if (handle) {
const doorCount = config.doorType === 'sliding'
? (config.slidingPanelsCount || 2)
: (config.modules?.length || config.blocks?.length || 2);
const cost = handle.price_chf * doorCount;
totalChf += cost;
breakdown.push({
type: 'handle',
name: handle.name,
unitPrice: handle.price_chf,
quantity: doorCount,
totalChf: cost
});
}
}
// Hinge costs - typically 3 per hinged door
if (hardware.hingeVariantId && config.doorType !== 'sliding') {
const hinge = hinges.find(h => h.id === hardware.hingeVariantId);
if (hinge) {
const doorCount = config.modules?.length || config.blocks?.length || 2;
const hingesPerDoor = 3;
const quantity = doorCount * hingesPerDoor;
const cost = hinge.price_chf * quantity;
totalChf += cost;
breakdown.push({
type: 'hinge',
name: hinge.name,
unitPrice: hinge.price_chf,
quantity,
totalChf: cost
});
}
}
// Drawer system costs - one per drawer
if (hardware.drawerSystemId) {
const system = blum.find(h => h.id === hardware.drawerSystemId && h.type === 'drawer_system');
if (system) {
const modules = config.modules || [];
const drawerCount = modules.reduce((sum, m) => sum + (m.drawers || 0), 0);
if (drawerCount > 0) {
const cost = system.price_chf * drawerCount;
totalChf += cost;
breakdown.push({
type: 'drawer_system',
name: system.name,
unitPrice: system.price_chf,
quantity: drawerCount,
totalChf: cost
});
}
}
}
return {
totalChf: Math.round(totalChf * 100) / 100,
breakdown
};
}
/**
* Valide la configuration et calcule le prix total.
* @param {Object} config - configuration client
* @param {Object} data - données DB (settings, blockTypes, finishes, serviceOptions, modules, hardware)
*/
export function calculatePrice(config, data) {
const errors = [];
const settingsArr = data.settings || [];
const settings = {};
for (const r of settingsArr) {
let v = r.value;
if (typeof v === 'string' && /^-?\d+(\.\d+)?$/.test(v)) v = Number(v);
settings[r.key] = v;
}
const kitFeeChf = Number(settings.kit_preparation_fee_chf) ?? 350;
const plinthMin = Number(settings.plinth_height_min_mm) ?? 60;
const plinthMax = Number(settings.plinth_height_max_mm) ?? 160;
const slidingMinWidth = Number(settings.sliding_doors_min_width_mm) ?? 1200;
const {
installationType, widthMm, heightMm, depthMm, plinthHeightMm,
blocks = [], modules = [], hardware = {},
doorType, slidingPanelsCount, finishId, serviceOptionIds = []
} = config;
// Determine if using modules (V2) or blocks (legacy)
const useModules = modules && modules.length > 0;
// Plinthe 60–160 mm
if (plinthHeightMm < plinthMin || plinthHeightMm > plinthMax) {
errors.push({ field: 'plinthHeightMm', message: `La hauteur de plinthe doit être entre ${plinthMin} et ${plinthMax} mm.` });
}
// Portes coulissantes : largeur totale >= 1200 mm
if (doorType === 'sliding' && widthMm < slidingMinWidth) {
errors.push({ field: 'doorType', message: `Les portes coulissantes ne sont possibles qu'à partir de ${slidingMinWidth} mm de largeur.` });
}
if (doorType === 'sliding' && (slidingPanelsCount < 2 || slidingPanelsCount > 3)) {
errors.push({ field: 'slidingPanelsCount', message: 'Choisissez 2 ou 3 vantaux coulissants.' });
}
// Calculate block/module costs
let blocksChf = 0;
let modulesBreakdown = null;
if (useModules) {
// V2: Use module-based pricing
const moduleCatalog = data.modules || [];
const moduleCosts = calculateModuleCosts(modules, moduleCatalog);
blocksChf = moduleCosts.totalChf;
modulesBreakdown = moduleCosts.breakdown;
// Validate module widths
const totalModuleWidth = modules.reduce((sum, m) => sum + (m.widthMm || 0), 0);
if (Math.abs(totalModuleWidth - widthMm) > 10) {
errors.push({ field: 'modules', message: `La somme des largeurs des modules (${totalModuleWidth}mm) doit correspondre à la largeur totale (${widthMm}mm).` });
}
} else {
// Legacy: Use block-based pricing
const totalWidth = blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0);
if (Math.abs(totalWidth - widthMm) > 5 && blocks.length > 0) {
errors.push({ field: 'blocks', message: 'La somme des largeurs des blocs doit correspondre à la largeur totale.' });
}
const blockTypes = data.blockTypes || [];
for (const block of blocks) {
const bt = blockTypes.find((b) => b.id === block.blockTypeId || b.code === block.blockTypeCode);
if (bt) blocksChf += Number(bt.base_price_chf) || 550;
else blocksChf += 550;
}
}
// Calculate hardware costs (V2)
let hardwareChf = 0;
let hardwareBreakdown = null;
if (hardware && (hardware.handleVariantId || hardware.hingeVariantId || hardware.drawerSystemId)) {
const hardwareData = {
handles: data.handles || [],
hinges: data.hinges || [],
blum: data.blumHardware || []
};
const hardwareCosts = calculateHardwareCosts(hardware, hardwareData, config);
hardwareChf = hardwareCosts.totalChf;
hardwareBreakdown = hardwareCosts.breakdown;
}
let panelsChf = 0;
let panelOptimization = null;
const surfaceM2 = calculateVisibleSurface({
...config,
widthMm: widthMm || blocks.reduce((sum, b) => sum + (b.widthMm || 0), 0),
plinthHeightMm: plinthHeightMm || 100,
blocks: useModules ? modules.map(m => ({ widthMm: m.widthMm })) : blocks,
doorType: doorType || 'hinged',
slidingPanelsCount: slidingPanelsCount || 2
});
// Find finish/material by ID or code
const finish = (data.finishes || []).find((f) => f.id === finishId || f.code === config.finishCode);
if (finish) {
const priceChf = finish.price_chf;
// Check if we have multiple dimension variants for optimization
if (finish.dimensions && finish.dimensions.length > 0 && priceChf != null) {
// Auto-optimize: find the best dimension that minimizes cost
panelOptimization = findOptimalDimension(surfaceM2, finish.dimensions, Number(priceChf));
if (panelOptimization) {
panelsChf = panelOptimization.costChf;
}
} else if (finish.width_mm && finish.height_mm && priceChf != null) {
// Legacy: single dimension from finish
const result = panelCost(surfaceM2, finish.width_mm, finish.height_mm, Number(priceChf));
panelsChf = result.costChf;
// Create optimization info for legacy finishes
const panelSurfaceM2 = (finish.width_mm * finish.height_mm) / 1_000_000;
const wasteM2 = (result.panels * panelSurfaceM2) - surfaceM2;
panelOptimization = {
selectedDimension: {
width_mm: finish.width_mm,
height_mm: finish.height_mm,
thickness_mm: null
},
panelsNeeded: result.panels,
costChf: result.costChf,
wasteM2: Math.round(wasteM2 * 1000) / 1000,
wastePercent: Math.round((wasteM2 / (result.panels * panelSurfaceM2)) * 1000) / 10,
alternatives: []
};
}
}
let optionsChf = 0;
const services = data.serviceOptions || [];
for (const optId of serviceOptionIds) {
const opt = services.find((s) => s.id === optId || s.code === optId);
if (opt) optionsChf += Number(opt.price_chf) || 0;
}
const totalChf = blocksChf + kitFeeChf + panelsChf + optionsChf + hardwareChf;
return {
totalChf: Math.round(totalChf * 100) / 100,
surfaceM2: Math.round(surfaceM2 * 1000) / 1000,
details: {
blocksChf,
modulesBreakdown,
kitChf: kitFeeChf,
panelsChf,
optionsChf,
hardwareChf,
hardwareBreakdown,
panelOptimization
},
errors: errors.length ? errors : undefined
};
}
export default {
calculatePrice,
calculateVisibleSurface,
panelCost,
findOptimalDimension,
calculateModuleCosts,
calculateHardwareCosts
};
+245
View File
@@ -0,0 +1,245 @@
/**
* Room Photo Analyzer using OpenAI GPT-4 Vision
* Analyzes room photos to extract dimensions, detect doors/windows, and suggest wardrobe placement
*/
import OpenAI from 'openai';
// Lazy initialization to avoid errors when API key is not set
let openaiClient = null;
function getOpenAIClient() {
if (!openaiClient) {
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not configured. Add it to server/.env');
}
openaiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
}
return openaiClient;
}
/**
* Prompt for GPT-4 Vision to analyze room photos
*/
const ROOM_ANALYSIS_PROMPT = `You are a professional interior designer and architect specializing in custom wardrobe installations. Analyze this room photo with a STRONG FOCUS on the visible wall surfaces (parois) and ceiling (plafond).
PRIORITY: Carefully examine every visible wall and the ceiling. Detect their exact colors, materials, and textures as they appear in the photo. This is critical for accurate 3D room reconstruction.
Return a JSON object with the following structure:
{
"roomDimensions": {
"widthM": <estimated width in meters, e.g., 4.2>,
"lengthM": <estimated length/depth in meters, e.g., 3.8>,
"heightM": <estimated ceiling height in meters, typically 2.4-2.7>
},
"walls": [
{
"side": "back" | "left" | "right",
"visible": <boolean - is this wall clearly visible in the photo?>,
"color": "<EXACT hex color code sampled from the wall surface, e.g., #f5f5f0>",
"material": "paint" | "wallpaper" | "brick" | "wood_panel" | "concrete" | "plaster" | "stone" | "tile",
"texture": "smooth" | "textured" | "patterned" | "rough" | "glossy",
"hasWindow": <boolean>,
"hasDoor": <boolean>,
"surfaceArea": <approximate visible surface area percentage 0-100, accounting for windows/doors>
}
],
"windows": [
{
"wall": "back" | "left" | "right",
"xPercent": <horizontal position from left edge, 0-100>,
"yPercent": <vertical position from floor, 0-100>,
"widthPercent": <width as percentage of wall, e.g., 25>,
"heightPercent": <height as percentage of wall, e.g., 40>,
"type": "window" | "french_window" | "skylight" | "bay_window"
}
],
"doors": [
{
"wall": "back" | "left" | "right",
"xPercent": <horizontal position from left edge, 0-100>,
"widthPercent": <width as percentage of wall, typically 15-25>,
"heightPercent": <height as percentage of wall, typically 80-90>,
"type": "door" | "double_door" | "opening" | "closet" | "sliding_door"
}
],
"floor": {
"color": "<exact hex color code>",
"material": "wood" | "tile" | "carpet" | "concrete" | "laminate" | "vinyl" | "parquet"
},
"ceiling": {
"visible": <boolean - is the ceiling visible in the photo?>,
"color": "<EXACT hex color code detected from the ceiling surface>",
"type": "flat" | "sloped" | "beamed" | "coffered" | "vaulted",
"material": "paint" | "plaster" | "wood" | "concrete",
"slopeAngleDeg": <angle in degrees if sloped, 0 for flat, null if not applicable>,
"slopeDirection": "front_to_back" | "left_to_right" | null
},
"lighting": {
"natural": "bright" | "moderate" | "dim",
"windowSide": "left" | "right" | "back" | "front" | null
},
"suggestedWardrobeWall": "back" | "left" | "right",
"suggestedWardrobePosition": {
"xPercent": <start position from left, 0-100>,
"widthPercent": <suggested width as percentage of wall, e.g., 50>,
"reason": "<brief explanation of why this position is recommended>"
},
"roomType": "bedroom" | "living_room" | "hallway" | "dressing" | "other",
"confidence": <confidence score 0-100>
}
IMPORTANT GUIDELINES FOR WALL AND CEILING DETECTION:
- ALWAYS include all three walls (back, left, right). Set visible=false only if a wall is truly not visible at all.
- Sample the EXACT hex color from the dominant area of each wall surface — do NOT default to #f5f5f0.
- Pay close attention to paint color nuances: beige vs cream vs off-white vs warm grey etc.
- Detect the wall material: painted plaster, wallpaper, exposed brick, wood paneling, etc.
- For the ceiling: detect its actual color (it may not be pure white), its type, and material.
- If the ceiling is sloped (mansarde/sous-pente), estimate the slope angle and direction.
- Estimate dimensions based on standard furniture sizes if visible (beds, doors, windows).
- Standard door height is ~2.1m, standard door width is ~0.8-0.9m.
- Standard ceiling height is 2.4-2.7m in residential.
- For wardrobe placement, prefer walls without windows/doors and with enough clear space.
- Return ONLY valid JSON, no additional text.`;
/**
* Analyze a room photo using GPT-4 Vision
* @param {string} imageBase64 - Base64 encoded image data
* @param {string} mimeType - Image MIME type (image/jpeg, image/png, etc.)
* @returns {Object} Room analysis result
*/
export async function analyzeRoomPhoto(imageBase64, mimeType = 'image/jpeg') {
const openai = getOpenAIClient();
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: ROOM_ANALYSIS_PROMPT },
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${imageBase64}`,
detail: 'high'
}
}
]
}
],
max_tokens: 2000,
response_format: { type: 'json_object' }
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
const analysis = JSON.parse(content);
// Validate required fields
if (!analysis.roomDimensions || !analysis.suggestedWardrobeWall) {
throw new Error('Incomplete room analysis');
}
return {
success: true,
analysis,
tokensUsed: response.usage?.total_tokens || 0
};
} catch (error) {
console.error('Room analysis error:', error);
if (error.code === 'invalid_api_key') {
throw new Error('Invalid OpenAI API key');
}
if (error.code === 'rate_limit_exceeded') {
throw new Error('API rate limit exceeded. Please try again later.');
}
throw error;
}
}
/**
* Convert analysis to 3D room configuration for Three.js
* @param {Object} analysis - Room analysis from GPT-4 Vision
* @returns {Object} 3D room configuration
*/
export function analysisTo3DConfig(analysis) {
const { roomDimensions, walls, windows, doors, floor, ceiling, suggestedWardrobeWall, suggestedWardrobePosition } = analysis;
return {
// Room dimensions in meters
width: roomDimensions.widthM || 4,
length: roomDimensions.lengthM || 4,
height: roomDimensions.heightM || 2.5,
// Wall configurations with material info
walls: {
back: (() => {
const w = walls?.find(w => w.side === 'back');
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
})(),
left: (() => {
const w = walls?.find(w => w.side === 'left');
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
})(),
right: (() => {
const w = walls?.find(w => w.side === 'right');
return w ? { color: w.color || '#f5f5f0', visible: w.visible !== false, material: w.material || 'paint', texture: w.texture || 'smooth' }
: { color: '#f5f5f0', visible: true, material: 'paint', texture: 'smooth' };
})()
},
// Windows for each wall
windows: (windows || []).map(w => ({
wall: w.wall,
x: (w.xPercent || 50) / 100,
y: (w.yPercent || 30) / 100,
width: (w.widthPercent || 25) / 100,
height: (w.heightPercent || 40) / 100,
type: w.type || 'window'
})),
// Doors for each wall
doors: (doors || []).map(d => ({
wall: d.wall,
x: (d.xPercent || 10) / 100,
width: (d.widthPercent || 20) / 100,
height: (d.heightPercent || 85) / 100,
type: d.type || 'door'
})),
// Floor and ceiling
floor: {
color: floor?.color || '#c4a574',
material: floor?.material || 'wood'
},
ceiling: {
color: ceiling?.color || '#ffffff',
type: ceiling?.type || 'flat',
visible: ceiling?.visible !== false,
material: ceiling?.material || 'paint',
slopeAngleDeg: ceiling?.slopeAngleDeg || 0,
slopeDirection: ceiling?.slopeDirection || null
},
// Wardrobe placement
wardrobePlacement: {
wall: suggestedWardrobeWall || 'back',
xPercent: suggestedWardrobePosition?.xPercent || 25,
widthPercent: suggestedWardrobePosition?.widthPercent || 50
}
};
}
export default { analyzeRoomPhoto, analysisTo3DConfig };
+60
View File
@@ -0,0 +1,60 @@
/**
* Scheduler service using node-cron
* Handles scheduled tasks like Formex sync
*/
import cron from 'node-cron';
import { sync as formexSync } from './formexCrawler.js';
let formexJob = null;
/**
* Initialize the scheduler with configured jobs
*/
export function initScheduler() {
const cronExpression = process.env.FORMEX_SYNC_CRON || '0 3 * * *'; // Default: 3:00 AM daily
// Validate cron expression
if (!cron.validate(cronExpression)) {
console.error(`Invalid FORMEX_SYNC_CRON expression: ${cronExpression}`);
return;
}
// Schedule Formex sync
formexJob = cron.schedule(cronExpression, async () => {
console.log(`[${new Date().toISOString()}] Running scheduled Formex sync...`);
try {
const result = await formexSync();
console.log(`[${new Date().toISOString()}] Scheduled sync complete:`, result);
} catch (err) {
console.error(`[${new Date().toISOString()}] Scheduled sync failed:`, err.message);
}
}, {
scheduled: true,
timezone: 'Europe/Zurich' // Swiss timezone
});
console.log(`Formex sync scheduled: ${cronExpression} (Europe/Zurich)`);
}
/**
* Stop all scheduled jobs
*/
export function stopScheduler() {
if (formexJob) {
formexJob.stop();
formexJob = null;
console.log('Scheduler stopped');
}
}
/**
* Get scheduler status
*/
export function getSchedulerStatus() {
return {
formexSync: {
running: formexJob !== null,
schedule: process.env.FORMEX_SYNC_CRON || '0 3 * * *'
}
};
}