.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);
}
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 `
${m.title}
${m.contentHTML}
`;
}
/* ============================
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(`
${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 `
`;
}
/* ============================
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 `
`;
}
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 `
`;
}
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();