import React, { useState, useMemo, useCallback, useEffect, Suspense } from 'react'; import { PropertyType, RoomType, FinishLevel, SelectedRoom, UserInfo, ServiceItem } from './types'; import { ROOM_DIMENSIONS, CEILING_HEIGHT, FINISH_MULTIPLIERS, VAT_RATES, PROTECTION_PRICE_PER_M2, PROPERTY_ROOM_MAPPING, EMAILJS_CONFIG, CALENDLY_URL } from './constants'; import { getFinishLabel, getFinishDescription, openCalendlyPopup, preloadCalendly } from './utils'; import BelAirLogo from './components/BelAirLogo'; import { PROPERTY_ICONS } from './components/icons'; // Use local lightweight icons to avoid heavy lucide-react bundle on initial load import { ArrowRight, Calendar, Phone, ArrowUp, LayoutGrid, Zap, Leaf, Home, Loader2 } from './components/AppIcons'; // Import Data Synchronously to prevent "Click Lag" on fast devices // The data size is negligible compared to the HTTP latency of a separate chunk import { SERVICES_LUT } from './services'; import { ROOM_ICONS } from './components/room-icons'; // Eager load the SummaryCard to make Step 3 instant import RoomSummaryCard from './components/RoomSummaryCard'; // Lazy Load heavy components that are not needed immediately const ContactStep = React.lazy(() => import('./components/ContactStep')); const PrintQuote = React.lazy(() => import('./components/PrintQuote')); const RoomEditor = React.lazy(() => import('./components/RoomEditor')); // --- Global Types for External Libraries --- declare global { interface Window { emailjs: any; Calendly: any; } } // --- MAIN APP --- function App() { const [step, setStep] = useState(1); const [propertyType, setPropertyType] = useState(null); const [finishLevel, setFinishLevel] = useState(FinishLevel.STANDARD); const [rooms, setRooms] = useState([]); const [userInfo, setUserInfo] = useState({ firstName: '', lastName: '', email: '', phone: '', city: '', startDate: '', projectDetails: '', providesMaterials: false }); const [showScrollTop, setShowScrollTop] = useState(false); const [quoteRef, setQuoteRef] = useState(''); // NEW STATE FOR MODAL EDITING const [editingRoomId, setEditingRoomId] = useState(null); const editingRoom = useMemo(() => rooms.find(r => r.id === editingRoomId), [rooms, editingRoomId]); // Data Loading - Now synchronous for instant interaction const servicesLut = SERVICES_LUT; const roomIcons = ROOM_ICONS; // Preload Images on Mount useEffect(() => { // 1. Remove Loader Instantly (No artificial delay) const loader = document.getElementById('loader'); if (loader) { loader.style.display = 'none'; // Instant removal loader.remove(); } // 2. Prefetch heavy editors for later steps const prefetchTimer = setTimeout(() => { import('./components/RoomEditor'); import('./components/ContactStep'); import('./components/PrintQuote'); }, 1000); return () => clearTimeout(prefetchTimer); }, []); // Scroll to Top Logic (Optimized Passive Listener) useEffect(() => { const handleScroll = () => { if (window.scrollY > 300) { setShowScrollTop(true); } else { setShowScrollTop(false); } }; // @ts-ignore - Passive option is valid in modern browsers window.addEventListener('scroll', handleScroll, { passive: true }); return () => window.removeEventListener('scroll', handleScroll); }, []); const scrollToTop = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handlePropertySelect = useCallback((type: PropertyType) => { setPropertyType(type); setStep(2); }, []); const handleFinishSelect = useCallback((level: FinishLevel) => { setFinishLevel(level); setStep(3); }, []); const handleRoomAdd = useCallback((type: RoomType) => { const newRoom: SelectedRoom = { id: Math.random().toString(36).substr(2, 9), type, floorArea: ROOM_DIMENSIONS[type].floor, wallArea: 0, ceilingArea: ROOM_DIMENSIONS[type].floor, ceilingHeight: CEILING_HEIGHT, selectedServices: {} }; // Auto-calculate walls const perimeter = ROOM_DIMENSIONS[type].perimeter; newRoom.wallArea = Math.round(perimeter * newRoom.ceilingHeight); setRooms(prev => [...prev, newRoom]); setEditingRoomId(newRoom.id); // IMMEDIATELY OPEN EDITOR }, []); const handleDeleteRoom = useCallback((id: string) => { setRooms(prev => prev.filter(r => r.id !== id)); setEditingRoomId(prev => (prev === id ? null : prev)); }, []); const handleResetRoom = useCallback((id: string) => { if (window.confirm("Voulez-vous vraiment réinitialiser toutes les prestations de cette pièce ?")) { setRooms(prev => prev.map(r => { if (r.id === id) { return { ...r, selectedServices: {}, customSupplyValues: {} }; } return r; })); } }, []); const handleDuplicateRoom = useCallback((id: string) => { setRooms(prev => { const roomToDuplicate = prev.find(r => r.id === id); if (roomToDuplicate) { const newId = Math.random().toString(36).substr(2, 9); const newRoom = { ...roomToDuplicate, id: newId, customLabel: roomToDuplicate.customLabel ? `${roomToDuplicate.customLabel} (Copie)` : undefined }; return [...prev, newRoom]; } return prev; }); }, []); const handleCopyToSameType = useCallback((sourceId: string) => { setRooms(prev => { const sourceRoom = prev.find(r => r.id === sourceId); if (!sourceRoom) return prev; if (window.confirm(`Copier la configuration de "${sourceRoom.customLabel || sourceRoom.type}" vers toutes les autres pièces du même type ?`)) { return prev.map(r => { if (r.type === sourceRoom.type && r.id !== sourceId) { return { ...r, selectedServices: { ...sourceRoom.selectedServices }, customSupplyValues: { ...sourceRoom.customSupplyValues } // also copy supply prices/urls }; } return r; }); } return prev; }); }, []); const handleRenameRoom = useCallback((id: string, name: string) => { setRooms(prev => prev.map(r => r.id === id ? { ...r, customLabel: name } : r)); }, []); const handleUpdateArea = useCallback((id: string, field: 'floor' | 'wall', value: number) => { setRooms(prev => prev.map(r => { if (r.id === id) { const updated = { ...r }; if (field === 'floor') { updated.floorArea = value; updated.ceilingArea = value; const side = Math.sqrt(value); updated.wallArea = Math.round((side * 4) * updated.ceilingHeight); } else { updated.wallArea = value; } return updated; } return r; })); }, []); const handleUpdateHeight = useCallback((id: string, height: number) => { setRooms(prev => prev.map(r => { if (r.id === id) { const h = r.ceilingHeight || 2.7; const currentPerimeter = r.wallArea / h; const newWallArea = Math.round(currentPerimeter * height); return { ...r, ceilingHeight: height, wallArea: newWallArea }; } return r; })); }, []); const handleToggleService = useCallback((roomId: string, serviceId: string, isAdditive: boolean = false, delta: number = 0) => { setRooms(prev => prev.map(r => { if (r.id === roomId) { const currentQty = r.selectedServices[serviceId] || 0; let newQty = 0; if (isAdditive) { newQty = Math.max(0, currentQty + delta); } else { newQty = currentQty > 0 ? 0 : 1; } const newServices = { ...r.selectedServices }; if (newQty > 0) { newServices[serviceId] = newQty; } else { delete newServices[serviceId]; } return { ...r, selectedServices: newServices }; } return r; })); }, []); const handleUpdateSupply = useCallback((roomId: string, serviceId: string, value: number, url?: string) => { setRooms(prev => prev.map(r => { if (r.id === roomId) { const newSupplyValues = { ...r.customSupplyValues }; newSupplyValues[serviceId] = { price: value, url }; return { ...r, customSupplyValues: newSupplyValues }; } return r; })); }, []); const calculationResult = useMemo(() => { let totalHT = 0; const categoryBreakdown: Record = {}; rooms.forEach(room => { Object.entries(room.selectedServices).forEach(([serviceId, qty]) => { const service = servicesLut[serviceId]; if (service) { let quantity: number = Number(qty); if (service.unit === 'm2' && !service.allowQuantity) { if (service.target === 'wall') quantity = room.wallArea; else if (service.target === 'ceiling') quantity = room.ceilingArea; else quantity = room.floorArea; } const defaultSupply = service.supplyPrice || 0; const customSupplyData = room.customSupplyValues?.[serviceId]; const finalSupply = customSupplyData ? customSupplyData.price : defaultSupply; const baseLabor = service.unitPriceHT - defaultSupply; let multiplier: number = FINISH_MULTIPLIERS.STANDARD; if (finishLevel === FinishLevel.RENTAL) multiplier = FINISH_MULTIPLIERS.RENTAL; if (finishLevel === FinishLevel.PRESTIGE) multiplier = FINISH_MULTIPLIERS.PRESTIGE; const finalLabor = baseLabor * multiplier; const lineTotal = (finalLabor + finalSupply) * quantity; totalHT += lineTotal; if (!categoryBreakdown[service.category]) categoryBreakdown[service.category] = 0; categoryBreakdown[service.category] += lineTotal; } }); }); const totalFloorArea = rooms.reduce((acc, r) => acc + r.floorArea, 0); const protectionCost = totalFloorArea * PROTECTION_PRICE_PER_M2; totalHT += protectionCost; categoryBreakdown['Installation & Protection'] = protectionCost; const vatRate = propertyType === PropertyType.OFFICE ? VAT_RATES.OFFICE : VAT_RATES.STANDARD; const totalTTC = totalHT * (1 + vatRate); const estimatedDays = Math.ceil(totalHT / 900); let duration = ""; if (estimatedDays < 5) duration = "3 à 5 jours"; else if (estimatedDays < 10) duration = "8 à 12 jours"; else if (estimatedDays < 20) duration = "15 à 20 jours"; else if (estimatedDays < 40) duration = "5 à 7 semaines"; else duration = "2 à 3 mois"; const minBudget = totalHT * 0.95; const maxBudget = totalHT * 1.20; return { totalHT, totalTTC, vatRate, duration, categoryBreakdown, minBudget, maxBudget }; }, [rooms, finishLevel, propertyType, servicesLut]); const handleContactSubmit = (submittedInfo: UserInfo) => { setUserInfo(submittedInfo); const city = submittedInfo.city ? submittedInfo.city.toUpperCase().replace(/[^A-Z]/g, '').substring(0, 3) : 'PAR'; const year = new Date().getFullYear(); const storageKey = `quoteCount_${year}`; const current = parseInt(localStorage.getItem(storageKey) || '0'); const next = current + 1; localStorage.setItem(storageKey, next.toString()); const newRef = `${city}-${year}-${next.toString().padStart(3, '0')}`; setQuoteRef(newRef); const fullDetails = [ "--- NOUVEAU PROJET SIMULATEUR ---", `RÉFÉRENCE : ${newRef}`, `CLIENT : ${submittedInfo.firstName} ${submittedInfo.lastName}`, `EMAIL : ${submittedInfo.email}`, `TÉLÉPHONE : ${submittedInfo.phone}`, `ADRESSE : ${submittedInfo.city}`, `TYPE DE BIEN : ${propertyType}`, `GAMME : ${finishLevel}`, `DETAILS PROJET : ${submittedInfo.projectDetails || "Non spécifié"}`, `DURÉE ESTIMÉE : ${calculationResult.duration}`, "", "--- DÉTAIL DES PIÈCES ET PRESTATIONS ---", ...rooms.map(r => { const servicesText = Object.entries(r.selectedServices).map(([id, qty]) => { const s = servicesLut[id]; return s ? ` - ${s.label} (${s.allowQuantity ? 'x' + qty : 'Oui'})` : null; }).filter(Boolean).join('\n'); return `PIÈCE : ${r.customLabel || r.type} (${r.floorArea}m²)\n${servicesText}`; }), "", "--- BUDGET ESTIMÉ ---", `Total HT : ${calculationResult.totalHT.toLocaleString('fr-FR')} €`, `Total TTC : ${calculationResult.totalTTC.toLocaleString('fr-FR')} €` ].join('\n'); const templateParams = { to_name: "BelAir Habitat", from_name: `${submittedInfo.firstName} ${submittedInfo.lastName}`, from_email: submittedInfo.email, phone: submittedInfo.phone, city: submittedInfo.city, property_type: propertyType, finish_level: finishLevel, total_rooms: rooms.length, total_ht: `${calculationResult.totalHT.toFixed(2)} €`, duration: calculationResult.duration, project_details: submittedInfo.projectDetails, message: fullDetails, rooms_summary: fullDetails }; window.emailjs.send( EMAILJS_CONFIG.SERVICE_ID, EMAILJS_CONFIG.TEMPLATE_ID, templateParams, EMAILJS_CONFIG.PUBLIC_KEY ).catch((err: any) => console.error("Email error", err)); setStep(5); window.scrollTo(0, 0); }; useEffect(() => { // 1. Initial Load: Preload lightweight assets or critical data if needed // 2. Delayed Preload: Load Calendly script after 3 seconds so it doesn't slow down the first paint // This allows the "Upper Option" (Header button) to become fast quickly without blocking startup. const timer = setTimeout(() => { requestAnimationFrame(() => preloadCalendly()); }, 3000); return () => clearTimeout(timer); }, []); const handleNextStep = useCallback(() => { if (step === 3 && rooms.length === 0) { alert("Veuillez ajouter au moins une pièce."); return; } // Aggressive Preload: Start loading Calendly as soon as they go to Contact step if (step === 3) { requestAnimationFrame(() => preloadCalendly()); } setStep(s => s + 1); scrollToTop(); }, [step, rooms.length]); const handleNewQuote = useCallback(() => { if (window.confirm("Tout effacer et recommencer ?")) { setRooms([]); setUserInfo({ firstName: '', lastName: '', email: '', phone: '', city: '', startDate: '', projectDetails: '', providesMaterials: false }); setQuoteRef(''); setStep(1); scrollToTop(); } }, []); return (
setStep(1)}>
{step > 1 && !editingRoomId && (
Budget Estimé {calculationResult.totalHT.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €
)}
{editingRoom && propertyType && (
}> setEditingRoomId(null)} onReset={handleResetRoom} onCopyToSameType={handleCopyToSameType} onRename={handleRenameRoom} onUpdateArea={handleUpdateArea} onUpdateHeight={handleUpdateHeight} onToggleService={handleToggleService} onUpdateSupply={handleUpdateSupply} /> )}
{step === 1 && (

Quel type de bien
souhaitez-vous rénover ?

Sélectionnez la catégorie correspondant à votre projet.

{Object.values(PropertyType).map((type) => { const Icon = PROPERTY_ICONS[type]; return ( ); })}
)} {step === 2 && (

Quel est votre objectif ?

Cela nous aide à ajuster les gammes de matériaux.

{Object.values(FinishLevel).map((level) => { const isPrestige = level === FinishLevel.PRESTIGE; const isRental = level === FinishLevel.RENTAL; // CLEAN WHITE LUXURY DESIGN // All cards are White Background with Dark Text. // Differentiation comes from Borders and Icon Colors. let cardClasses = ""; let iconClasses = ""; let titleClasses = "text-brand-900"; // Always Dark Navy let descClasses = "text-slate-500"; // Always Slate Grey if (isPrestige) { // Prestige: Gold Border, Gold Icon, Subtle Gold Glow cardClasses = "bg-white border-2 border-gold-400 shadow-glow scale-[1.02] ring-0"; iconClasses = "bg-gold-50 text-gold-600"; } else if (isRental) { // Rental: Green Border cardClasses = "bg-white border border-emerald-200 hover:border-emerald-400 hover:shadow-lg"; iconClasses = "bg-emerald-50 text-emerald-600"; } else { // Standard: Slate/Blue Border cardClasses = "bg-white border border-slate-200 hover:border-brand-500 hover:shadow-lg"; iconClasses = "bg-slate-50 text-brand-600"; } return ( ); })}
)} {step === 3 && !editingRoomId && (
Total estimé {calculationResult.totalHT.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} € ⏱️ {calculationResult.duration}

Définissons votre projet

{/* Note: Suspense is removed because RoomSummaryCard is now imported synchronously for speed */} {rooms.map((room) => ( ))} {rooms.length === 0 && (

Commencez par ajouter une pièce

)}

Ajouter une pièce

{(propertyType ? PROPERTY_ROOM_MAPPING[propertyType] : []).map(type => { const Icon = roomIcons[type] || Loader2; return ( ); })}
Budget Estimé {calculationResult.minBudget.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} - {calculationResult.maxBudget.toLocaleString('fr-FR', { maximumFractionDigits: 0 })} €
Durée {calculationResult.duration || "N/A"}
)} {step === 4 && ( }> setStep(3)} /> )} {step === 5 && ( }> setStep(3)} onNewQuote={handleNewQuote} /> )}
{showScrollTop && !editingRoomId && ( )} ); } export default App;