.btn-modern:active { transform: translateY(0px) scale(0.97); } /* Ghost Button */ .btn-ghost-modern { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 20px; border-radius: var(--radius-pill); border: 1px solid var(--border-subtle); background: rgba(255, 255, 255, 0.4); color: var(--text); font-size: var(--font-md); cursor: pointer; backdrop-filter: blur(12px); transition: transform 140ms ease, background 160ms ease, color 160ms ease; } [data-theme="dark"] .btn-ghost-modern { background: rgba(2, 6, 23, 0.4); } .btn-ghost-modern:hover { background: var(--primary-soft); color: var(--primary); transform: translateY(-2px); } .btn-ghost-modern:active { transform: translateY(0px) scale(0.97); } /* Modern Select Wrapper */ .select-modern { position: relative; display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; } /* Label */ .select-modern label { font-size: var(--font-sm); color: var(--text-muted); font-weight: 500; } /* Select Element */ .select-modern select { appearance: none; padding: 12px 16px; padding-right: 42px; border-radius: var(--radius-pill); border: 1px solid var(--border-subtle); background: rgba(255, 255, 255, 0.55); backdrop-filter: blur(14px); font-size: var(--font-md); color: var(--text); cursor: pointer; transition: border 160ms ease, box-shadow 160ms ease, background 160ms ease; } [data-theme="dark"] .select-modern select { background: rgba(2, 6, 23, 0.55); } /* Hover + Focus */ .select-modern select:hover { border-color: var(--primary); } .select-modern select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); } /* Dropdown Arrow */ .select-modern::after { content: "▾"; position: absolute; right: 16px; top: 38px; font-size: 14px; color: var(--text-muted); pointer-events: none; } /* Option Styling */ .select-modern select option { background: var(--bg-elevated); color: var(--text); padding: 10px; font-size: var(--font-md); } /* GROUP LABELS */ .nav-group { display: flex; flex-direction: column; gap: 4px; margin-right: 18px; } .nav-group-label { font-size: 11px; font-weight: 600; color: var(--text-soft); margin-left: 6px; } /* SPACER pushes avatar + notifications to right */ .nav-spacer { flex: 1; } /* NOTIFICATIONS */ .nav-notifications { position: relative; font-size: 20px; cursor: pointer; margin-right: 12px; } .notif-badge { position: absolute; top: -4px; right: -6px; background: var(--primary); color: white; font-size: 10px; padding: 2px 6px; border-radius: 999px; box-shadow: 0 2px 6px rgba(37, 99, 235, 0.4); } /* AVATAR */ .nav-avatar img { width: 32px; height: 32px; border-radius: 999px; cursor: pointer; border: 2px solid var(--primary-soft); transition: transform 150ms ease; } .nav-avatar img:hover { transform: scale(1.05); } /* PROFILE MENU */ .profile-menu, .notif-menu { position: absolute; top: 58px; right: 14px; width: 200px; background: var(--bg-elevated); border-radius: var(--radius-md); box-shadow: var(--shadow-soft); border: 1px solid var(--border-subtle); backdrop-filter: blur(18px); z-index: 100; } .profile-menu.hidden, .notif-menu.hidden { display: none; } .profile-menu-item, .notif-item { padding: 10px 14px; cursor: pointer; font-size: var(--font-sm); color: var(--text); transition: background 150ms ease; } .profile-menu-item:hover, .notif-item:hover { background: var(--primary-soft); } .profile-menu-separator { height: 1px; background: var(--border-subtle); margin: 4px 0; } /* Modern top nav */ .top-nav { position: sticky; top: 0; z-index: 60; display: flex; align-items: center; gap: 6px; padding: 10px 14px; margin: 10px auto 0; max-width: 1100px; background: rgba(255, 255, 255, 0.55); backdrop-filter: blur(18px); border-radius: var(--radius-pill); border: 1px solid var(--border-subtle); overflow-x: auto; scrollbar-width: none; } [data-theme="dark"] .top-nav { background: rgba(2, 6, 23, 0.55); } .top-nav::-webkit-scrollbar { display: none; } /* Nav links */ .top-nav a { position: relative; display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: var(--radius-pill); font-size: var(--font-sm); color: var(--text-muted); text-decoration: none; white-space: nowrap; transition: color 160ms ease, transform 160ms ease, background 160ms ease; } .top-nav a:hover { color: var(--primary); background: var(--primary-soft); transform: translateY(-1px); } /* Active link styling */ .top-nav a.active { color: var(--primary); font-weight: 600; } /* Icons */ .nav-icon { font-size: 16px; } /* Animated active indicator */ .nav-active-indicator { position: absolute; bottom: 4px; height: 4px; width: 28px; background: var(--primary); border-radius: 999px; transition: transform 260ms ease, width 260ms ease; pointer-events: none; } /* Sticky container */ .stepper-sticky { position: sticky; top: 0; z-index: 50; padding: 12px 0; backdrop-filter: blur(18px); background: rgba(255, 255, 255, 0.65); border-bottom: 1px solid var(--border-subtle); } /* Dark mode support */ [data-theme="dark"] .stepper-sticky { background: rgba(2, 6, 23, 0.65); } /* Stepper container */ .stepper-container { max-width: 1100px; margin: 0 auto; padding: 0 12px; } /* Progress bar */ .stepper-progress { height: 4px; width: 100%; background: rgba(15, 23, 42, 0.08); border-radius: 999px; overflow: hidden; margin-bottom: 10px; } .stepper-progress-bar { height: 100%; background: var(--primary); transition: width 300ms ease; } /* Steps row */ .stepper-steps { display: flex; gap: 12px; flex-wrap: wrap; } /* Individual step */ .stepper-step { display: flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: var(--radius-pill); background: rgba(15, 23, 42, 0.06); color: var(--text-muted); font-size: var(--font-sm); cursor: pointer; transition: background 180ms ease, color 180ms ease, transform 140ms ease; } .stepper-step:hover { background: var(--primary-soft); color: var(--primary); transform: translateY(-1px); } /* Active step */ .stepper-step.active { background: var(--primary); color: white; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); } /* Disabled step */ .stepper-step.disabled { opacity: 0.4; cursor: not-allowed; transform: none; } /* Icons */ .stepper-icon { font-size: 16px; } :root { --bg: #f3f4f6; --bg-elevated: #ffffff; --bg-soft: #f9fafb; --border-subtle: rgba(15, 23, 42, 0.08); --shadow-soft: 0 10px 30px rgba(15, 23, 42, 0.06); --shadow-strong: 0 18px 45px rgba(15, 23, 42, 0.16); --primary: #2563eb; --primary-soft: rgba(37, 99, 235, 0.1); --primary-hover: #1d4ed8; --accent: #10b981; --text: #0f172a; --text-muted: #6b7280; --text-soft: #9ca3af; --radius-lg: 18px; --radius-md: 12px; --radius-pill: 999px; --transition-fast: 150ms ease-out; --transition-med: 220ms ease-out; --font-lg: clamp(22px, 4vw, 30px); --font-md: clamp(16px, 2.5vw, 19px); --font-sm: clamp(13px, 2vw, 16px); } [data-theme="dark"] { --bg: #020617; --bg-elevated: #020617; --bg-soft: #0f172a; --border-subtle: rgba(148, 163, 184, 0.25); --shadow-soft: 0 18px 50px rgba(0, 0, 0, 0.5); --shadow-strong: 0 24px 80px rgba(0, 0, 0, 0.7); --primary: #60a5fa; --primary-soft: rgba(96, 165, 250, 0.16); --primary-hover: #3b82f6; --accent: #22c55e; --text: #e5e7eb; --text-muted: #9ca3af; --text-soft: #6b7280; } *, *::before, *::after { box-sizing: border-box; } body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif; background: radial-gradient(circle at top, rgba(37, 99, 235, 0.08), transparent 60%), var(--bg); color: var(--text); -webkit-font-smoothing: antialiased; transition: background-color var(--transition-med), color var(--transition-med); } .app-shell { max-width: 1100px; margin: 12px auto 0; padding: 8px 12px; display: flex; align-items: center; gap: 12px; } .app-brand { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--text); font-size: 18px; } .app-brand-badge { width: 26px; height: 26px; border-radius: 10px; background: radial-gradient(circle at 30% 30%, #fee2e2, transparent), linear-gradient(135deg, #1d4ed8, #22c55e); box-shadow: 0 6px 18px rgba(37, 99, 235, 0.4); } .theme-toggle { margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: var(--font-sm); color: var(--text-muted); } .theme-toggle button { padding: 6px 10px; margin: 0; border-radius: var(--radius-pill); background: rgba(15, 23, 42, 0.08); border: 1px solid var(--border-subtle); font-size: 12px; cursor: pointer; } nav { max-width: 1100px; margin: 8px auto 0; display: flex; gap: 8px; padding: 10px 12px; background: rgba(15, 23, 42, 0.04); backdrop-filter: blur(16px); border-radius: var(--radius-pill); border: 1px solid var(--border-subtle); overflow-x: auto; } nav::-webkit-scrollbar { display: none; } nav a { padding: 7px 14px; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); text-decoration: none; font-size: var(--font-sm); display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; transition: background-color var(--transition-fast), color var(--transition-fast), transform 80ms ease-out; } nav a.active { background: var(--primary-soft); color: var(--primary); } nav a:hover { background: rgba(15, 23, 42, 0.06); color: var(--text); transform: translateY(-1px); } #view { max-width: 1100px; margin: 24px auto 32px; padding: 24px 20px 28px; background: radial-gradient(circle at top left, rgba(15, 23, 42, 0.04), transparent 55%), var(--bg-elevated); border-radius: 24px; box-shadow: var(--shadow-soft); border: 1px solid var(--border-subtle); }
Roofing Marketplace
Theme
Loading…
/* ============================ THEME SYSTEM ============================ */ function applyTheme(theme) { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("theme", theme); } function toggleTheme() { const current = document.documentElement.getAttribute("data-theme") || "light"; applyTheme(current === "light" ? "dark" : "light"); } (function initTheme() { const stored = localStorage.getItem("theme") || "light"; applyTheme(stored); })(); /* ============================ GLOBAL STORE (Reactive) ============================ */ const Store = { state: { currentUser: null, role: null, currentJob: null, roofers: [], quotes: [], quotesLoading: false, jobs: [], clientJobs: [], admin: null, messages: [], toasts: [], modal: null, autoRoofs: [], currentRooferProfile: null, route: "client", routeParams: {}, loading: {} }, subscribers: [], set(partial) { this.state = { ...this.state, ...partial }; this.subscribers.forEach(fn => fn(this.state)); }, on(fn) { this.subscribers.push(fn); fn(this.state); }, setLoading(key, value) { this.state.loading = { ...this.state.loading, [key]: value }; this.subscribers.forEach(fn => fn(this.state)); } }; /* ============================ ROUTER ============================ */ const Routes = {}; function navigate(route, params = {}) { Store.set({ route, routeParams: params }); if (params.jobId) window.location.hash = `#${route}-${params.jobId}`; else if (params.rooferId) window.location.hash = `#${route}-${params.rooferId}`; else window.location.hash = `#${route}`; } window.addEventListener("hashchange", () => { const hash = window.location.hash.replace("#", "") || "client"; const [route, param] = hash.split("-"); const params = {}; if (route === "messaging" && param) params.jobId = param; if (route === "rooferProfile" && param) params.rooferId = param; Store.set({ route, routeParams: params }); }); /* ============================ NAV HIGHLIGHTING ============================ */ function highlightNav(route) { const links = document.querySelectorAll("nav a"); const indicator = document.querySelector(".nav-active-indicator"); let activeLink = null; links.forEach(a => { const r = a.getAttribute("data-route"); const isActive = r === route; a.classList.toggle("active", isActive); if (isActive) activeLink = a; }); if (activeLink && indicator) { const rect = activeLink.getBoundingClientRect(); const navRect = activeLink.parentElement.getBoundingClientRect(); const width = rect.width * 0.55; indicator.style.width = width + "px"; indicator.style.transform = `translateX(${rect.left - navRect.left + (rect.width - width) / 2}px)`; } } /* ============================ ROUTER TRANSITIONS ============================ */ function mount(rootId, viewFn) { const root = document.getElementById(rootId); if (!root) return; Store.on(state => { const oldInner = root.querySelector(".view-inner"); if (oldInner) { oldInner.classList.remove("view-enter"); oldInner.classList.add("view-exit"); setTimeout(() => { root.innerHTML = viewFn(state); requestAnimationFrame(() => { const el = document.querySelector(".view-inner"); if (el) el.classList.add("view-enter"); highlightNav(state.route); }); }, 160); } else { root.innerHTML = viewFn(state); requestAnimationFrame(() => { const el = document.querySelector(".view-inner"); if (el) el.classList.add("view-enter"); highlightNav(state.route); }); } }); } /* ============================ TOAST SYSTEM ============================ */ function showToast(message, type = "info", duration = 3000) { const id = Date.now() + Math.random(); const toast = { id, message, type }; Store.set({ toasts: [...Store.state.toasts, toast] }); setTimeout(() => { Store.set({ toasts: Store.state.toasts.filter(t => t.id !== id) }); }, duration); } function ToastContainer(state) { if (!state.toasts.length) return ""; return `
${state.toasts.map(t => `
${t.message}
`).join("")}
`; } /* ============================ MODAL SYSTEM ============================ */ function openModal(title, contentHTML) { Store.set({ modal: { title, contentHTML } }); } function closeModal() { Store.set({ modal: null }); } function ModalContainer(state) { if (!state.modal) return ""; const m = state.modal; return ` `; } /* ============================ UI COMPONENTS ============================ */ function Card(content, opts = {}) { return `
${content}
`; } function Button(label, onclick, opts = {}) { const variant = opts.variant || "primary"; const cls = variant === "ghost" ? "btn-ghost-modern" : "btn-modern"; const style = opts.style || ""; const icon = opts.icon ? `${opts.icon}` : ""; return ` `; } function Chip(text, color = "default") { const styles = { default: "background:rgba(15,23,42,0.06);", primary: "background:rgba(37,99,235,0.12);color:var(--primary);", accent: "background:rgba(16,185,129,0.12);color:var(--accent);" }; return `${text}`; } /* ============================ SKELETON LOADERS ============================ */ function Skeleton(height = "20px", width = "100%", radius = "12px") { return `
`; } function QuotesSkeleton() { return Card(` ${Skeleton("22px", "40%")}
${Skeleton("16px", "20%")} ${Skeleton("16px", "25%")} ${Skeleton("16px", "18%")}
${Skeleton("40px", "100%", "14px")}
`); } function JobsSkeleton() { return ` ${Card(` ${Skeleton("20px", "30%")} ${Skeleton("14px", "50%", "8px")} ${Skeleton("14px", "40%", "8px")} `)} `; } /* ============================ STEPPER (Client Flow) ============================ */ function Stepper(currentStep) { const job = Store.state.currentJob || {}; // Guard rails const canGoAddress = true; // always allowed after details const canGoQuotes = job.area > 0; // only after roof area saved const canGoSelected = job.roofer_id; // only after selecting roofer const steps = [ { label: "Details", route: "client", icon: "📝", enabled: true }, { label: "Address", route: "address", icon: "📍", enabled: canGoAddress }, { label: "Quotes", route: "quotes", icon: "💰", enabled: canGoQuotes }, { label: "Selected Roofer", route: "messaging", icon: "👷", enabled: canGoSelected } ]; const progressPercent = (currentStep / (steps.length - 1)) * 100; return `
${steps .map((s, i) => { const active = i === currentStep; const disabled = !s.enabled; return `
${s.icon} ${s.label}
`; }) .join("")}
`; } /* ============================ UTILITY HELPERS ============================ */ function safeAction(action, { label = "Action" } = {}) { return action().catch(e => { console.error(e); showToast(`${label} failed. Please try again.`, "error"); }); } function formatTime(ts) { try { return new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); } catch { return ""; } } function scrollChatToBottom() { const box = document.getElementById("chatBox"); if (!box) return; const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 60; if (nearBottom) box.scrollTop = box.scrollHeight; } /* ============================ FIREBASE INITIALIZATION ============================ */ // Firebase SDK imports (ESM) import { initializeApp } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-app.js"; import { getAuth, onAuthStateChanged, signInWithEmailAndPassword, signOut } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-auth.js"; import { getFirestore, collection, getDocs, addDoc, doc, updateDoc, query, where, orderBy, onSnapshot } from "https://www.gstatic.com/firebasejs/10.12.0/firebase-firestore.js"; /* ============================ FIREBASE CONFIG (Your real config — already public) ============================ */ const firebaseConfig = { apiKey: "AIzaSyA-HINhOFbNrzCtqrIwbXfvh-3L-c3r-gY", authDomain: "roofing-app-84ecc.firebaseapp.com", projectId: "roofing-app-84ecc", storageBucket: "roofing-app-84ecc.firebasestorage.app", messagingSenderId: "540049423746", appId: "1:540049423746:web:222f0508cf8229f4bda39b", measurementId: "G-9888FJXF0F" }; /* ============================ FIREBASE APP + SERVICES ============================ */ const firebaseApp = initializeApp(firebaseConfig); const auth = getAuth(firebaseApp); const db = getFirestore(firebaseApp); /* ============================ FIREBASE HELPERS ============================ */ async function firebaseLogin(email, password) { return signInWithEmailAndPassword(auth, email, password); } async function firebaseLogout() { return signOut(auth); } async function firebaseGet(collectionName) { const snap = await getDocs(collection(db, collectionName)); return snap.docs.map(d => ({ id: d.id, ...d.data() })); } async function firebaseAdd(collectionName, data) { return addDoc(collection(db, collectionName), data); } async function firebaseUpdate(collectionName, id, data) { return updateDoc(doc(db, collectionName, id), data); } function firebaseQuery(collectionName, filters = [], sort = null) { let q = collection(db, collectionName); if (filters.length) { q = query( q, ...filters.map(f => where(f.field, f.op, f.value)) ); } if (sort) { q = query(q, orderBy(sort.field, sort.direction || "asc")); } return getDocs(q).then(snap => snap.docs.map(d => ({ id: d.id, ...d.data() })) ); } function firebaseSubscribeMessages(jobId, callback) { const q = query( collection(db, "messages"), where("job_id", "==", jobId), orderBy("created_at", "asc") ); return onSnapshot(q, snap => { const messages = snap.docs.map(d => ({ id: d.id, ...d.data() })); callback(messages); }); } /* ============================ AUTH STATE LISTENER ============================ */ onAuthStateChanged(auth, user => { Store.set({ currentUser: user || null }); }); /* ============================ LEAFLET MAP + DRAWING ============================ */ let map; let drawnItems; let drawControl; let autoRoofLayers = []; function initMap() { const mapEl = document.getElementById("map"); if (!mapEl) return; map = L.map("map").setView([29.8947, -81.3145], 18); // OpenStreetMap tiles L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 22, attribution: "© OpenStreetMap contributors" }).addTo(map); drawnItems = new L.FeatureGroup(); map.addLayer(drawnItems); drawControl = new L.Control.Draw({ draw: { polygon: true, polyline: false, rectangle: false, circle: false, marker: false, circlemarker: false }, edit: { featureGroup: drawnItems } }); map.addControl(drawControl); map.on(L.Draw.Event.CREATED, function (e) { drawnItems.clearLayers(); drawnItems.addLayer(e.layer); }); map.on(L.Draw.Event.EDITED, function () { // recalc on save }); const job = Store.state.currentJob; if (job && job.location) { map.setView([job.location.lat, job.location.lng], 20); } } /* ============================ GEOCODING (Nominatim) ============================ */ async function geocodeAddress(address) { const url = "https://nominatim.openstreetmap.org/search?format=json&q=" + encodeURIComponent(address); const res = await fetch(url, { headers: { "User-Agent": "RoofingMarketplace/1.0" } }); const data = await res.json(); if (!data.length) return null; return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon), display_name: data[0].display_name }; } async function handleAddressSearch() { const address = document.getElementById("addressInput").value; if (!address.trim()) { alert("Please enter an address."); return; } const result = await geocodeAddress(address); if (!result) { alert("Address not found."); return; } if (map) { map.setView([result.lat, result.lng], 20); } Store.set({ currentJob: { ...(Store.state.currentJob || {}), address: result.display_name, location: { lat: result.lat, lng: result.lng } } }); } /* ============================ ROOF AREA CALCULATION ============================ */ function computeRoofArea() { if (!drawnItems || drawnItems.getLayers().length === 0) return null; const layer = drawnItems.getLayers()[0]; const latlngs = layer.getLatLngs()[0]; if (!latlngs || !latlngs.length) return null; const areaMeters = L.GeometryUtil.geodesicArea(latlngs); const areaSqft = areaMeters * 10.7639; return { area_sqft: Math.round(areaSqft), coordinates: latlngs.map(p => ({ lat: p.lat, lng: p.lng })) }; } /* ============================ RENDER AUTO-DETECTED ROOFS ============================ */ function renderRoofPolygonsOnMap(roofs) { if (!map) return; autoRoofLayers.forEach(l => map.removeLayer(l)); autoRoofLayers = []; roofs.forEach(roof => { const latlngs = roof.coordinates.map(c => [c.lat, c.lng]); const poly = L.polygon(latlngs, { color: "#22c55e", weight: 2, fillOpacity: 0.25 }).addTo(map); autoRoofLayers.push(poly); }); if (autoRoofLayers.length) { const group = L.featureGroup(autoRoofLayers); map.fitBounds(group.getBounds()); } } /* ============================ AUTO ROOF DETECTION (Backend) ============================ */ async function requestAutoRoofDetection() { const job = Store.state.currentJob; if (!job || !job.location) { alert("Please choose an address first."); return; } const payload = { address: job.address, location: job.location }; try { const res = await fetch("/api/roof-detect", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const data = await res.json(); const roofs = data.roofs || []; Store.set({ autoRoofs: roofs }); renderRoofPolygonsOnMap(roofs); showToast("Roof(s) auto‑detected. Adjust or draw manually if needed.", "info"); } catch (e) { console.error(e); alert("Roof detection failed. You can still draw the roof manually."); } } /* ============================ SAVE ROOF AREA + CONTINUE FLOW ============================ */ async function saveRoofArea() { const roofFromDrawing = computeRoofArea(); const roofsFromAuto = Store.state.autoRoofs || []; let totalArea = 0; let roofsCombined = []; if (roofFromDrawing) { totalArea += roofFromDrawing.area_sqft; roofsCombined.push(roofFromDrawing); } if (roofsFromAuto.length) { roofsFromAuto.forEach(r => { totalArea += r.area_sqft || 0; roofsCombined.push(r); }); } if (!totalArea) { alert("Please auto‑detect or draw your roof outline first."); return; } const job = Store.state.currentJob || {}; Store.set({ currentJob: { ...job, area: totalArea, roofs: roofsCombined }, quotesLoading: true }); await loadRoofers(); computeQuotes(); navigate("quotes"); } /* ============================ LOAD ROOFERS ============================ */ async function loadRoofers() { const { db, collection, getDocs } = window._firebase || { db, collection, getDocs }; try { const snap = await getDocs(collection(db, "roofers")); const roofers = snap.docs.map(d => ({ id: d.id, ...d.data() })); Store.set({ roofers }); } catch (e) { console.error(e); Store.set({ roofers: [] }); } } /* ============================ COMPUTE QUOTES ============================ */ function computeQuotes() { const job = Store.state.currentJob; if (!job || !job.area || !Store.state.roofers.length) { Store.set({ quotesLoading: false }); return; } const quotes = Store.state.roofers.map(r => { const materialRate = (r.material_rates && r.material_rates[job.material]) || r.base_rate || 3.5; const pitchMult = (r.pitch_multiplier && r.pitch_multiplier[job.pitch]) || 1; const smartBase = r.smart_base_rate || materialRate; const base = job.area * smartBase * pitchMult; const labor = r.labor_rate || 1500; const travel = r.travel_fee || 0; const tearoffFee = job.tearoff ? job.area * (r.tearoff_rate_per_sqft || 0.5) : 0; const price = Math.round(base + labor + travel + tearoffFee); return { roofer_id: r.id, company: r.name, price, rating: r.rating || 4.7, warranty: r.warranty || "25 years", timeline: r.timeline || "2–4 days" }; }); Store.set({ quotes, quotesLoading: false }); } /* ============================ QUOTE CARD COMPONENT ============================ */ function QuoteCard(q) { return Card(`
${q.company}
${Chip(`⭐ ${q.rating.toFixed ? q.rating.toFixed(1) : q.rating}`, "primary")} ${Chip(`Timeline: ${q.timeline}`)} ${Chip(`Warranty: ${q.warranty}`)}
$${q.price.toLocaleString()}
Estimated
${Button(`Select ${q.company}`, `selectQuote('${q.roofer_id}')`)} ${Button("View profile", `goToRooferProfile('${q.roofer_id}')`, { variant: "ghost" })}
`); } /* ============================ SELECT ROOFER (Create Job) ============================ */ async function selectQuote(rooferId) { const job = Store.state.currentJob; if (!job) return; const { db, collection, addDoc } = window._firebase || { db, collection, addDoc }; const jobsCol = collection(db, "jobs"); const docRef = await addDoc(jobsCol, { area: job.area, pitch: job.pitch, material: job.material, tearoff: job.tearoff, status: "Estimate", roofer_id: rooferId, address: job.address || "", location: job.location || null, roofs: job.roofs || null, created_at: new Date().toISOString() }); const newJob = { id: docRef.id, ...job, roofer_id: rooferId, status: "Estimate" }; Store.set({ currentJob: newJob }); showToast("Roofer selected. You can now message and schedule.", "success"); navigate("messaging", { jobId: docRef.id }); loadMessages(docRef.id); } /* ============================ PAYMENT (Stripe Connect Placeholder) ============================ */ async function createPaymentForJob(jobId) { const job = Store.state.currentJob; if (!job || !jobId) { showToast("No job selected for payment.", "error"); return; } const amount = 500; // placeholder deposit const rooferStripeAccountId = "STRIPE_CONNECT_ACCOUNT_ID_PLACEHOLDER"; try { const res = await fetch("/api/create-checkout-session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jobId, amount, rooferStripeAccountId, description: `Roofing job for ${job.address || "client"}` }) }); const data = await res.json(); if (data.url) { window.location = data.url; } else { showToast("Unable to start payment.", "error"); } } catch (e) { console.error(e); showToast("Payment error.", "error"); } } /* ============================ REAL‑TIME MESSAGING (Firestore) ============================ */ let messageUnsub = null; async function loadMessages(jobId) { if (!jobId) return; // Unsubscribe previous listener if (messageUnsub) messageUnsub(); messageUnsub = firebaseSubscribeMessages(jobId, messages => { Store.set({ messages }); scrollChatToBottom(); }); } async function sendMessage(jobId) { const input = document.getElementById("chatInput"); const text = input.value.trim(); if (!text) return; const sender_role = Store.state.role || "client"; await addDoc(collection(db, "messages"), { job_id: jobId, text, sender_role, created_at: new Date().toISOString() }); input.value = ""; scrollChatToBottom(); } /* ============================ TYPING INDICATOR (Local Only) ============================ */ function onChatInput() { const val = document.getElementById("chatInput").value; Store.set({ typing: { ...Store.state.typing, me: !!val } }); } /* ============================ MESSAGES HTML ============================ */ function messagesHtml(list) { if (!list.length) { return `
No messages yet. Start the conversation.
`; } return list .map(m => { const cls = m.sender_role === "roofer" ? "chat-bubble roofer" : "chat-bubble client"; const label = m.sender_role === "roofer" ? "Roofer" : "Client"; return `
${label}
${m.text}
${formatTime(m.created_at)}
`; }) .join(""); } /* ============================ MESSAGING VIEW ============================ */ function MessagingView(state) { const jobId = state.routeParams.jobId; if (!jobId) { return `

No job selected

Please select a roofer and create a job first.

`; } const msgs = state.messages || []; return `

Messaging

${messagesHtml(msgs)}
`; } /* ============================ VIEWS ============================ */ /* ============================ CLIENT FLOW — STEP 1 ============================ */ function ClientFlow(state) { return `
${Stepper(0)}

Tell us about your roof

You can enter details manually or upload a photo for AI‑assisted detection.

${Button("Next: Address & Roof Map", "startQuoteFlow()", { icon: "➡️" })}
`; }
${Button("Next: Address & Roof Map", "startQuoteFlow()")} `; } function startQuoteFlow() { const pitch = document.getElementById("pitch").value; const material = document.getElementById("material").value; const tearoff = document.getElementById("tearoff").value === "yes"; Store.set({ currentJob: { ...(Store.state.currentJob || {}), pitch, material, tearoff } }); navigate("address"); } /* ============================ CLIENT FLOW — STEP 2 (ADDRESS) ============================ */ function addressLookupViewBody() { const job = Store.state.currentJob || {}; return `

Find your roof

Enter your address, then auto‑detect your roof or trace the outline on open‑source maps.

${Button("Auto‑detect roof (backend)", "requestAutoRoofDetection()", { style: "flex:1 1 160px;" })} ${Button("Locate address", "handleAddressSearch()", { variant: "ghost", style: "flex:1 1 140px;" })}
${Button("Use this roof measurement & view quotes", "saveRoofArea()", { style: "margin-top:16px;" })} ${ job.area ? `

Current estimated area: ${job.area} sq ft

` : "" } `; } function AddressFlow(state) { setTimeout(() => { if (typeof L !== "undefined") initMap(); }, 50); return `
${Stepper(1)} ${addressLookupViewBody()}
`; } /* ============================ CLIENT FLOW — STEP 3 (QUOTES) ============================ */ function QuotesFlow(state) { if (state.quotesLoading) { return `
${Stepper(2)} ${QuotesSkeleton()}
`; } if (!state.quotes.length) { return `
${Stepper(2)}

Your roof, in a few clicks.

Start by telling us about your roof and address. We’ll use open maps and show instant quotes.

`; } return `
${Stepper(2)}

Compare your roofing options

Estimated area: ${state.currentJob.area} sq ft

${state.quotes.map(QuoteCard).join("")} ${ state.currentJob && state.currentJob.id ? Button("Pay deposit", `createPaymentForJob('${state.currentJob.id}')`, { variant: "ghost" }) : "" }
`; } /* ============================ ROOFER LOGIN ============================ */ function RooferLogin(state) { return `

Roofer Login

Access your jobs, messages, and verification tools.

${Button("Login", "rooferLogin()")}
`; } async function rooferLogin() { const email = document.getElementById("rooferEmail").value; const password = document.getElementById("rooferPassword").value; try { const cred = await firebaseLogin(email, password); Store.set({ currentUser: cred.user, role: "roofer" }); showToast("Logged in as roofer.", "success"); navigate("rooferDashboard"); loadRooferData(); } catch (e) { console.error(e); alert("Login failed."); } } /* ============================ ROOFER DASHBOARD ============================ */ async function loadRooferData() { const user = Store.state.currentUser; if (!user) return; const roofersSnap = await firebaseQuery("roofers", [ { field: "auth_user_id", op: "==", value: user.uid } ]); const roofer = roofersSnap[0] || null; const jobsSnap = await firebaseQuery( "jobs", [{ field: "roofer_id", op: "==", value: roofer?.id || "" }], { field: "created_at", direction: "desc" } ); Store.set({ currentRooferProfile: roofer, jobs: jobsSnap }); } function JobCard(j) { return Card(`
Job #${j.id}
${j.area || ""} sq ft • ${j.material || ""} • pitch: ${j.pitch || ""}
${j.address || ""}
${Chip(j.status || "Lead", "primary")}
${Button("Advance", `advanceJobStatus('${j.id}');event.stopPropagation();`)} ${Button("Messages", `goToMessaging('${j.id}');event.stopPropagation();`, { variant: "ghost" })}
`); } async function advanceJobStatus(jobId) { const job = Store.state.jobs.find(j => j.id === jobId); if (!job) return; const JOB_STATUS = ["Lead", "Estimate", "Contract", "Scheduled", "Completed"]; const idx = JOB_STATUS.indexOf(job.status); const nextStatus = JOB_STATUS[Math.min(idx + 1, JOB_STATUS.length - 1)]; await firebaseUpdate("jobs", jobId, { status: nextStatus }); const jobs = Store.state.jobs.map(j => j.id === jobId ? { ...j, status: nextStatus } : j ); Store.set({ jobs }); showToast(`Job #${jobId} moved to ${nextStatus}.`, "info"); } function goToMessaging(jobId) { navigate("messaging", { jobId }); loadMessages(jobId); } function RooferDashboard(state) { const r = state.currentRooferProfile; if (!state.currentUser || state.role !== "roofer") { return `

You must log in as a roofer.

${Button("Go to roofer login", "navigate('loginRoofer')")}
`; } if (!r) { loadRooferData(); return `
${JobsSkeleton()}
`; } return `

Welcome back, ${r.name}

Manage your leads, verify roofs, and move jobs through the pipeline.

⭐ ${r.rating || "New"} rated
${ (r.badges || []) .map(b => `${b}`) .join("") || "Add badges to your profile in the admin panel." }
Base rate: $${r.base_rate || "—"}/sq ft
Smart rate: $${r.smart_base_rate || "—"}/sq ft

Jobs

Tap a job to update status or chat with the client.

${ state.jobs && state.jobs.length ? state.jobs.map(JobCard).join("") : JobsSkeleton() }
`; } /* ============================ ROOFER PROFILE ============================ */ function goToRooferProfile(rooferId) { navigate("rooferProfile", { rooferId }); } function RooferProfile(state) { const rooferId = state.routeParams.rooferId; const roofer = Store.state.roofers.find(r => r.id === rooferId); if (!roofer) { return `

Roofer not found

`; } return `

${roofer.name}

${Chip(`⭐ ${roofer.rating || "New"}`, "primary")}

${roofer.bio || "No bio available."}

Rates

Base rate: $${roofer.base_rate || "—"}/sq ft

Smart rate: $${roofer.smart_base_rate || "—"}/sq ft

Badges

${ (roofer.badges || []) .map(b => `${b}`) .join("") || "No badges yet." }

${Button("Back to quotes", "navigate('quotes')", { variant: "ghost" })}
`; } /* ============================ ADMIN DASHBOARD ============================ */ async function loadAdminData() { const clients = await firebaseGet("clients"); const roofers = await firebaseGet("roofers"); const jobs = await firebaseGet("jobs"); const quotes = await firebaseGet("quotes"); Store.set({ admin: { clients, roofers, jobs, quotes } }); } function AdminDashboard(state) { if (!state.admin) { loadAdminData(); return `

Loading admin data…

`; } const a = state.admin; return `

Admin Dashboard

Monitor clients, roofers, jobs, and quotes.

${Card(`

Overview

Clients: ${a.clients.length}

Roofers: ${a.roofers.length}

Jobs: ${a.jobs.length}

Quotes: ${a.quotes.length}

`)}

Latest Jobs

${a.jobs .slice(-10) .map( j => `
Job #${j.id} – ${j.status || ""} – ${j.material || ""} – ${j.area || ""} sq ft
` ) .join("")}
`; } /* ============================ CLIENT DASHBOARD ============================ */ function ClientDashboard(state) { const jobs = state.clientJobs || []; return `

Your Jobs

${ jobs.length ? jobs .map( j => `
Job #${j.id}
${j.area} sq ft • ${j.material}
${Chip(j.status, "primary")}
` ) .join("") : "

No jobs yet.

" }
`; } /* ============================ REGISTER VIEWS IN ROUTER ============================ */ Routes.client = ClientFlow; Routes.address = AddressFlow; Routes.quotes = QuotesFlow; Routes.rooferDashboard = RooferDashboard; Routes.loginRoofer = RooferLogin; Routes.admin = AdminDashboard; Routes.messaging = MessagingView; Routes.rooferProfile = RooferProfile; Routes.clientDashboard = ClientDashboard; /* ============================ APP BOOTSTRAP ============================ */ function renderApp(state) { const View = Routes[state.route] || ClientFlow; return ` ${View(state)} ${ToastContainer(state)} ${ModalContainer(state)} `; } function mountApp() { const root = document.getElementById("view"); Store.on(state => { root.innerHTML = renderApp(state); }); } function loadInitialRoute() { const hash = window.location.hash.replace("#", "") || "client"; const [route, param] = hash.split("-"); const params = {}; if (route === "messaging" && param) params.jobId = param; if (route === "rooferProfile" && param) params.rooferId = param; Store.set({ route, routeParams: params }); } function initApp() { loadInitialRoute(); mountApp(); } initApp();