/* Loadpath — flow screens, part 1: eligibility → upload → classify → quote → pay */ (function () { const { Icon, Btn, Badge, Callout, ConfDot, SrcTag, Card, Money, KV, AddressInput, engineerTypeLabel, } = window; const h = React.createElement; const { useState, useEffect, useRef } = React; // ---------------- Eligibility ---------------- function ScreenEligibility({ project, go }) { const initialAddr = [project && project.address, project && project.city] .filter(Boolean) .join(", "); const [addr, setAddr] = useState(initialAddr); const [phase, setPhase] = useState("idle"); // idle | checking | ok | error const [eligibility, setEligibility] = useState(null); const [err, setErr] = useState(""); async function check() { const address = (addr || "").trim(); if (!address) { setErr("Project address is required"); setPhase("error"); return; } setPhase("checking"); setErr(""); setEligibility(null); try { const result = await window.API.eligibility.check(address); setEligibility(result); if (!result || !result.eligible) { setErr( (result && result.message) || "This address is outside the supported service area", ); setPhase("error"); return; } setPhase("ok"); } catch (e) { setErr(e.message || "Unable to geocode this address"); setPhase("error"); } } const codeEdition = (eligibility && eligibility.code_edition) || ""; const codeLabel = codeEdition ? codeEdition.split("·")[0].trim() : "Resolved by jurisdiction"; const stateCode = (eligibility && eligibility.state) || (project && project.state) || ""; const stateLabel = (eligibility && (eligibility.state_name || stateName(eligibility.state))) || stateName(stateCode) || stateCode || "project state"; const jurisdiction = (eligibility && eligibility.jurisdiction) || "jurisdiction resolved"; return h( "div", { className: "fade-in" }, h( "div", { className: "eyebrow screen-eyebrow" }, "Step 1 · Intake & eligibility", ), h("h1", { className: "screen-title" }, "Where is the project?"), h( "p", { className: "screen-sub" }, "Eligibility is determined by the ", h("b", null, "project address"), " — never your business address. We operate in 38 states today; design criteria are pulled per address.", ), h( Card, null, h( "div", { className: "field" }, h( "label", null, "Project address ", h("span", { className: "req" }, "*"), ), h( "div", { style: { display: "flex", gap: 10 } }, h(AddressInput, { className: "input", value: addr, onChange: (e) => { setAddr(e.target.value); setPhase("idle"); setErr(""); setEligibility(null); }, onFill: ({ street, city, state, zip }) => { setAddr( [street, city, state + " " + zip].filter(Boolean).join(", "), ); setPhase("idle"); setErr(""); setEligibility(null); }, placeholder: "Street, City, ST ZIP", }), h( Btn, { kind: "secondary", icon: "map-pin", onClick: check, disabled: phase === "checking" || !(addr || "").trim(), }, "Check", ), ), ), phase === "checking" && h( "div", { className: "proc-log", style: { marginTop: 8 } }, h( "div", { className: "ln active" }, h("span", { className: "spin" }), "Geocoding address → resolving jurisdiction…", ), ), phase === "error" && err && h( "div", { className: "fade-in", style: { marginTop: 8 } }, h( Callout, { kind: "danger", title: "Eligibility check failed" }, err, ), ), phase === "ok" && h( "div", { className: "fade-in", style: { marginTop: 8 } }, h( Callout, { kind: "success", title: "Covered — " + stateLabel }, "This project is inside our service area. Sealed downstream by a PE licensed in ", stateCode || stateLabel, ". ", "Jurisdiction: ", h("b", null, jurisdiction), ". ", "Code edition resolved: ", h("b", null, codeLabel), ".", ), ), ), h( "div", { style: { marginTop: 16, display: "flex", gap: 10, alignItems: "center", fontSize: 12.5, color: "var(--fg-subtle)", }, }, h(Icon, { name: "info", size: 14 }), "Outside the 38 states, we capture the lead and notify on expansion — no upload required.", ), h( "div", { className: "btn-row end", style: { marginTop: 24 } }, h( Btn, { kind: "primary", iconRight: "arrow-right", disabled: phase !== "ok", onClick: go.next, }, "Continue to upload", ), ), ); } // ---------------- Upload ---------------- function ScreenUpload({ project, go }) { const [drag, setDrag] = useState(false); const [file, setFile] = useState(null); const hasQuote = project.fee && project.status === "quoted"; const [current, setCurrent] = useState(project); const initialScreen = project.status === "missing_info" ? "missing" : project.status === "rejected" ? "blocked" : hasQuote ? "pass" : project.drawingFileID ? "ready" : "idle"; const [screen, setScreen] = useState(initialScreen); // idle | ready | uploading | screening | classifying | pass | blocked | missing | error const [err, setErr] = useState(""); const [parseError, setParseError] = useState(""); const [rawText, setRawText] = useState(""); const [classifyPromptUsed, setClassifyPromptUsed] = useState(""); const inputRef = useRef(null); function choose(f) { if (!f) return; setFile(f); setCurrent(project); setErr(""); setParseError(""); setRawText(""); setClassifyPromptUsed(""); uploadAndScreen(f); } function updateCurrent(p) { if (!p) return; setCurrent(p); if (go.updateProject) go.updateProject(p); } async function classifyUploadedDrawing() { setScreen("classifying"); const classified = await window.API.projects.classify(project.id); const proj = classified && classified.project ? classified.project : classified; updateCurrent(proj); setRawText(proj.classify_raw || ""); setClassifyPromptUsed(classified.classify_prompt || ""); if (classified && classified.parse_error) { setParseError(classified.parse_error); setScreen("error"); return; } if (proj.truss || proj.status === "rejected") { setScreen("blocked"); return; } if (proj.status === "missing_info") { setScreen("missing"); return; } if (!proj.fee) { setErr("Quote was not created. Please re-run drawing analysis."); setScreen("error"); return; } setScreen("pass"); setTimeout(() => { if (go.toQuote) go.toQuote(); else go.next(); }, 550); } async function analyzeExistingDrawing() { setErr(""); setParseError(""); try { setScreen("screening"); const screened = await window.API.projects.preScreen(project.id); const screenedProject = screened && screened.project ? screened.project : current; updateCurrent(screenedProject); if (screenedProject.status === "rejected") { setScreen("blocked"); return; } await classifyUploadedDrawing(); } catch (e) { setErr(e.message || "Drawing analysis failed"); setScreen("error"); } } async function uploadAndScreen(selectedFile) { const drawing = selectedFile || file; if (!drawing) return; setErr(""); setParseError(""); try { setScreen("uploading"); const uploaded = await window.API.projects.upload(project.id, drawing); updateCurrent(uploaded.project || current); setScreen("screening"); const screened = await window.API.projects.preScreen(project.id); const screenedProject = screened && screened.project ? screened.project : current; updateCurrent(screenedProject); if (screenedProject.status === "rejected") { setScreen("blocked"); return; } await classifyUploadedDrawing(); } catch (e) { setErr(e.message || "Upload and analysis failed"); setScreen("error"); } } const busy = screen === "uploading" || screen === "screening" || screen === "classifying"; const showFile = !!file || !!current.drawingFileID; const fileName = file ? file.name : "Uploaded drawing set"; const fileMeta = file ? (file.size / 1024 / 1024).toFixed(2) + " MB" : "Saved PDF"; return h( "div", { className: "fade-in" }, h("div", { className: "eyebrow screen-eyebrow" }, "Upload"), h("h1", { className: "screen-title" }, "Upload the solar drawing"), h( "p", { className: "screen-sub" }, "Upload one clear solar drawing PDF. We check readability, classify the project, count calculation routes, and prepare the quote before any payment.", ), !showFile ? h( "div", { className: "dropzone" + (drag ? " drag" : ""), onClick: () => inputRef.current && inputRef.current.click(), onDragOver: (e) => { e.preventDefault(); setDrag(true); }, onDragLeave: () => setDrag(false), onDrop: (e) => { e.preventDefault(); setDrag(false); choose(e.dataTransfer.files && e.dataTransfer.files[0]); }, }, h("input", { ref: inputRef, type: "file", accept: "application/pdf,.pdf", style: { display: "none" }, onChange: (e) => choose(e.target.files && e.target.files[0]), }), h( "div", { className: "dz-icon" }, h(Icon, { name: "upload", size: 44, stroke: 1.25 }), ), h("h4", null, "Drop the drawing PDF, or click to select"), h("p", null, "Multi-sheet PDFs are welcome · max 20 MB"), ) : h( "div", { className: "fade-in" }, h( "div", { className: "filechip" }, h("div", { className: "fi" }, "PDF"), h( "div", { className: "meta" }, h("div", { className: "fn" }, fileName), h("div", { className: "fm" }, fileMeta), ), (screen === "idle" || screen === "ready") && file && h(Btn, { kind: "ghost", icon: "x", size: "sm", onClick: () => setFile(null), }), screen === "pass" && h(Badge, { kind: "pass", icon: "check" }, "Quote ready"), ), screen === "ready" && h( "div", { className: "btn-row", style: { marginTop: 14 } }, h( Btn, { kind: "secondary", icon: "scan-line", onClick: analyzeExistingDrawing, }, "Analyze saved drawing", ), ), busy && h( Card, { className: "scanline", tight: true }, h( "div", { className: "proc-log" }, h( "div", { className: "ln " + (screen === "uploading" ? "active" : ""), }, screen === "uploading" ? h("span", { className: "spin" }) : h(Icon, { name: "check", size: 14, className: "tick", style: { color: "var(--signal-success)" }, }), "Storing and processing PDF", ), h( "div", { className: "ln " + (screen === "screening" ? "active" : screen === "classifying" ? "" : "pending"), }, screen === "screening" ? h("span", { className: "spin" }) : screen === "classifying" ? h(Icon, { name: "check", size: 14, className: "tick", style: { color: "var(--signal-success)" }, }) : h("span", { className: "tick", style: { width: 14 }, }), "Checking readability and scope", ), h( "div", { className: "ln " + (screen === "classifying" ? "active" : "pending"), }, screen === "classifying" ? h("span", { className: "spin" }) : h("span", { className: "tick", style: { width: 14 } }), "Classifying project and preparing quote", ), ), ), err && h( "div", { style: { color: "var(--signal-danger)", fontSize: 13, marginTop: 10, }, }, err, ), parseError && h( "div", { style: { marginTop: 12 } }, h( Callout, { kind: "warn", title: "Response received — structured parsing failed", }, parseError, ), ), screen === "pass" && h( "div", { style: { marginTop: 14 } }, h( Callout, { kind: "success", title: "Drawing accepted · quote ready" }, "The project is in scope and the price is ready. Moving to Quote.", ), ), ), screen === "blocked" && h( "div", { style: { marginTop: 16 } }, h(TrussOutOfScope, { project: current, go }), ), screen === "missing" && h( "div", { style: { marginTop: 16 } }, h(MissingInfoReport, { project: current, go }), ), rawText && (screen === "error" || screen === "blocked" || screen === "missing") && h(RawResponseBlock, { text: rawText, classifyPrompt: classifyPromptUsed, }), h( "div", { className: "btn-row end", style: { marginTop: 24 } }, h( Btn, { kind: "primary", iconRight: "arrow-right", disabled: screen !== "pass", onClick: go.toQuote || go.next, }, "View quote", ), ), ); } // ---------------- Classify ---------------- function ScreenClassify({ project, tweaks, go }) { const style = tweaks.extractionStyle || "log"; const [done, setDone] = useState(false); const [animDone, setAnimDone] = useState(false); const [err, setErr] = useState(""); const [result, setResult] = useState(null); const [rawText, setRawText] = useState(""); const [parseError, setParseError] = useState(""); const [classifyPromptUsed, setClassifyPromptUsed] = useState(""); useEffect(() => { let alive = true; setErr(""); setParseError(""); setRawText(""); setClassifyPromptUsed(""); window.API.projects .classify(project.id) .then((d) => { if (!alive) return; // API returns {project, classify_prompt} on success, or {project, parse_error, classify_prompt} on parse failure const proj = d && d.project ? d.project : d; const pe = d && d.parse_error ? d.parse_error : ""; setRawText(proj.classify_raw || ""); setParseError(pe); setClassifyPromptUsed(d.classify_prompt || ""); setResult(proj); if (go.updateProject) go.updateProject(proj); setDone(true); }) .catch((e) => { if (alive) setErr(e.message || "Classification failed"); }); return () => { alive = false; }; }, [project.id]); const current = result || project; const raw = rawText || current.classify_raw || ""; return h( "div", { className: "fade-in" }, h( "div", { className: "eyebrow screen-eyebrow" }, "Step 3 · Classification & extraction", ), h("h1", { className: "screen-title" }, "Reading the drawing"), h( "p", { className: "screen-sub" }, "Our engine extracts every project-specific value with its source sheet, classifies the project, counts distinct framing combinations, and detects rafter vs. truss. The drawing always wins over any stated type.", ), err ? h(Callout, { kind: "danger", title: "Classification failed" }, err) : !done ? h( "div", null, !animDone ? h(Extraction, { project: current, style, onDone: () => setAnimDone(true), }) : h( Card, null, h( "div", { className: "proc-log" }, h( "div", { className: "ln active" }, h("span", { className: "spin" }), "Reading and analyzing the drawing — this typically takes 30–60 s…", ), ), ), ) : parseError ? h( "div", { className: "fade-in" }, classifyPromptUsed ? h( Callout, { kind: "info", title: "Custom prompt response" }, "Claude responded using the admin system prompt. See the response block below.", ) : h( Callout, { kind: "warn", title: "Response received — structured parsing failed", }, parseError, ), h("div", null), ) : h( "div", { className: "fade-in" }, current.truss || current.status === "rejected" ? h(TrussOutOfScope, { project: current, go }) : current.status === "missing_info" ? h(MissingInfoReport, { project: current, go }) : h(ClassifyResult, { project: current, go }), ), done && raw && h(RawResponseBlock, { text: raw, classifyPrompt: classifyPromptUsed }), ); } // ---------------- Raw Response Block ---------------- function RawResponseBlock({ text, classifyPrompt }) { const [open, setOpen] = useState(true); const [copied, setCopied] = useState(false); const [promptOpen, setPromptOpen] = useState(false); // Detect if text is JSON or markdown let isJSON = false; let pretty = text; try { pretty = JSON.stringify(JSON.parse(text), null, 2); isJSON = true; } catch (e) {} // Render markdown via marked.js if available and text is not JSON const isMarkdown = !isJSON && typeof window.marked !== "undefined"; const mdHtml = isMarkdown ? window.marked.parse(text || "") : null; const copy = () => { navigator.clipboard .writeText(text) .then(() => { setCopied(true); setTimeout(() => setCopied(false), 1800); }) .catch(() => {}); }; const preStyle = { marginTop: 8, padding: 10, background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 6, fontSize: 11.5, fontFamily: "var(--font-mono)", overflowX: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: 300, overflowY: "auto", color: "var(--fg)", lineHeight: 1.6, }; const mdStyle = { marginTop: 8, padding: "12px 16px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 6, fontSize: 13, lineHeight: 1.7, color: "var(--fg)", maxHeight: 600, overflowY: "auto", }; return h( "div", { style: { marginTop: 20, display: "flex", flexDirection: "column", gap: 8, }, }, classifyPrompt && h( Card, { tight: true }, h( "div", { style: { display: "flex", alignItems: "center", gap: 8 } }, h(Icon, { name: "settings", size: 13, style: { color: "var(--accent)" }, }), h( "span", { className: "eyebrow", style: { margin: 0, flex: 1, color: "var(--accent)" }, }, "Admin system prompt (active)", ), h( "button", { style: { fontSize: 12, color: "var(--fg-subtle)", background: "none", border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer", padding: "3px 10px", fontFamily: "inherit", }, onClick: () => setPromptOpen((o) => !o), }, promptOpen ? "▲ Collapse" : "▼ Show", ), ), promptOpen && h("pre", { style: preStyle }, classifyPrompt), ), h( Card, { tight: true }, h( "div", { style: { display: "flex", alignItems: "center", gap: 8 } }, h(Icon, { name: "terminal", size: 13, style: { color: "var(--fg-subtle)" }, }), h( "span", { className: "eyebrow", style: { margin: 0, flex: 1 } }, isMarkdown ? "Claude response (markdown)" : "Raw API response", ), h( "button", { style: { fontSize: 12, color: "var(--fg-subtle)", background: "none", border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer", padding: "3px 10px", fontFamily: "inherit", }, onClick: copy, }, copied ? "✓ Copied" : "Copy", ), h( "button", { style: { fontSize: 12, color: "var(--fg-subtle)", background: "none", border: "1px solid var(--border)", borderRadius: 4, cursor: "pointer", padding: "3px 10px", fontFamily: "inherit", marginLeft: 4, }, onClick: () => setOpen((o) => !o), }, open ? "▲ Collapse" : "▼ Expand", ), ), open && (isMarkdown ? h("div", { style: mdStyle, className: "md-body", dangerouslySetInnerHTML: { __html: mdHtml }, }) : h("pre", { style: { ...preStyle, maxHeight: 500 } }, pretty)), ), ); } function Extraction({ project, style, onDone }) { const steps = [ "Loading sheets PV1.0 … PV6.0", "Vision pass — reading module layout, racking, attachment grid", "Extracting framing: rafter size, spacing, span, pitch", "Cross-checking values across sheets (verification pass)", "Classifying type · counting framing combinations · rafter vs. truss", "Resolving zoning via Zoneomics (" + (project.zoning || "pending") + ")", ]; const [i, setI] = useState(0); useEffect(() => { if (i >= steps.length) { const t = setTimeout(onDone, 350); return () => clearTimeout(t); } const t = setTimeout(() => setI(i + 1), 520); return () => clearTimeout(t); }, [i]); return h( Card, { className: style === "overlay" ? "scanline" : "" }, h( "div", { className: "eyebrow", style: { marginBottom: 14 } }, "Analysis · extraction", ), h( "div", { className: "proc-log" }, steps.map((s, k) => h( "div", { key: k, className: "ln " + (k < i ? "" : k === i ? "active" : "pending"), }, k < i ? h(Icon, { name: "check", size: 14, className: "tick", style: { color: "var(--signal-success)" }, }) : k === i ? h("span", { className: "spin" }) : h("span", { className: "tick", style: { width: 14 } }), s, ), ), ), ); } function ClassifyResult({ project, go }) { const sys = project.system || {}; const extracted = project.extracted || []; const combinations = project.combinations || []; const calcUnits = project.calcUnits || project.calc_units || []; const routes = engineerTypes(project); return h( "div", { className: "stagger" }, h( "div", { className: "grid-2", style: { marginBottom: 16 } }, h( Card, { tight: true, eyebrow: "Classification" }, h(KV, { k: "Project type" }, project.type || "—"), h( KV, { k: "Framing combinations" }, h("b", null, project.combosCount), ), h( KV, { k: "Engineering routes" }, routes.length ? routes.map((t) => h(Badge, { key: t, kind: "neutral" }, engineerTypeLabel(t)), ) : "—", ), h( KV, { k: "Framing" }, h(Badge, { kind: "pass", icon: "check" }, "Rafter — in scope"), ), h(KV, { k: "Zoning" }, project.zoning || "—"), ), h( Card, { tight: true, eyebrow: "System" }, h(KV, { k: "DC / AC" }, (sys.dc || "—") + " / " + (sys.ac || "—")), h(KV, { k: "Modules" }, sys.modules ? sys.modules + " ea" : "—"), h(KV, { k: "Racking" }, sys.racking || "—"), h(KV, { k: "Attachment" }, sys.attach || "—"), ), ), extracted.length > 0 && h( Card, { eyebrow: "Extracted values · " + extracted.length + " fields", title: null, right: h( "span", { style: { display: "flex", gap: 14, fontSize: 11.5, color: "var(--fg-subtle)", alignItems: "center", }, }, h( "span", { style: { display: "flex", gap: 5, alignItems: "center" } }, h(ConfDot, { level: "high" }), "high", ), h( "span", { style: { display: "flex", gap: 5, alignItems: "center" } }, h(ConfDot, { level: "med" }), "verify", ), h( "span", { style: { display: "flex", gap: 5, alignItems: "center" } }, h(ConfDot, { level: "low" }), "flagged", ), ), }, h( "table", { className: "dtable" }, h( "thead", null, h( "tr", null, h("th", null, "Field"), h("th", null, "Value"), h("th", null, "Source"), h("th", null, "Conf"), ), ), h( "tbody", null, extracted.map((e, k) => h( "tr", { key: k }, h("td", { className: "name" }, e.name), h( "td", { className: "val" }, e.value, " ", h("span", { className: "unit" }, e.unit), ), h( "td", null, h( "div", { className: "src" }, h(SrcTag, { sheet: e.sheet }), ), ), h("td", null, h(ConfDot, { level: e.conf })), ), ), ), ), ), calcUnits.length > 0 && h( Card, { eyebrow: "Calculation units · " + calcUnits.length + " routed", title: null, }, h( "div", { className: "combo-list" }, calcUnits.map((u, i) => h( "div", { key: i, className: "combo" }, h( "div", { className: "ci" }, h(Icon, { name: "calculator", size: 16 }), ), h( "div", null, h( "div", { className: "ctitle" }, "Unit " + (u.unitID || u.unit_id || i + 1) + " · " + (u.description || "Calculation unit"), ), h( "div", { className: "csub" }, [u.structure, u.mount, u.framing] .filter(Boolean) .join(" · ") || "Route details pending", ), ), h( Badge, { kind: "neutral" }, engineerTypeLabel(u.engineerType || u.engineer_type || "—"), ), ), ), ), ), project.combosCount > 1 && h( "div", { style: { marginTop: 16 } }, h( Callout, { kind: "accent", icon: "layers", title: project.combosCount + " distinct framing combinations · " + (routes.length || 1) + " engineering route" + ((routes.length || 1) > 1 ? "s" : ""), }, "Claude uses the classified project scope for the quote. Engineering runs by engineer_type route, with one calculation and one PE letter per route.", ), ), h( "div", { className: "btn-row end", style: { marginTop: 24 } }, h( Btn, { kind: "primary", iconRight: "arrow-right", onClick: go.next }, "See the quote", ), ), ); } function TrussOutOfScope({ project, go }) { const isTruss = !!project.truss; return h( "div", { className: "fade-in" }, isTruss ? h( Callout, { kind: "danger", icon: "alert-octagon", title: "Out of scope — engineered truss roof", }, "This platform provides PV structural engineering for ", h("b", null, "sawn-rafter"), " roof framing. The drawing shows an engineered truss roof, which we do not cover — a manufactured truss cannot be modified or sistered without the truss manufacturer\u2019s engineer. Please consult the truss manufacturer\u2019s engineer for this structure.", ) : h( Callout, { kind: "danger", icon: "alert-octagon", title: "Out of scope — declined", }, "This project falls outside accepted scope for PV structural engineering. See the detailed explanation in the response block below.", ), h( Card, { tight: true, style: { marginTop: 16 }, eyebrow: "What we detected" }, (project.extracted || []).length ? project.extracted.map((e, k) => h(KV, { key: k, k: e.name }, e.value), ) : h( "div", { style: { fontSize: 12.5, color: "var(--fg-muted)" } }, project.outcome || "The drawing is outside the current automated scope.", ), ), h("div", null), ); } function MissingInfoReport({ project, go }) { const missing = project.missing || []; return h( "div", { className: "fade-in" }, h( Callout, { kind: "warn", icon: "alert-triangle", title: "Missing-Information Report — specific items required", }, "Your drawing passed the basic pre-screen, but some of the values required for the analysis appear to be missing or inconsistent. A quick update will have everything ready to go.", ), missing.length > 0 && h( Card, { style: { marginTop: 16 }, eyebrow: "What's blocking the analysis" }, h( "table", { className: "dtable" }, h( "thead", null, h( "tr", null, h("th", null, "Item"), h("th", null, "Reason"), h("th", null, "Needed for"), ), ), h( "tbody", null, missing.map((m, i) => h( "tr", { key: i }, h("td", { className: "name" }, m.item), h("td", { style: { color: "var(--fg-muted)" } }, m.reason), h("td", null, h("span", { className: "srctag" }, m.section)), ), ), ), ), ), h( "div", { style: { marginTop: 16 } }, h( Callout, { kind: "info" }, h( "b", null, "No assumptions will be made for missing project-specific data.", ), " Please provide the items above and re-upload; the analysis will then be completed.", ), ), h( "div", { className: "btn-row end", style: { marginTop: 24 } }, h( Btn, { kind: "primary", icon: "upload", onClick: go.toUpload }, "Re-upload corrected drawing", ), ), ); } // ---------------- Quote ---------------- // ScreenQuote is no longer used as a separate step — merged into ScreenPay below. function ScreenQuote({ project, tweaks, go }) { return h(ScreenPay, { project, tweaks, go }); } function CountUp({ to, dur = 900 }) { const [v, setV] = useState(0); useEffect(() => { let raf, start, done = false; const finish = () => { if (!done) { done = true; setV(to); } }; const tick = (t) => { if (!start) start = t; const p = Math.min((t - start) / dur, 1); setV(to * (1 - Math.pow(1 - p, 3))); if (p < 1) raf = requestAnimationFrame(tick); else finish(); }; raf = requestAnimationFrame(tick); // Fallback: rAF is throttled to 0 in background/preview iframes — guarantee the final value. const fb = setTimeout(finish, dur + 120); return () => { cancelAnimationFrame(raf); clearTimeout(fb); }; }, [to]); const [d, c] = v.toFixed(2).split("."); return h( "span", { className: "quote-amount big" }, "$", d, h("span", { className: "quote-cents" }, "." + c), ); } function QuoteReveal({ project }) { return h( "div", { className: "quote-hero" }, h( "div", { className: "eyebrow", style: { marginBottom: 10 } }, "Project-specific quote", ), h(CountUp, { to: project.fee }), h("div", { className: "quote-basis" }, project.feeBasis), h( "div", { style: { marginTop: 16, fontSize: 12.5, color: "var(--fg-subtle)", display: "flex", gap: 8, justifyContent: "center", alignItems: "center", }, }, h(Icon, { name: "zap", size: 14, style: { color: "var(--accent)" } }), "Immediate turnaround on the generated package", ), ); } function QuoteBreakdown({ project }) { return h( Card, null, h( "div", { className: "eyebrow", style: { marginBottom: 8 } }, "Quote scope", ), h( KV, { k: "Project type" }, project.type || "Solar structural engineering", ), h(KV, { k: "Use" }, project.zoning || "—"), h(KV, { k: "Framing combinations" }, project.combosCount || 1), h( KV, { k: "Calculation packages" }, engineerTypes(project).length || project.combosCount || 1, ), h( "p", { style: { margin: "14px 0 0", fontSize: 12.5, color: "var(--fg-muted)", lineHeight: 1.55, }, }, project.feeBasis, ), h("div", { className: "divider" }), h( "div", { style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", }, }, h("span", { style: { fontWeight: 600 } }, "Your fee"), h(Money, { amount: project.fee }), ), ); } function QuoteMinimal({ project }) { return h( Card, null, h( "div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", }, }, h( "div", null, h("div", { className: "eyebrow" }, "Total"), h( "div", { style: { fontSize: 13, color: "var(--fg-muted)", marginTop: 6 } }, project.feeBasis, ), ), h(Money, { amount: project.fee, size: "big" }), ), ); } // ---------------- Payment (merged Quote + Pay) ---------------- function ScreenPay({ project, tweaks }) { const [phase, setPhase] = useState("idle"); const [errMsg, setErrMsg] = useState(""); const fee = project.fee; const quoteMissing = !fee; const style = (tweaks && tweaks.quoteStyle) || "reveal"; const startCheckout = async () => { setPhase("loading"); setErrMsg(""); try { const data = await window.API.projects.checkout(project.id); if (!data.checkout_url) throw new Error("No checkout URL returned"); setPhase("redirecting"); window.location.href = data.checkout_url; } catch (e) { setErrMsg(e.message || "Failed to start payment"); setPhase("error"); } }; const busy = phase === "loading" || phase === "redirecting"; if (phase === "redirecting") { return h( "div", { className: "fade-in", style: { textAlign: "center", padding: "60px 0" }, }, h("span", { className: "spin", style: { width: 32, height: 32, borderWidth: 3, borderTopColor: "var(--accent)", borderColor: "var(--border)", display: "inline-block", marginBottom: 20, }, }), h("h2", null, "Redirecting to Stripe…"), h( "p", { style: { color: "var(--fg-muted)" } }, "You will be returned here after payment.", ), ); } const orderCard = h( Card, { tight: true, eyebrow: "Order" }, h( KV, { k: (project.feeBasis || "").split("·")[0].trim() || "Fee" }, fmt(fee), ), h(KV, { k: "Combinations" }, project.combosCount || 1), h("div", { className: "divider" }), h( "div", { style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 8, }, }, h("span", { style: { fontWeight: 650 } }, "Total"), h(Money, { amount: fee }), ), h( "div", { style: { fontSize: 12, color: "var(--fg-muted)", display: "flex", gap: 6, alignItems: "center", marginTop: 4, }, }, h(Icon, { name: "shield-check", size: 13 }), "Secured by Stripe", ), errMsg && h( "div", { style: { marginTop: 8, color: "var(--signal-danger)", fontSize: 12, }, }, errMsg, ), ); return h( "div", { className: "fade-in" }, h("div", { className: "eyebrow screen-eyebrow" }, "Payment"), h("h1", { className: "screen-title" }, "Your price"), h( "p", { className: "screen-sub" }, "We have determined this project-specific price. After payment you'll be brought back here automatically.", ), h( "div", { className: "grid-2-1" }, h( "div", null, quoteMissing && h( Callout, { kind: "danger", title: "Quote unavailable" }, "This project needs to be classified again before it can be quoted.", ), !quoteMissing && style === "reveal" && h(QuoteReveal, { project }), !quoteMissing && style === "breakdown" && h(QuoteBreakdown, { project }), !quoteMissing && style === "minimal" && h(QuoteMinimal, { project }), ), orderCard, ), h( "div", { className: "btn-row end", style: { marginTop: 24 } }, h( Btn, { kind: "primary", icon: busy ? null : "credit-card", onClick: startCheckout, disabled: busy || quoteMissing, }, busy ? h( React.Fragment, null, h("span", { className: "spin", style: { borderTopColor: "#fff", borderColor: "rgba(255,255,255,.4)", }, }), "Loading…", ) : "Pay with Stripe · " + fmt(fee), ), ), ); } function fmt(n) { return n == null ? "—" : "$" + n.toFixed(2); } function engineerTypes(project) { const seen = {}; (project.calcUnits || project.calc_units || []).forEach((u) => { const t = String(u.engineerType || u.engineer_type || "") .trim() .toUpperCase(); if (t) seen[t] = true; }); return Object.keys(seen).sort(); } function stateName(s) { const m = { CT: "Connecticut", MA: "Massachusetts", NJ: "New Jersey" }; return m[s] || s; } Object.assign(window, { ScreenEligibility, ScreenUpload, ScreenClassify, ScreenQuote, ScreenPay, }); })();