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, WASTE_PRICE_PER_M3, 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, CATEGORY_ORDER } from "./services"; import emailjs from "@emailjs/browser"; 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 --- // Waste Volume Factors (m3 per unit) const WASTE_VOLUME_FACTORS: Record = { // Flooring demo_floor_tile: 0.04, demo_floor_wood: 0.03, demo_carpet_vinyl: 0.01, // Walls & Ceilings demo_wall_tile: 0.03, demo_wall_paper: 0.005, demo_wall_crepi: 0.01, demo_partition: 0.08, demo_ceiling_false: 0.05, // Fixtures demo_kitchen_total: 3.5, demo_fireplace: 1.5, demo_sanitary: 0.3, // Bath, WC demo_water_heater: 0.4, demo_radiator: 0.1, // Joinery demo_door_internal: 0.15, demo_window: 0.15, demo_cupboard: 0.1, demo_terrace: 0.1, demo_elec_light: 0.02, }; 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; let totalWasteVolume = 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; // Calculate Waste Volume if (service.category === "Démolition / Dépose") { const factor = WASTE_VOLUME_FACTORS[service.id] || 0.02; // Default fallback totalWasteVolume += quantity * factor; } } }); }); const totalFloorArea = rooms.reduce((acc, r) => acc + r.floorArea, 0); const protectionCost = totalFloorArea * PROTECTION_PRICE_PER_M2; totalHT += protectionCost; categoryBreakdown["Installation & Protection"] = protectionCost; const wasteCost = Math.ceil(totalWasteVolume) * WASTE_PRICE_PER_M3; if (wasteCost > 0) { totalHT += wasteCost; categoryBreakdown["Évacuation & Traitement Déchets"] = wasteCost; } 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.2; 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}`, "", "--- RÉPARTITION PAR LOTS ---", ...Object.entries(calculationResult.categoryBreakdown) .sort(([, a], [, b]) => (b as number) - (a as number)) .map(([cat, amount]) => { const percent = ( ((amount as number) / calculationResult.totalHT) * 100 ).toFixed(1); return `${cat} : ${(amount as number).toLocaleString("fr-FR", { minimumFractionDigits: 2 })} € (${percent}%)`; }), "", "--- DÉTAIL DES PIÈCES ET PRESTATIONS ---", ...rooms.map((r) => { const servicesList = Object.entries(r.selectedServices) .map(([id, qty]) => ({ id, qty, s: servicesLut[id] })) .filter((item) => item.s) .sort((a, b) => { const idxA = CATEGORY_ORDER.indexOf(a.s!.category); const idxB = CATEGORY_ORDER.indexOf(b.s!.category); return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); }) .map(({ id, qty, s }) => { let quantity: number = Number(qty); if (s!.unit === "m2" && !s!.allowQuantity) { if (s!.target === "wall") quantity = r.wallArea; else if (s!.target === "ceiling") quantity = r.ceilingArea; else quantity = r.floorArea; } const defaultSupply = s!.supplyPrice || 0; const customSupplyData = r.customSupplyValues?.[id]; const finalSupply = customSupplyData ? customSupplyData.price : defaultSupply; const baseLabor = s!.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; const description = s!.description ? `\n > ${s!.description}` : ""; return ` - ${s!.label} (${quantity} ${s!.unit}) : ${lineTotal.toLocaleString("fr-FR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} € HT${description}`; }); const servicesText = servicesList.length > 0 ? servicesList.join("\n") : " - Aucune prestation spécifique sélectionnée (uniquement protection standard)"; return `PIÈCE : ${r.customLabel || r.type} (${r.floorArea}m²)\n${servicesText}`; }), "", "* Ce devis est une estimation automatique. Le montant final sera validé après visite technique.", "", "--- BUDGET ESTIMÉ ---", `Total HT : ${calculationResult.totalHT.toLocaleString("fr-FR")} €`, `TVA (${(calculationResult.vatRate * 100).toFixed(0)}%) : ${(calculationResult.totalHT * calculationResult.vatRate).toLocaleString("fr-FR")} €`, `Total TTC : ${calculationResult.totalTTC.toLocaleString("fr-FR")} €`, ] .filter(Boolean) .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, }; emailjs .send( EMAILJS_CONFIG.SERVICE_ID, EMAILJS_CONFIG.TEMPLATE_ID, templateParams, EMAILJS_CONFIG.PUBLIC_KEY, ) .then(() => { setStep(5); window.scrollTo(0, 0); }) .catch((err: any) => { console.error("Email error", err); alert("Erreur lors de l'envoi du devis. Veuillez réessayer."); }); }; 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(() => { // Immediate reset: Clear all data and go back to start setRooms([]); setUserInfo({ firstName: "", lastName: "", email: "", phone: "", city: "", startDate: "", projectDetails: "", providesMaterials: false, }); setQuoteRef(""); setPropertyType(null); setFinishLevel(FinishLevel.STANDARD); setEditingRoomId(null); setStep(1); scrollToTop(); }, []); return (
setStep(1)} > v2025.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;