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
+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
}
}
}
});