@archerjessop/utilities 7.12.0 → 7.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/widget/createPanel.js +1 -1
- package/dist/browser/widget/createPanel.js.map +1 -1
- package/dist/browser/widget/finance.js +1 -1
- package/dist/browser/widget/finance.js.map +1 -1
- package/dist/browser/widget/interestRateSync.js +2 -0
- package/dist/browser/widget/interestRateSync.js.map +1 -0
- package/dist/browser/widget/nav.js +1 -1
- package/dist/browser/widget/nav.js.map +1 -1
- package/dist/browser/widget/pipeline.js +1 -1
- package/dist/browser/widget/pipeline.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const
|
|
1
|
+
import{syncInterestRateForUnits as n}from"./interestRateSync.js";const e=[{value:"multifamily",label:"Multifamily"},{value:"str",label:"STR"},{value:"assisted",label:"Assisted/Co Living"},{value:"business",label:"Business"},{value:"mixed_use",label:"Mixed Use"},{value:"rv_park",label:"RV Park"}];function createPanel(n){const{cssUrls:e=[],defaultPropertyType:a="multifamily",callbacks:s={}}=n||{};console.log("🎨 createPanel() called");const t=document.getElementById("ln-footer");t&&(console.log("🗑️ Removing existing footer"),t.remove()),console.log("📦 Loading CSS files...");const i=e.map(n=>{const e=document.createElement("link");return e.rel="stylesheet",e.href=n,e});let l=0;const c=i.length,onLoad=()=>{l++,console.log(`📄 CSS file loaded (${l}/${c})`),l===c&&(console.log("✨ All CSS loaded, creating footer elements"),createPanelElements(a,s))};i.forEach(n=>{n.onload=onLoad}),console.log("⏰ Setting 100ms fallback timeout"),setTimeout(()=>{console.log("⚠️ Fallback timeout reached, creating footer elements anyway"),createPanelElements(a,s)},100),i.forEach(n=>{document.head.appendChild(n)})}function createPanelElements(a,s){if(document.getElementById("ln-footer"))return;const{updateState:t}=s,i=document.createElement("div");i.id="ln-footer",i.className="ext-footer",i.innerHTML=`\n <div class="footer-container">\n <div class="footer-content">\n <div class="metrics-grid">\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Address</span>\n <span id="prop-name" class="metric-value clickable prop-name">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Lead Status</span>\n <span id="prop-lead-status" class="metric-value prop-lead-status">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Price</span>\n <span id="prop-price" class="metric-value triangle prop-price">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Cash Flow</span>\n <span id="prop-cashflow" class="metric-value weight-semibold prop-cashflow">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Cap Rate</span>\n <span id="prop-cap" class="metric-value prop-cap">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">NOI</span>\n <span id="prop-noi" class="metric-value prop-noi">Loading...</span>\n <a id="prop-noi-awning" class="noi-awning-link" target="_blank" rel="noopener" title="Open Awning's calculator and copy this address to the clipboard" style="display:none;cursor:pointer;font-size:11px;font-weight:600;color:#200955;text-decoration:none;margin-top:2px;">↗ Awning</a>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">COCR (15%)</span>\n <span id="prop-cocr-15" class="metric-value prop-cocr-15">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">COCR (30%)</span>\n <span id="prop-cocr-30" class="metric-value prop-cocr-30">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric prop-dom-metric">\n <span class="metric-label">DOM</span>\n <span id="prop-dom" class="metric-value triangle prop-dom">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Equity</span>\n <span id="prop-equity" class="metric-value prop-equity">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Seller FI (40%)</span>\n <span id="prop-seller-fi" class="metric-value prop-seller-fi">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Down (60%)</span>\n <span id="prop-down" class="metric-value triangle prop-down">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">DSCR (70%)</span>\n <span id="prop-dscr" class="metric-value prop-dscr">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">SF Payment</span>\n <span id="prop-sf" class="metric-value prop-sf">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Contact</span>\n <span id="prop-contact" class="metric-value prop-contact">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Phone</span>\n <span id="prop-phone" class="metric-value prop-contact">Loading...</span>\n </div>\n </div>\n\n <div class="metric-column">\n <div class="metric">\n <span class="metric-label">Net to Buyer</span>\n <span id="prop-net" class="metric-value prop-net">Loading...</span>\n </div>\n <div class="metric">\n <span class="metric-label">Assignment</span>\n <span id="prop-assignment" class="metric-value prop-assignment">Loading...</span>\n </div>\n </div>\n </div>\n </div>\n <div class="footer-controls">\n <div class="footer-controls-col">\n <div class="units-input-row">\n <input type="number" id="ln-units-input" class="units-input" min="1" max="999" value="4">\n <span class="units-inline-label">units</span>\n </div>\n <button class="btn-discount" id="ln-discount-btn">85% of Asking</button>\n </div>\n <div class="footer-controls-col">\n <select class="dropdown" id="ln-property-type">\n ${function(n){return e.map(({value:e,label:a})=>`<option value="${e}"${e===n?" selected":""}>${a}</option>`).join("\n ")}(a)}\n </select>\n <select class="dropdown" id="ln-interest-rate-type">\n <option value="dscr_residential" selected>DSCR Res (8%)</option>\n <option value="dscr_commercial">DSCR Com (10%)</option>\n <option value="commercial">Commercial (10%)</option>\n <option value="mixed_use">Mixed Use (10%)</option>\n <option value="rv_park">RV Park (11%)</option>\n </select>\n <button class="btn-primary" id="ln-export-btn" title="Open dashboard with property data">\n <svg class="icon" viewBox="0 0 24 24">\n <path d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z" />\n </svg>\n Dashboard\n </button>\n </div>\n </div>\n </div>\n `;try{document.body?document.body.appendChild(i):document.documentElement&&document.documentElement.appendChild(i)}catch(n){}const l=document.getElementById("ln-export-btn");l&&l.addEventListener("click",()=>{s.onExportClick?.()});const c=document.getElementById("ln-property-type");c&&c.addEventListener("change",()=>{s.onPropertyTypeChange?.(c.value)});const o=document.getElementById("ln-interest-rate-type");o&&o.addEventListener("change",()=>{t({currentInterestRateType:o.value}),s.onInterestRateTypeChange?.(o.value)});const r=document.getElementById("ln-units-input");return r&&r.addEventListener("change",()=>{const e=parseInt(r.value)||4;t({numberOfUnits:e});const a=s.state&&n(s.state,t,e);a&&s.onInterestRateTypeChange?.(a)}),i}export{createPanel};
|
|
2
2
|
//# sourceMappingURL=createPanel.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createPanel.js","sources":["../../../src/browser/widget/createPanel.js"],"sourcesContent":["// Shared analysis-panel (footer) builder for the property analyzers.\r\n//\r\n// Extracted verbatim from each analyzer's dom-utils.js createFooter/createFooterElements.\r\n// Per-platform values are injected so this module has no Chrome or global-state dependency:\r\n// - cssUrls: stylesheet hrefs (callers resolve chrome.runtime.getURL themselves)\r\n// - defaultPropertyType: which property-type <option> is pre-selected (loopnet \"multifamily\", zillow \"str\")\r\n// - callbacks.state / callbacks.updateState: the per-platform state singleton\r\n// - callbacks.onExportClick / onPropertyTypeChange / onInterestRateTypeChange: optional notifications\r\n// (today these are CustomEvent dispatches; callers that need them pass a handler, others omit)\r\n\r\nconst PROPERTY_TYPE_OPTIONS = [\r\n { value: \"multifamily\", label: \"Multifamily\" },\r\n { value: \"str\", label: \"STR\" },\r\n { value: \"assisted\", label: \"Assisted/Co Living\" },\r\n { value: \"business\", label: \"Business\" },\r\n { value: \"mixed_use\", label: \"Mixed Use\" },\r\n { value: \"rv_park\", label: \"RV Park\" }\r\n];\r\n\r\nfunction renderPropertyTypeOptions(defaultPropertyType) {\r\n return PROPERTY_TYPE_OPTIONS.map(({ value, label }) => {\r\n const selected = value === defaultPropertyType ? \" selected\" : \"\";\r\n return `<option value=\"${value}\"${selected}>${label}</option>`;\r\n }).join(\"\\n \");\r\n}\r\n\r\nexport function createPanel(config) {\r\n const { cssUrls = [], defaultPropertyType = \"multifamily\", callbacks = {} } = config || {};\r\n\r\n console.log(\"🎨 createPanel() called\");\r\n\r\n const existing = document.getElementById(\"ln-footer\");\r\n if (existing) {\r\n console.log(\"🗑️ Removing existing footer\");\r\n existing.remove();\r\n }\r\n\r\n console.log(\"📦 Loading CSS files...\");\r\n\r\n const links = cssUrls.map((href) => {\r\n const link = document.createElement(\"link\");\r\n link.rel = \"stylesheet\";\r\n link.href = href;\r\n return link;\r\n });\r\n\r\n // Wait for all stylesheets to load before creating footer\r\n let loadedCount = 0;\r\n const total = links.length;\r\n const onLoad = () => {\r\n loadedCount++;\r\n console.log(`📄 CSS file loaded (${loadedCount}/${total})`);\r\n if (loadedCount === total) {\r\n console.log(\"✨ All CSS loaded, creating footer elements\");\r\n createPanelElements(defaultPropertyType, callbacks);\r\n }\r\n };\r\n\r\n links.forEach((link) => { link.onload = onLoad; });\r\n\r\n console.log(\"⏰ Setting 100ms fallback timeout\");\r\n // Fallback - create footer after timeout even if shared CSS doesn't load\r\n setTimeout(() => {\r\n console.log(\"⚠️ Fallback timeout reached, creating footer elements anyway\");\r\n createPanelElements(defaultPropertyType, callbacks);\r\n }, 100);\r\n\r\n links.forEach((link) => { document.head.appendChild(link); });\r\n}\r\n\r\nfunction createPanelElements(defaultPropertyType, callbacks) {\r\n // Prevent duplicate creation\r\n if (document.getElementById(\"ln-footer\")) return;\r\n\r\n const { updateState } = callbacks;\r\n\r\n const footer = document.createElement(\"div\");\r\n footer.id = \"ln-footer\";\r\n footer.className = \"ext-footer\";\r\n\r\n footer.innerHTML = `\r\n <div class=\"footer-container\">\r\n <div class=\"footer-content\">\r\n <div class=\"metrics-grid\">\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Address</span>\r\n <span id=\"prop-name\" class=\"metric-value clickable prop-name\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Lead Status</span>\r\n <span id=\"prop-lead-status\" class=\"metric-value prop-lead-status\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Price</span>\r\n <span id=\"prop-price\" class=\"metric-value triangle prop-price\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Cash Flow</span>\r\n <span id=\"prop-cashflow\" class=\"metric-value weight-semibold prop-cashflow\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Cap Rate</span>\r\n <span id=\"prop-cap\" class=\"metric-value prop-cap\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">NOI</span>\r\n <span id=\"prop-noi\" class=\"metric-value prop-noi\">Loading...</span>\r\n <a id=\"prop-noi-awning\" class=\"noi-awning-link\" target=\"_blank\" rel=\"noopener\" title=\"Open Awning's calculator and copy this address to the clipboard\" style=\"display:none;cursor:pointer;font-size:11px;font-weight:600;color:#200955;text-decoration:none;margin-top:2px;\">↗ Awning</a>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">COCR (15%)</span>\r\n <span id=\"prop-cocr-15\" class=\"metric-value prop-cocr-15\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">COCR (30%)</span>\r\n <span id=\"prop-cocr-30\" class=\"metric-value prop-cocr-30\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric prop-dom-metric\">\r\n <span class=\"metric-label\">DOM</span>\r\n <span id=\"prop-dom\" class=\"metric-value triangle prop-dom\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Equity</span>\r\n <span id=\"prop-equity\" class=\"metric-value prop-equity\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Seller FI (40%)</span>\r\n <span id=\"prop-seller-fi\" class=\"metric-value prop-seller-fi\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Down (60%)</span>\r\n <span id=\"prop-down\" class=\"metric-value triangle prop-down\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">DSCR (70%)</span>\r\n <span id=\"prop-dscr\" class=\"metric-value prop-dscr\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">SF Payment</span>\r\n <span id=\"prop-sf\" class=\"metric-value prop-sf\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Contact</span>\r\n <span id=\"prop-contact\" class=\"metric-value prop-contact\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Phone</span>\r\n <span id=\"prop-phone\" class=\"metric-value prop-contact\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Net to Buyer</span>\r\n <span id=\"prop-net\" class=\"metric-value prop-net\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Assignment</span>\r\n <span id=\"prop-assignment\" class=\"metric-value prop-assignment\">Loading...</span>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n <div class=\"footer-controls\">\r\n <div class=\"footer-controls-col\">\r\n <div class=\"units-input-row\">\r\n <input type=\"number\" id=\"ln-units-input\" class=\"units-input\" min=\"1\" max=\"999\" value=\"4\">\r\n <span class=\"units-inline-label\">units</span>\r\n </div>\r\n <button class=\"btn-discount\" id=\"ln-discount-btn\">85% of Asking</button>\r\n </div>\r\n <div class=\"footer-controls-col\">\r\n <select class=\"dropdown\" id=\"ln-property-type\">\r\n ${renderPropertyTypeOptions(defaultPropertyType)}\r\n </select>\r\n <select class=\"dropdown\" id=\"ln-interest-rate-type\">\r\n <option value=\"dscr_residential\" selected>DSCR Res (8%)</option>\r\n <option value=\"dscr_commercial\">DSCR Com (10%)</option>\r\n <option value=\"commercial\">Commercial (10%)</option>\r\n <option value=\"mixed_use\">Mixed Use (10%)</option>\r\n <option value=\"rv_park\">RV Park (11%)</option>\r\n </select>\r\n <button class=\"btn-primary\" id=\"ln-export-btn\" title=\"Open dashboard with property data\">\r\n <svg class=\"icon\" viewBox=\"0 0 24 24\">\r\n <path d=\"M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z\" />\r\n </svg>\r\n Dashboard\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n `;\r\n\r\n try {\r\n if (document.body) {\r\n document.body.appendChild(footer);\r\n } else if (document.documentElement) {\r\n document.documentElement.appendChild(footer);\r\n }\r\n } catch (error) {\r\n // Silent fail\r\n }\r\n\r\n const exportBtn = document.getElementById(\"ln-export-btn\");\r\n if (exportBtn) {\r\n exportBtn.addEventListener(\"click\", () => {\r\n callbacks.onExportClick?.();\r\n });\r\n }\r\n\r\n const propertyTypeDropdown = document.getElementById(\"ln-property-type\");\r\n if (propertyTypeDropdown) {\r\n propertyTypeDropdown.addEventListener(\"change\", () => {\r\n callbacks.onPropertyTypeChange?.(propertyTypeDropdown.value);\r\n });\r\n }\r\n\r\n const interestRateTypeDropdown = document.getElementById(\"ln-interest-rate-type\");\r\n if (interestRateTypeDropdown) {\r\n interestRateTypeDropdown.addEventListener(\"change\", () => {\r\n updateState({ currentInterestRateType: interestRateTypeDropdown.value });\r\n callbacks.onInterestRateTypeChange?.(interestRateTypeDropdown.value);\r\n });\r\n }\r\n\r\n const unitsInput = document.getElementById(\"ln-units-input\");\r\n if (unitsInput) {\r\n unitsInput.addEventListener(\"change\", () => {\r\n const value = parseInt(unitsInput.value) || 4;\r\n updateState({ numberOfUnits: value });\r\n\r\n const irDropdown = document.getElementById(\"ln-interest-rate-type\");\r\n if (irDropdown) {\r\n if (value > 11 && irDropdown.value !== \"dscr_commercial\") {\r\n irDropdown.value = \"dscr_commercial\";\r\n updateState({ currentInterestRateType: \"dscr_commercial\" });\r\n callbacks.onInterestRateTypeChange?.(\"dscr_commercial\");\r\n } else if (value <= 11 && irDropdown.value === \"dscr_commercial\") {\r\n irDropdown.value = \"dscr_residential\";\r\n updateState({ currentInterestRateType: \"dscr_residential\" });\r\n callbacks.onInterestRateTypeChange?.(\"dscr_residential\");\r\n }\r\n }\r\n });\r\n }\r\n\r\n return footer;\r\n}\r\n"],"names":["PROPERTY_TYPE_OPTIONS","value","label","createPanel","config","cssUrls","defaultPropertyType","callbacks","console","log","existing","document","getElementById","remove","links","map","href","link","createElement","rel","loadedCount","total","length","onLoad","createPanelElements","forEach","onload","setTimeout","head","appendChild","updateState","footer","id","className","innerHTML","join","renderPropertyTypeOptions","body","documentElement","error","exportBtn","addEventListener","onExportClick","propertyTypeDropdown","onPropertyTypeChange","interestRateTypeDropdown","currentInterestRateType","onInterestRateTypeChange","unitsInput","parseInt","numberOfUnits","irDropdown"],"mappings":"AAUA,MAAMA,EAAwB,CAC5B,CAAEC,MAAO,cAAeC,MAAO,eAC/B,CAAED,MAAO,MAAOC,MAAO,OACvB,CAAED,MAAO,WAAYC,MAAO,sBAC5B,CAAED,MAAO,WAAYC,MAAO,YAC5B,CAAED,MAAO,YAAaC,MAAO,aAC7B,CAAED,MAAO,UAAWC,MAAO,YAUtB,SAASC,YAAYC,GAC1B,MAAMC,QAAEA,EAAU,GAAEC,oBAAEA,EAAsB,cAAaC,UAAEA,EAAY,CAAA,GAAOH,GAAU,GAExFI,QAAQC,IAAI,2BAEZ,MAAMC,EAAWC,SAASC,eAAe,aACrCF,IACFF,QAAQC,IAAI,gCACZC,EAASG,UAGXL,QAAQC,IAAI,2BAEZ,MAAMK,EAAQT,EAAQU,IAAKC,IACzB,MAAMC,EAAON,SAASO,cAAc,QAGpC,OAFAD,EAAKE,IAAM,aACXF,EAAKD,KAAOA,EACLC,IAIT,IAAIG,EAAc,EAClB,MAAMC,EAAQP,EAAMQ,OACdC,OAAS,KACbH,IACAZ,QAAQC,IAAI,uBAAuBW,KAAeC,MAC9CD,IAAgBC,IAClBb,QAAQC,IAAI,8CACZe,oBAAoBlB,EAAqBC,KAI7CO,EAAMW,QAASR,IAAWA,EAAKS,OAASH,SAExCf,QAAQC,IAAI,oCAEZkB,WAAW,KACTnB,QAAQC,IAAI,gEACZe,oBAAoBlB,EAAqBC,IACxC,KAEHO,EAAMW,QAASR,IAAWN,SAASiB,KAAKC,YAAYZ,IACtD,CAEA,SAASO,oBAAoBlB,EAAqBC,GAEhD,GAAII,SAASC,eAAe,aAAc,OAE1C,MAAMkB,YAAEA,GAAgBvB,EAElBwB,EAASpB,SAASO,cAAc,OACtCa,EAAOC,GAAK,YACZD,EAAOE,UAAY,aAEnBF,EAAOG,UAAY,8iKA7DrB,SAAmC5B,GACjC,OAAON,EAAsBe,IAAI,EAAGd,QAAOC,WAElC,kBAAkBD,KADRA,IAAUK,EAAsB,YAAc,MACjBJ,cAC7CiC,KAAK,iBACV,CA2KcC,CAA0B9B,2zBAoBtC,IACMK,SAAS0B,KACX1B,SAAS0B,KAAKR,YAAYE,GACjBpB,SAAS2B,iBAClB3B,SAAS2B,gBAAgBT,YAAYE,EAEzC,CAAE,MAAOQ,GAET,CAEA,MAAMC,EAAY7B,SAASC,eAAe,iBACtC4B,GACFA,EAAUC,iBAAiB,QAAS,KAClClC,EAAUmC,oBAId,MAAMC,EAAuBhC,SAASC,eAAe,oBACjD+B,GACFA,EAAqBF,iBAAiB,SAAU,KAC9ClC,EAAUqC,uBAAuBD,EAAqB1C,SAI1D,MAAM4C,EAA2BlC,SAASC,eAAe,yBACrDiC,GACFA,EAAyBJ,iBAAiB,SAAU,KAClDX,EAAY,CAAEgB,wBAAyBD,EAAyB5C,QAChEM,EAAUwC,2BAA2BF,EAAyB5C,SAIlE,MAAM+C,EAAarC,SAASC,eAAe,kBAqB3C,OApBIoC,GACFA,EAAWP,iBAAiB,SAAU,KACpC,MAAMxC,EAAQgD,SAASD,EAAW/C,QAAU,EAC5C6B,EAAY,CAAEoB,cAAejD,IAE7B,MAAMkD,EAAaxC,SAASC,eAAe,yBACvCuC,IACElD,EAAQ,IAA2B,oBAArBkD,EAAWlD,OAC3BkD,EAAWlD,MAAQ,kBACnB6B,EAAY,CAAEgB,wBAAyB,oBACvCvC,EAAUwC,2BAA2B,oBAC5B9C,GAAS,IAA2B,oBAArBkD,EAAWlD,QACnCkD,EAAWlD,MAAQ,mBACnB6B,EAAY,CAAEgB,wBAAyB,qBACvCvC,EAAUwC,2BAA2B,wBAMtChB,CACT"}
|
|
1
|
+
{"version":3,"file":"createPanel.js","sources":["../../../src/browser/widget/createPanel.js"],"sourcesContent":["// Shared analysis-panel (footer) builder for the property analyzers.\r\n//\r\n// Extracted verbatim from each analyzer's dom-utils.js createFooter/createFooterElements.\r\n// Per-platform values are injected so this module has no Chrome or global-state dependency:\r\n// - cssUrls: stylesheet hrefs (callers resolve chrome.runtime.getURL themselves)\r\n// - defaultPropertyType: which property-type <option> is pre-selected (loopnet \"multifamily\", zillow \"str\")\r\n// - callbacks.state / callbacks.updateState: the per-platform state singleton\r\n// - callbacks.onExportClick / onPropertyTypeChange / onInterestRateTypeChange: optional notifications\r\n// (today these are CustomEvent dispatches; callers that need them pass a handler, others omit)\r\n\r\nimport { syncInterestRateForUnits } from \"./interestRateSync.js\";\r\n\r\nconst PROPERTY_TYPE_OPTIONS = [\r\n { value: \"multifamily\", label: \"Multifamily\" },\r\n { value: \"str\", label: \"STR\" },\r\n { value: \"assisted\", label: \"Assisted/Co Living\" },\r\n { value: \"business\", label: \"Business\" },\r\n { value: \"mixed_use\", label: \"Mixed Use\" },\r\n { value: \"rv_park\", label: \"RV Park\" }\r\n];\r\n\r\nfunction renderPropertyTypeOptions(defaultPropertyType) {\r\n return PROPERTY_TYPE_OPTIONS.map(({ value, label }) => {\r\n const selected = value === defaultPropertyType ? \" selected\" : \"\";\r\n return `<option value=\"${value}\"${selected}>${label}</option>`;\r\n }).join(\"\\n \");\r\n}\r\n\r\nexport function createPanel(config) {\r\n const { cssUrls = [], defaultPropertyType = \"multifamily\", callbacks = {} } = config || {};\r\n\r\n console.log(\"🎨 createPanel() called\");\r\n\r\n const existing = document.getElementById(\"ln-footer\");\r\n if (existing) {\r\n console.log(\"🗑️ Removing existing footer\");\r\n existing.remove();\r\n }\r\n\r\n console.log(\"📦 Loading CSS files...\");\r\n\r\n const links = cssUrls.map((href) => {\r\n const link = document.createElement(\"link\");\r\n link.rel = \"stylesheet\";\r\n link.href = href;\r\n return link;\r\n });\r\n\r\n // Wait for all stylesheets to load before creating footer\r\n let loadedCount = 0;\r\n const total = links.length;\r\n const onLoad = () => {\r\n loadedCount++;\r\n console.log(`📄 CSS file loaded (${loadedCount}/${total})`);\r\n if (loadedCount === total) {\r\n console.log(\"✨ All CSS loaded, creating footer elements\");\r\n createPanelElements(defaultPropertyType, callbacks);\r\n }\r\n };\r\n\r\n links.forEach((link) => { link.onload = onLoad; });\r\n\r\n console.log(\"⏰ Setting 100ms fallback timeout\");\r\n // Fallback - create footer after timeout even if shared CSS doesn't load\r\n setTimeout(() => {\r\n console.log(\"⚠️ Fallback timeout reached, creating footer elements anyway\");\r\n createPanelElements(defaultPropertyType, callbacks);\r\n }, 100);\r\n\r\n links.forEach((link) => { document.head.appendChild(link); });\r\n}\r\n\r\nfunction createPanelElements(defaultPropertyType, callbacks) {\r\n // Prevent duplicate creation\r\n if (document.getElementById(\"ln-footer\")) return;\r\n\r\n const { updateState } = callbacks;\r\n\r\n const footer = document.createElement(\"div\");\r\n footer.id = \"ln-footer\";\r\n footer.className = \"ext-footer\";\r\n\r\n footer.innerHTML = `\r\n <div class=\"footer-container\">\r\n <div class=\"footer-content\">\r\n <div class=\"metrics-grid\">\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Address</span>\r\n <span id=\"prop-name\" class=\"metric-value clickable prop-name\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Lead Status</span>\r\n <span id=\"prop-lead-status\" class=\"metric-value prop-lead-status\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Price</span>\r\n <span id=\"prop-price\" class=\"metric-value triangle prop-price\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Cash Flow</span>\r\n <span id=\"prop-cashflow\" class=\"metric-value weight-semibold prop-cashflow\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Cap Rate</span>\r\n <span id=\"prop-cap\" class=\"metric-value prop-cap\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">NOI</span>\r\n <span id=\"prop-noi\" class=\"metric-value prop-noi\">Loading...</span>\r\n <a id=\"prop-noi-awning\" class=\"noi-awning-link\" target=\"_blank\" rel=\"noopener\" title=\"Open Awning's calculator and copy this address to the clipboard\" style=\"display:none;cursor:pointer;font-size:11px;font-weight:600;color:#200955;text-decoration:none;margin-top:2px;\">↗ Awning</a>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">COCR (15%)</span>\r\n <span id=\"prop-cocr-15\" class=\"metric-value prop-cocr-15\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">COCR (30%)</span>\r\n <span id=\"prop-cocr-30\" class=\"metric-value prop-cocr-30\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric prop-dom-metric\">\r\n <span class=\"metric-label\">DOM</span>\r\n <span id=\"prop-dom\" class=\"metric-value triangle prop-dom\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Equity</span>\r\n <span id=\"prop-equity\" class=\"metric-value prop-equity\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Seller FI (40%)</span>\r\n <span id=\"prop-seller-fi\" class=\"metric-value prop-seller-fi\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Down (60%)</span>\r\n <span id=\"prop-down\" class=\"metric-value triangle prop-down\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">DSCR (70%)</span>\r\n <span id=\"prop-dscr\" class=\"metric-value prop-dscr\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">SF Payment</span>\r\n <span id=\"prop-sf\" class=\"metric-value prop-sf\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Contact</span>\r\n <span id=\"prop-contact\" class=\"metric-value prop-contact\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Phone</span>\r\n <span id=\"prop-phone\" class=\"metric-value prop-contact\">Loading...</span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"metric-column\">\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Net to Buyer</span>\r\n <span id=\"prop-net\" class=\"metric-value prop-net\">Loading...</span>\r\n </div>\r\n <div class=\"metric\">\r\n <span class=\"metric-label\">Assignment</span>\r\n <span id=\"prop-assignment\" class=\"metric-value prop-assignment\">Loading...</span>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n <div class=\"footer-controls\">\r\n <div class=\"footer-controls-col\">\r\n <div class=\"units-input-row\">\r\n <input type=\"number\" id=\"ln-units-input\" class=\"units-input\" min=\"1\" max=\"999\" value=\"4\">\r\n <span class=\"units-inline-label\">units</span>\r\n </div>\r\n <button class=\"btn-discount\" id=\"ln-discount-btn\">85% of Asking</button>\r\n </div>\r\n <div class=\"footer-controls-col\">\r\n <select class=\"dropdown\" id=\"ln-property-type\">\r\n ${renderPropertyTypeOptions(defaultPropertyType)}\r\n </select>\r\n <select class=\"dropdown\" id=\"ln-interest-rate-type\">\r\n <option value=\"dscr_residential\" selected>DSCR Res (8%)</option>\r\n <option value=\"dscr_commercial\">DSCR Com (10%)</option>\r\n <option value=\"commercial\">Commercial (10%)</option>\r\n <option value=\"mixed_use\">Mixed Use (10%)</option>\r\n <option value=\"rv_park\">RV Park (11%)</option>\r\n </select>\r\n <button class=\"btn-primary\" id=\"ln-export-btn\" title=\"Open dashboard with property data\">\r\n <svg class=\"icon\" viewBox=\"0 0 24 24\">\r\n <path d=\"M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z\" />\r\n </svg>\r\n Dashboard\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n `;\r\n\r\n try {\r\n if (document.body) {\r\n document.body.appendChild(footer);\r\n } else if (document.documentElement) {\r\n document.documentElement.appendChild(footer);\r\n }\r\n } catch (error) {\r\n // Silent fail\r\n }\r\n\r\n const exportBtn = document.getElementById(\"ln-export-btn\");\r\n if (exportBtn) {\r\n exportBtn.addEventListener(\"click\", () => {\r\n callbacks.onExportClick?.();\r\n });\r\n }\r\n\r\n const propertyTypeDropdown = document.getElementById(\"ln-property-type\");\r\n if (propertyTypeDropdown) {\r\n propertyTypeDropdown.addEventListener(\"change\", () => {\r\n callbacks.onPropertyTypeChange?.(propertyTypeDropdown.value);\r\n });\r\n }\r\n\r\n const interestRateTypeDropdown = document.getElementById(\"ln-interest-rate-type\");\r\n if (interestRateTypeDropdown) {\r\n interestRateTypeDropdown.addEventListener(\"change\", () => {\r\n updateState({ currentInterestRateType: interestRateTypeDropdown.value });\r\n callbacks.onInterestRateTypeChange?.(interestRateTypeDropdown.value);\r\n });\r\n }\r\n\r\n const unitsInput = document.getElementById(\"ln-units-input\");\r\n if (unitsInput) {\r\n unitsInput.addEventListener(\"change\", () => {\r\n const value = parseInt(unitsInput.value) || 4;\r\n updateState({ numberOfUnits: value });\r\n\r\n const newRateType = callbacks.state && syncInterestRateForUnits(callbacks.state, updateState, value);\r\n if (newRateType) callbacks.onInterestRateTypeChange?.(newRateType);\r\n });\r\n }\r\n\r\n return footer;\r\n}\r\n"],"names":["PROPERTY_TYPE_OPTIONS","value","label","createPanel","config","cssUrls","defaultPropertyType","callbacks","console","log","existing","document","getElementById","remove","links","map","href","link","createElement","rel","loadedCount","total","length","onLoad","createPanelElements","forEach","onload","setTimeout","head","appendChild","updateState","footer","id","className","innerHTML","join","renderPropertyTypeOptions","body","documentElement","error","exportBtn","addEventListener","onExportClick","propertyTypeDropdown","onPropertyTypeChange","interestRateTypeDropdown","currentInterestRateType","onInterestRateTypeChange","unitsInput","parseInt","numberOfUnits","newRateType","state","syncInterestRateForUnits"],"mappings":"iEAYA,MAAMA,EAAwB,CAC5B,CAAEC,MAAO,cAAeC,MAAO,eAC/B,CAAED,MAAO,MAAOC,MAAO,OACvB,CAAED,MAAO,WAAYC,MAAO,sBAC5B,CAAED,MAAO,WAAYC,MAAO,YAC5B,CAAED,MAAO,YAAaC,MAAO,aAC7B,CAAED,MAAO,UAAWC,MAAO,YAUtB,SAASC,YAAYC,GAC1B,MAAMC,QAAEA,EAAU,GAAEC,oBAAEA,EAAsB,cAAaC,UAAEA,EAAY,CAAA,GAAOH,GAAU,GAExFI,QAAQC,IAAI,2BAEZ,MAAMC,EAAWC,SAASC,eAAe,aACrCF,IACFF,QAAQC,IAAI,gCACZC,EAASG,UAGXL,QAAQC,IAAI,2BAEZ,MAAMK,EAAQT,EAAQU,IAAKC,IACzB,MAAMC,EAAON,SAASO,cAAc,QAGpC,OAFAD,EAAKE,IAAM,aACXF,EAAKD,KAAOA,EACLC,IAIT,IAAIG,EAAc,EAClB,MAAMC,EAAQP,EAAMQ,OACdC,OAAS,KACbH,IACAZ,QAAQC,IAAI,uBAAuBW,KAAeC,MAC9CD,IAAgBC,IAClBb,QAAQC,IAAI,8CACZe,oBAAoBlB,EAAqBC,KAI7CO,EAAMW,QAASR,IAAWA,EAAKS,OAASH,SAExCf,QAAQC,IAAI,oCAEZkB,WAAW,KACTnB,QAAQC,IAAI,gEACZe,oBAAoBlB,EAAqBC,IACxC,KAEHO,EAAMW,QAASR,IAAWN,SAASiB,KAAKC,YAAYZ,IACtD,CAEA,SAASO,oBAAoBlB,EAAqBC,GAEhD,GAAII,SAASC,eAAe,aAAc,OAE1C,MAAMkB,YAAEA,GAAgBvB,EAElBwB,EAASpB,SAASO,cAAc,OACtCa,EAAOC,GAAK,YACZD,EAAOE,UAAY,aAEnBF,EAAOG,UAAY,8iKA7DrB,SAAmC5B,GACjC,OAAON,EAAsBe,IAAI,EAAGd,QAAOC,WAElC,kBAAkBD,KADRA,IAAUK,EAAsB,YAAc,MACjBJ,cAC7CiC,KAAK,iBACV,CA2KcC,CAA0B9B,2zBAoBtC,IACMK,SAAS0B,KACX1B,SAAS0B,KAAKR,YAAYE,GACjBpB,SAAS2B,iBAClB3B,SAAS2B,gBAAgBT,YAAYE,EAEzC,CAAE,MAAOQ,GAET,CAEA,MAAMC,EAAY7B,SAASC,eAAe,iBACtC4B,GACFA,EAAUC,iBAAiB,QAAS,KAClClC,EAAUmC,oBAId,MAAMC,EAAuBhC,SAASC,eAAe,oBACjD+B,GACFA,EAAqBF,iBAAiB,SAAU,KAC9ClC,EAAUqC,uBAAuBD,EAAqB1C,SAI1D,MAAM4C,EAA2BlC,SAASC,eAAe,yBACrDiC,GACFA,EAAyBJ,iBAAiB,SAAU,KAClDX,EAAY,CAAEgB,wBAAyBD,EAAyB5C,QAChEM,EAAUwC,2BAA2BF,EAAyB5C,SAIlE,MAAM+C,EAAarC,SAASC,eAAe,kBAW3C,OAVIoC,GACFA,EAAWP,iBAAiB,SAAU,KACpC,MAAMxC,EAAQgD,SAASD,EAAW/C,QAAU,EAC5C6B,EAAY,CAAEoB,cAAejD,IAE7B,MAAMkD,EAAc5C,EAAU6C,OAASC,EAAyB9C,EAAU6C,MAAOtB,EAAa7B,GAC1FkD,GAAa5C,EAAUwC,2BAA2BI,KAInDpB,CACT"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{computeManualOverrideNOI as t,resolveCapRateProvenance as a}from"../financial/capRate.js";import{calculateFinancials as e}from"../financial/calculateFinancials.js";import{
|
|
1
|
+
import{computeManualOverrideNOI as t,resolveCapRateProvenance as a}from"../financial/capRate.js";import{calculateFinancials as e}from"../financial/calculateFinancials.js";import{syncInterestRateForUnits as i}from"./interestRateSync.js";import{FINANCIAL_CONSTANTS as n}from"../../config/financial.js";import{normalizeWhitespace as r}from"../../formatting/text.js";const p=["name","price","capRate","contact","phone","listingDate"];function isValidListingShape(t){return!(!t||"object"!=typeof t)&&p.every(a=>"string"==typeof t[a])}function createFinance({ctx:c,adapter:l,render:o}){const{state:s,updateState:u}=c;function applyCapRate(t){const{isDefault:e,estimated:i,num:r,displayCap:p}=a(t.capRate,100*n.DEFAULT_CAP_RATE);if(t.capRate=p,e)return s.originalCapRate||u({originalCapRate:p}),s.originalMultifamilyCapRate||u({originalMultifamilyCapRate:`${r}%`}),void u({currentEstimatedCapRate:r,isUsingEstimatedCapRate:!0,originalEstimatedCapRate:r});u({isUsingEstimatedCapRate:i}),i&&null!==r&&u({currentEstimatedCapRate:r}),s.originalCapRate||u({originalCapRate:p}),s.originalMultifamilyCapRate||null===r||u({originalMultifamilyCapRate:`${r}%`})}return{applyCapRate:applyCapRate,handlePropertyTypeChange:function(){const t=document.getElementById("ln-property-type");if(!t)return;const a=t.value;return u({currentPropertyType:a}),"str"!==a&&u({cachedSTRData:null}),u({baseNOI:null}),i(s,u),a},recalculateFinancials:async function(){const a=document.getElementById("prop-price");if(document.getElementById("prop-name"),!a)return;const i=o.getCurrentPrice()||a.textContent;let n;if(s.isUsingEstimatedCapRate)n=`${s.currentEstimatedCapRate}%*`;else{const t=document.getElementById("prop-cap");n=t?t.textContent:"8%"}if("str"===s.currentPropertyType&&u({cachedSTRData:null}),s.capManuallySet){const a=t(s.originalPrice||i,s.currentEstimatedCapRate);null!=a&&u({baseNOI:a})}const r=await e(c,i,n,s.currentPropertyType);o.applyFinancials(r),o.updateActiveCapDisplay(),o.updateEquityDisplay()},scrapeAndApply:function(){const t=l.scrape();if(!isValidListingShape(t))return null;for(const a of p)t[a]=r(t[a]);const a=t.priceWasDefaulted??"Not found"===t.price;return u({priceWasDefaulted:a}),a||s.originalPrice||u({originalPrice:t.originalPrice??t.price}),applyCapRate(t),t}}}export{createFinance,isValidListingShape};
|
|
2
2
|
//# sourceMappingURL=finance.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"finance.js","sources":["../../../src/browser/widget/finance.js"],"sourcesContent":["// Finance unit (orchestration): applies cap-rate provenance to ctx, applies the scrape-derived\r\n// state, and recomputes the financial metrics. The pure rules live in financial/capRate.js; this\r\n// module is the thin state/DOM glue around them. Extracted verbatim from createAnalyzer (T12).\r\n\r\nimport { computeManualOverrideNOI, resolveCapRateProvenance } from \"../financial/capRate.js\";\r\nimport { calculateFinancials } from \"../financial/calculateFinancials.js\";\r\nimport { FINANCIAL_CONSTANTS } from \"../../config/financial.js\";\r\nimport { normalizeWhitespace } from \"../../formatting/text.js\";\r\n\r\n// The Listing fields every scraper must return as strings (default \"Not found\"). A missing or\r\n// non-string field means the scraper broke; the engine refuses to render/export rather than\r\n// letting `undefined` flow into the NOI/COCR math and silently paint wrong numbers (fail-loud).\r\nconst LISTING_CONTRACT_FIELDS = [\"name\", \"price\", \"capRate\", \"contact\", \"phone\", \"listingDate\"];\r\n\r\nexport function isValidListingShape(data) {\r\n if (!data || typeof data !== \"object\") return false;\r\n return LISTING_CONTRACT_FIELDS.every((field) => typeof data[field] === \"string\");\r\n}\r\n\r\nexport function createFinance({ ctx, adapter, render }) {\r\n const { state, updateState } = ctx;\r\n\r\n // Resolve the cap-rate provenance from the scraped string and write the cap state the\r\n // financial calc + discount handlers read. The default (DEFAULT_CAP_RATE * 100 = 5,\r\n // whole-number percent) fixes the latent no-cap bug where the decimal 0.05 was stored\r\n // and then divided by 100, computing NOI at 0.05%.\r\n function applyCapRate(listing) {\r\n const { isDefault, estimated, num, displayCap } = resolveCapRateProvenance(\r\n listing.capRate,\r\n FINANCIAL_CONSTANTS.DEFAULT_CAP_RATE * 100\r\n );\r\n listing.capRate = displayCap;\r\n\r\n if (isDefault) {\r\n if (!state.originalCapRate) updateState({ originalCapRate: displayCap });\r\n if (!state.originalMultifamilyCapRate) updateState({ originalMultifamilyCapRate: `${num}%` });\r\n updateState({\r\n currentEstimatedCapRate: num,\r\n isUsingEstimatedCapRate: true,\r\n originalEstimatedCapRate: num,\r\n });\r\n return;\r\n }\r\n\r\n updateState({ isUsingEstimatedCapRate: estimated });\r\n if (estimated && num !== null) updateState({ currentEstimatedCapRate: num });\r\n if (!state.originalCapRate) updateState({ originalCapRate: displayCap });\r\n if (!state.originalMultifamilyCapRate && num !== null) {\r\n updateState({ originalMultifamilyCapRate: `${num}%` });\r\n }\r\n }\r\n\r\n // Scrape the page and apply the universal scrape-derived state (was extractData's side\r\n // effects). Returns the resolved listing (capRate normalized to a display string) or null\r\n // when the scrape is malformed.\r\n function scrapeAndApply() {\r\n const listing = adapter.scrape();\r\n if (!isValidListingShape(listing)) return null;\r\n\r\n // Normalize whitespace on every contract string field centrally, so adapters stay pure\r\n // scrapers and no consumer (panel or export) ever sees the interior newlines/tabs that\r\n // site markup splits text across (e.g. a broker name on two lines). \"Not found\" is\r\n // unchanged. This is the single enforcement point for the data contract's \"normalize text\".\r\n for (const field of LISTING_CONTRACT_FIELDS) {\r\n listing[field] = normalizeWhitespace(listing[field]);\r\n }\r\n\r\n const priceWasDefaulted = listing.priceWasDefaulted ?? (listing.price === \"Not found\");\r\n updateState({ priceWasDefaulted });\r\n\r\n if (!priceWasDefaulted && !state.originalPrice) {\r\n updateState({ originalPrice: listing.originalPrice ?? listing.price });\r\n }\r\n\r\n applyCapRate(listing);\r\n return listing;\r\n }\r\n\r\n function handlePropertyTypeChange() {\r\n const dropdown = document.getElementById(\"ln-property-type\");\r\n if (!dropdown) return;\r\n const newType = dropdown.value;\r\n updateState({ currentPropertyType: newType });\r\n if (newType !== \"str\") updateState({ cachedSTRData: null });\r\n updateState({ baseNOI: null });\r\n return newType;\r\n }\r\n\r\n async function recalculateFinancials() {\r\n const priceElement = document.getElementById(\"prop-price\");\r\n const addressElement = document.getElementById(\"prop-name\");\r\n if (!priceElement) return;\r\n\r\n const priceText = render.getCurrentPrice() || priceElement.textContent;\r\n const address = addressElement?.textContent || \"\";\r\n let capRateText;\r\n if (state.isUsingEstimatedCapRate) {\r\n capRateText = `${state.currentEstimatedCapRate}%*`;\r\n } else {\r\n const capElement = document.getElementById(\"prop-cap\");\r\n capRateText = capElement ? capElement.textContent : \"8%\";\r\n }\r\n\r\n if (state.currentPropertyType === \"str\") updateState({ cachedSTRData: null });\r\n\r\n // Manual cap override: clicking the cap rate sets NOI = original price x cap for EVERY type\r\n // (analyst intent), so the active cap moves with the click even for STR/assisted whose NOI\r\n // is otherwise the type estimate / bedroom value. Pre-seed baseNOI so calculateFinancials\r\n // uses it instead of recomputing from the type model.\r\n if (state.capManuallySet) {\r\n const noi = computeManualOverrideNOI(state.originalPrice || priceText, state.currentEstimatedCapRate);\r\n if (noi != null) updateState({ baseNOI: noi });\r\n }\r\n\r\n const financials = await calculateFinancials(ctx, priceText, capRateText, state.currentPropertyType, address);\r\n render.applyFinancials(financials);\r\n render.updateActiveCapDisplay();\r\n render.updateEquityDisplay();\r\n }\r\n\r\n return { applyCapRate, handlePropertyTypeChange, recalculateFinancials, scrapeAndApply };\r\n}\r\n"],"names":["LISTING_CONTRACT_FIELDS","isValidListingShape","data","every","field","createFinance","ctx","adapter","render","state","updateState","applyCapRate","listing","isDefault","estimated","num","displayCap","resolveCapRateProvenance","capRate","FINANCIAL_CONSTANTS","DEFAULT_CAP_RATE","originalCapRate","originalMultifamilyCapRate","currentEstimatedCapRate","isUsingEstimatedCapRate","originalEstimatedCapRate","handlePropertyTypeChange","dropdown","document","getElementById","newType","value","currentPropertyType","cachedSTRData","baseNOI","recalculateFinancials","async","priceElement","priceText","getCurrentPrice","textContent","capRateText","capElement","capManuallySet","noi","computeManualOverrideNOI","originalPrice","financials","calculateFinancials","applyFinancials","updateActiveCapDisplay","updateEquityDisplay","scrapeAndApply","scrape","normalizeWhitespace","priceWasDefaulted","price"],"mappings":"
|
|
1
|
+
{"version":3,"file":"finance.js","sources":["../../../src/browser/widget/finance.js"],"sourcesContent":["// Finance unit (orchestration): applies cap-rate provenance to ctx, applies the scrape-derived\r\n// state, and recomputes the financial metrics. The pure rules live in financial/capRate.js; this\r\n// module is the thin state/DOM glue around them. Extracted verbatim from createAnalyzer (T12).\r\n\r\nimport { computeManualOverrideNOI, resolveCapRateProvenance } from \"../financial/capRate.js\";\r\nimport { calculateFinancials } from \"../financial/calculateFinancials.js\";\r\nimport { syncInterestRateForUnits } from \"./interestRateSync.js\";\r\nimport { FINANCIAL_CONSTANTS } from \"../../config/financial.js\";\r\nimport { normalizeWhitespace } from \"../../formatting/text.js\";\r\n\r\n// The Listing fields every scraper must return as strings (default \"Not found\"). A missing or\r\n// non-string field means the scraper broke; the engine refuses to render/export rather than\r\n// letting `undefined` flow into the NOI/COCR math and silently paint wrong numbers (fail-loud).\r\nconst LISTING_CONTRACT_FIELDS = [\"name\", \"price\", \"capRate\", \"contact\", \"phone\", \"listingDate\"];\r\n\r\nexport function isValidListingShape(data) {\r\n if (!data || typeof data !== \"object\") return false;\r\n return LISTING_CONTRACT_FIELDS.every((field) => typeof data[field] === \"string\");\r\n}\r\n\r\nexport function createFinance({ ctx, adapter, render }) {\r\n const { state, updateState } = ctx;\r\n\r\n // Resolve the cap-rate provenance from the scraped string and write the cap state the\r\n // financial calc + discount handlers read. The default (DEFAULT_CAP_RATE * 100 = 5,\r\n // whole-number percent) fixes the latent no-cap bug where the decimal 0.05 was stored\r\n // and then divided by 100, computing NOI at 0.05%.\r\n function applyCapRate(listing) {\r\n const { isDefault, estimated, num, displayCap } = resolveCapRateProvenance(\r\n listing.capRate,\r\n FINANCIAL_CONSTANTS.DEFAULT_CAP_RATE * 100\r\n );\r\n listing.capRate = displayCap;\r\n\r\n if (isDefault) {\r\n if (!state.originalCapRate) updateState({ originalCapRate: displayCap });\r\n if (!state.originalMultifamilyCapRate) updateState({ originalMultifamilyCapRate: `${num}%` });\r\n updateState({\r\n currentEstimatedCapRate: num,\r\n isUsingEstimatedCapRate: true,\r\n originalEstimatedCapRate: num,\r\n });\r\n return;\r\n }\r\n\r\n updateState({ isUsingEstimatedCapRate: estimated });\r\n if (estimated && num !== null) updateState({ currentEstimatedCapRate: num });\r\n if (!state.originalCapRate) updateState({ originalCapRate: displayCap });\r\n if (!state.originalMultifamilyCapRate && num !== null) {\r\n updateState({ originalMultifamilyCapRate: `${num}%` });\r\n }\r\n }\r\n\r\n // Scrape the page and apply the universal scrape-derived state (was extractData's side\r\n // effects). Returns the resolved listing (capRate normalized to a display string) or null\r\n // when the scrape is malformed.\r\n function scrapeAndApply() {\r\n const listing = adapter.scrape();\r\n if (!isValidListingShape(listing)) return null;\r\n\r\n // Normalize whitespace on every contract string field centrally, so adapters stay pure\r\n // scrapers and no consumer (panel or export) ever sees the interior newlines/tabs that\r\n // site markup splits text across (e.g. a broker name on two lines). \"Not found\" is\r\n // unchanged. This is the single enforcement point for the data contract's \"normalize text\".\r\n for (const field of LISTING_CONTRACT_FIELDS) {\r\n listing[field] = normalizeWhitespace(listing[field]);\r\n }\r\n\r\n const priceWasDefaulted = listing.priceWasDefaulted ?? (listing.price === \"Not found\");\r\n updateState({ priceWasDefaulted });\r\n\r\n if (!priceWasDefaulted && !state.originalPrice) {\r\n updateState({ originalPrice: listing.originalPrice ?? listing.price });\r\n }\r\n\r\n applyCapRate(listing);\r\n return listing;\r\n }\r\n\r\n function handlePropertyTypeChange() {\r\n const dropdown = document.getElementById(\"ln-property-type\");\r\n if (!dropdown) return;\r\n const newType = dropdown.value;\r\n updateState({ currentPropertyType: newType });\r\n if (newType !== \"str\") updateState({ cachedSTRData: null });\r\n updateState({ baseNOI: null });\r\n syncInterestRateForUnits(state, updateState);\r\n return newType;\r\n }\r\n\r\n async function recalculateFinancials() {\r\n const priceElement = document.getElementById(\"prop-price\");\r\n const addressElement = document.getElementById(\"prop-name\");\r\n if (!priceElement) return;\r\n\r\n const priceText = render.getCurrentPrice() || priceElement.textContent;\r\n const address = addressElement?.textContent || \"\";\r\n let capRateText;\r\n if (state.isUsingEstimatedCapRate) {\r\n capRateText = `${state.currentEstimatedCapRate}%*`;\r\n } else {\r\n const capElement = document.getElementById(\"prop-cap\");\r\n capRateText = capElement ? capElement.textContent : \"8%\";\r\n }\r\n\r\n if (state.currentPropertyType === \"str\") updateState({ cachedSTRData: null });\r\n\r\n // Manual cap override: clicking the cap rate sets NOI = original price x cap for EVERY type\r\n // (analyst intent), so the active cap moves with the click even for STR/assisted whose NOI\r\n // is otherwise the type estimate / bedroom value. Pre-seed baseNOI so calculateFinancials\r\n // uses it instead of recomputing from the type model.\r\n if (state.capManuallySet) {\r\n const noi = computeManualOverrideNOI(state.originalPrice || priceText, state.currentEstimatedCapRate);\r\n if (noi != null) updateState({ baseNOI: noi });\r\n }\r\n\r\n const financials = await calculateFinancials(ctx, priceText, capRateText, state.currentPropertyType, address);\r\n render.applyFinancials(financials);\r\n render.updateActiveCapDisplay();\r\n render.updateEquityDisplay();\r\n }\r\n\r\n return { applyCapRate, handlePropertyTypeChange, recalculateFinancials, scrapeAndApply };\r\n}\r\n"],"names":["LISTING_CONTRACT_FIELDS","isValidListingShape","data","every","field","createFinance","ctx","adapter","render","state","updateState","applyCapRate","listing","isDefault","estimated","num","displayCap","resolveCapRateProvenance","capRate","FINANCIAL_CONSTANTS","DEFAULT_CAP_RATE","originalCapRate","originalMultifamilyCapRate","currentEstimatedCapRate","isUsingEstimatedCapRate","originalEstimatedCapRate","handlePropertyTypeChange","dropdown","document","getElementById","newType","value","currentPropertyType","cachedSTRData","baseNOI","syncInterestRateForUnits","recalculateFinancials","async","priceElement","priceText","getCurrentPrice","textContent","capRateText","capElement","capManuallySet","noi","computeManualOverrideNOI","originalPrice","financials","calculateFinancials","applyFinancials","updateActiveCapDisplay","updateEquityDisplay","scrapeAndApply","scrape","normalizeWhitespace","priceWasDefaulted","price"],"mappings":"2WAaA,MAAMA,EAA0B,CAAC,OAAQ,QAAS,UAAW,UAAW,QAAS,eAE1E,SAASC,oBAAoBC,GAClC,SAAKA,GAAwB,iBAATA,IACbF,EAAwBG,MAAOC,GAAiC,iBAAhBF,EAAKE,GAC9D,CAEO,SAASC,eAAcC,IAAEA,EAAGC,QAAEA,EAAOC,OAAEA,IAC5C,MAAMC,MAAEA,EAAKC,YAAEA,GAAgBJ,EAM/B,SAASK,aAAaC,GACpB,MAAMC,UAAEA,EAASC,UAAEA,EAASC,IAAEA,EAAGC,WAAEA,GAAeC,EAChDL,EAAQM,QAC+B,IAAvCC,EAAoBC,kBAItB,GAFAR,EAAQM,QAAUF,EAEdH,EAQF,OAPKJ,EAAMY,iBAAiBX,EAAY,CAAEW,gBAAiBL,IACtDP,EAAMa,4BAA4BZ,EAAY,CAAEY,2BAA4B,GAAGP,YACpFL,EAAY,CACVa,wBAAyBR,EACzBS,yBAAyB,EACzBC,yBAA0BV,IAK9BL,EAAY,CAAEc,wBAAyBV,IACnCA,GAAqB,OAARC,GAAcL,EAAY,CAAEa,wBAAyBR,IACjEN,EAAMY,iBAAiBX,EAAY,CAAEW,gBAAiBL,IACtDP,EAAMa,4BAAsC,OAARP,GACvCL,EAAY,CAAEY,2BAA4B,GAAGP,MAEjD,CAuEA,MAAO,CAAEJ,0BAAce,yBA3CvB,WACE,MAAMC,EAAWC,SAASC,eAAe,oBACzC,IAAKF,EAAU,OACf,MAAMG,EAAUH,EAASI,MAKzB,OAJArB,EAAY,CAAEsB,oBAAqBF,IACnB,QAAZA,GAAmBpB,EAAY,CAAEuB,cAAe,OACpDvB,EAAY,CAAEwB,QAAS,OACvBC,EAAyB1B,EAAOC,GACzBoB,CACT,EAkCiDM,sBAhCjDC,iBACE,MAAMC,EAAeV,SAASC,eAAe,cAE7C,GADuBD,SAASC,eAAe,cAC1CS,EAAc,OAEnB,MAAMC,EAAY/B,EAAOgC,mBAAqBF,EAAaG,YAE3D,IAAIC,EACJ,GAAIjC,EAAMe,wBACRkB,EAAc,GAAGjC,EAAMc,gCAClB,CACL,MAAMoB,EAAaf,SAASC,eAAe,YAC3Ca,EAAcC,EAAaA,EAAWF,YAAc,IACtD,CAQA,GANkC,QAA9BhC,EAAMuB,qBAA+BtB,EAAY,CAAEuB,cAAe,OAMlExB,EAAMmC,eAAgB,CACxB,MAAMC,EAAMC,EAAyBrC,EAAMsC,eAAiBR,EAAW9B,EAAMc,yBAClE,MAAPsB,GAAanC,EAAY,CAAEwB,QAASW,GAC1C,CAEA,MAAMG,QAAmBC,EAAoB3C,EAAKiC,EAAWG,EAAajC,EAAMuB,qBAChFxB,EAAO0C,gBAAgBF,GACvBxC,EAAO2C,yBACP3C,EAAO4C,qBACT,EAEwEC,eAlExE,WACE,MAAMzC,EAAUL,EAAQ+C,SACxB,IAAKrD,oBAAoBW,GAAU,OAAO,KAM1C,IAAK,MAAMR,KAASJ,EAClBY,EAAQR,GAASmD,EAAoB3C,EAAQR,IAG/C,MAAMoD,EAAoB5C,EAAQ4C,mBAAwC,cAAlB5C,EAAQ6C,MAQhE,OAPA/C,EAAY,CAAE8C,sBAETA,GAAsB/C,EAAMsC,eAC/BrC,EAAY,CAAEqC,cAAenC,EAAQmC,eAAiBnC,EAAQ6C,QAGhE9C,aAAaC,GACNA,CACT,EA8CF"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{determineInterestRateType as e}from"../../config/financial.js";function syncInterestRateForUnits(t,r,n=t.numberOfUnits){if(!("mfr"===t.currentPropertyType||"multifamily"===t.currentPropertyType))return!1;const i=document.getElementById("ln-interest-rate-type");if(!i)return!1;const o=e(t.currentPropertyType,n);return i.value!==o&&(i.value=o,r({currentInterestRateType:o}),o)}export{syncInterestRateForUnits};
|
|
2
|
+
//# sourceMappingURL=interestRateSync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"interestRateSync.js","sources":["../../../src/browser/widget/interestRateSync.js"],"sourcesContent":["// Auto-selects the DSCR Commercial (10%) interest-rate tier for multifamily listings of 5+ units,\n// reverting to DSCR Residential (8%) below that. Delegates the threshold to determineInterestRateType\n// so the rule lives in one place. MFR-only by design: other property types keep their own tier and\n// are never force-switched here.\n\nimport { determineInterestRateType } from \"../../config/financial.js\";\n\nexport function syncInterestRateForUnits(state, updateState, unitCount = state.numberOfUnits) {\n const isMfr = state.currentPropertyType === \"mfr\" || state.currentPropertyType === \"multifamily\";\n if (!isMfr) return false;\n\n const irDropdown = document.getElementById(\"ln-interest-rate-type\");\n if (!irDropdown) return false;\n\n const target = determineInterestRateType(state.currentPropertyType, unitCount);\n if (irDropdown.value === target) return false;\n\n irDropdown.value = target;\n updateState({ currentInterestRateType: target });\n return target;\n}\n"],"names":["syncInterestRateForUnits","state","updateState","unitCount","numberOfUnits","currentPropertyType","irDropdown","document","getElementById","target","determineInterestRateType","value","currentInterestRateType"],"mappings":"sEAOO,SAASA,yBAAyBC,EAAOC,EAAaC,EAAYF,EAAMG,eAE7E,KAD4C,QAA9BH,EAAMI,qBAA+D,gBAA9BJ,EAAMI,qBAC/C,OAAO,EAEnB,MAAMC,EAAaC,SAASC,eAAe,yBAC3C,IAAKF,EAAY,OAAO,EAExB,MAAMG,EAASC,EAA0BT,EAAMI,oBAAqBF,GACpE,OAAIG,EAAWK,QAAUF,IAEzBH,EAAWK,MAAQF,EACnBP,EAAY,CAAEU,wBAAyBH,IAChCA,EACT"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
function createNav({adapter:t,config:e,ctx:n,runPipeline:a}){const{resetForNavigation:o}=n,listingId=()=>t.getListingId(window.location.href);function handleNavigation(){t.matches(window.location.href)?(o(),a()):document.getElementById("ln-footer")?.remove()}function setupSpaWatcher(){let t=listingId();const onUrlMaybeChanged=()=>{const e=listingId();e!==t&&(t=e,handleNavigation())};for(const t of["pushState","replaceState"]){const e=history[t];history[t]=function(...t){const n=e.apply(this,t);return onUrlMaybeChanged(),n}}window.addEventListener("popstate",onUrlMaybeChanged)}return{handleNavigation:handleNavigation,setupSpaWatcher:setupSpaWatcher,start:function(){t.matches(window.location.href)&&a(),!1!==e.spa&&setupSpaWatcher()}}}export{createNav};
|
|
1
|
+
function createNav({adapter:t,config:e,ctx:n,runPipeline:a}){const{resetForNavigation:o}=n,listingId=()=>t.getListingId(window.location.href);function handleNavigation(){t.matches(window.location.href)?(o(),a()):document.getElementById("ln-footer")?.remove()}function setupSpaWatcher(){let t=listingId();const onUrlMaybeChanged=()=>{const e=listingId();e!==t&&(t=e,handleNavigation())};for(const t of["pushState","replaceState"]){const e=history[t];history[t]=function(...t){const n=e.apply(this,t);return onUrlMaybeChanged(),n}}window.addEventListener("popstate",onUrlMaybeChanged),setInterval(onUrlMaybeChanged,400)}return{handleNavigation:handleNavigation,setupSpaWatcher:setupSpaWatcher,start:function(){t.matches(window.location.href)&&a(),!1!==e.spa&&setupSpaWatcher()}}}export{createNav};
|
|
2
2
|
//# sourceMappingURL=nav.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nav.js","sources":["../../../src/browser/widget/nav.js"],"sourcesContent":["// Nav unit: the always-on History-API SPA watcher + the on/off-listing navigation handler and\r\n// the start() entry point. On a full-reload site getListingId is stable so the patched History\r\n// methods simply never fire a navigation. Extracted verbatim from createAnalyzer (T12).\r\n\r\nexport function createNav({ adapter, config, ctx, runPipeline }) {\r\n const { resetForNavigation } = ctx;\r\n const listingId = () => adapter.getListingId(window.location.href);\r\n\r\n function handleNavigation() {\r\n if (!adapter.matches(window.location.href)) {\r\n document.getElementById(\"ln-footer\")?.remove();\r\n return;\r\n }\r\n resetForNavigation();\r\n runPipeline();\r\n }\r\n\r\n function setupSpaWatcher() {\r\n let currentId = listingId();\r\n const onUrlMaybeChanged = () => {\r\n const newId = listingId();\r\n if (newId === currentId) return;\r\n currentId = newId;\r\n handleNavigation();\r\n };\r\n\r\n // SPA platforms navigate via the History API (no event); patch both methods and listen\r\n // for back/forward. On a full-reload site these simply never fire.\r\n for (const method of [\"pushState\", \"replaceState\"]) {\r\n const original = history[method];\r\n history[method] = function (...args) {\r\n const result = original.apply(this, args);\r\n onUrlMaybeChanged();\r\n return result;\r\n };\r\n }\r\n window.addEventListener(\"popstate\", onUrlMaybeChanged);\r\n }\r\n\r\n function start() {\r\n if (adapter.matches(window.location.href)) runPipeline();\r\n if (config.spa !== false) setupSpaWatcher();\r\n }\r\n\r\n return { handleNavigation, setupSpaWatcher, start };\r\n}\r\n"],"names":["createNav","adapter","config","ctx","runPipeline","resetForNavigation","listingId","getListingId","window","location","href","handleNavigation","matches","document","getElementById","remove","setupSpaWatcher","currentId","onUrlMaybeChanged","newId","method","original","history","args","result","apply","this","addEventListener","start","spa"],"mappings":"
|
|
1
|
+
{"version":3,"file":"nav.js","sources":["../../../src/browser/widget/nav.js"],"sourcesContent":["// Nav unit: the always-on History-API SPA watcher + the on/off-listing navigation handler and\r\n// the start() entry point. On a full-reload site getListingId is stable so the patched History\r\n// methods simply never fire a navigation. Extracted verbatim from createAnalyzer (T12).\r\n\r\n// Fallback poll for the SPA URL. Frameworks like Next.js (Zillow) navigate by calling a private\r\n// reference to history.pushState they captured before our content script patched it, so the\r\n// patched methods below never fire — the panel would stay on the old listing until a full reload.\r\n// Polling location.href catches the change regardless of how it was triggered; the check is a\r\n// cheap string compare gated on the listing id, so a no-op when nothing navigated.\r\nconst SPA_URL_POLL_INTERVAL = 400;\r\n\r\nexport function createNav({ adapter, config, ctx, runPipeline }) {\r\n const { resetForNavigation } = ctx;\r\n const listingId = () => adapter.getListingId(window.location.href);\r\n\r\n function handleNavigation() {\r\n if (!adapter.matches(window.location.href)) {\r\n document.getElementById(\"ln-footer\")?.remove();\r\n return;\r\n }\r\n resetForNavigation();\r\n runPipeline();\r\n }\r\n\r\n function setupSpaWatcher() {\r\n let currentId = listingId();\r\n const onUrlMaybeChanged = () => {\r\n const newId = listingId();\r\n if (newId === currentId) return;\r\n currentId = newId;\r\n handleNavigation();\r\n };\r\n\r\n // SPA platforms navigate via the History API (no event); patch both methods and listen\r\n // for back/forward. On a full-reload site these simply never fire.\r\n for (const method of [\"pushState\", \"replaceState\"]) {\r\n const original = history[method];\r\n history[method] = function (...args) {\r\n const result = original.apply(this, args);\r\n onUrlMaybeChanged();\r\n return result;\r\n };\r\n }\r\n window.addEventListener(\"popstate\", onUrlMaybeChanged);\r\n\r\n // Safety net for frameworks that bypass the patched History methods (see note above).\r\n setInterval(onUrlMaybeChanged, SPA_URL_POLL_INTERVAL);\r\n }\r\n\r\n function start() {\r\n if (adapter.matches(window.location.href)) runPipeline();\r\n if (config.spa !== false) setupSpaWatcher();\r\n }\r\n\r\n return { handleNavigation, setupSpaWatcher, start };\r\n}\r\n"],"names":["createNav","adapter","config","ctx","runPipeline","resetForNavigation","listingId","getListingId","window","location","href","handleNavigation","matches","document","getElementById","remove","setupSpaWatcher","currentId","onUrlMaybeChanged","newId","method","original","history","args","result","apply","this","addEventListener","setInterval","start","spa"],"mappings":"AAWO,SAASA,WAAUC,QAAEA,EAAOC,OAAEA,EAAMC,IAAEA,EAAGC,YAAEA,IAChD,MAAMC,mBAAEA,GAAuBF,EACzBG,UAAY,IAAML,EAAQM,aAAaC,OAAOC,SAASC,MAE7D,SAASC,mBACFV,EAAQW,QAAQJ,OAAOC,SAASC,OAIrCL,IACAD,KAJES,SAASC,eAAe,cAAcC,QAK1C,CAEA,SAASC,kBACP,IAAIC,EAAYX,YAChB,MAAMY,kBAAoB,KACxB,MAAMC,EAAQb,YACVa,IAAUF,IACdA,EAAYE,EACZR,qBAKF,IAAK,MAAMS,IAAU,CAAC,YAAa,gBAAiB,CAClD,MAAMC,EAAWC,QAAQF,GACzBE,QAAQF,GAAU,YAAaG,GAC7B,MAAMC,EAASH,EAASI,MAAMC,KAAMH,GAEpC,OADAL,oBACOM,CACT,CACF,CACAhB,OAAOmB,iBAAiB,WAAYT,mBAGpCU,YAAYV,kBArCc,IAsC5B,CAOA,MAAO,CAAEP,kCAAkBK,gCAAiBa,MAL5C,WACM5B,EAAQW,QAAQJ,OAAOC,SAASC,OAAON,KACxB,IAAfF,EAAO4B,KAAed,iBAC5B,EAGF"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{createNavigationGuard as e}from"./createNavigationGuard.js";import{createPanel as t}from"./createPanel.js";import{runReveals as
|
|
1
|
+
import{createNavigationGuard as e}from"./createNavigationGuard.js";import{createPanel as t}from"./createPanel.js";import{runReveals as n}from"./runReveals.js";import{syncInterestRateForUnits as a}from"./interestRateSync.js";import{setupPriceClickHandler as r,setupCapRateClickHandler as o,setupDownPaymentClickHandler as c,setupNoiClickHandler as i,setupAwningLinkHandler as l,setupDiscountButtonHandler as p}from"../ui/click-handlers.js";import{calculateFinancials as s}from"../financial/calculateFinancials.js";import{calculateDOM as u}from"../../date/utilities.js";import{normalizeWhitespace as d}from"../../formatting/text.js";function createPipeline({adapter:m,config:y,ctx:f,exportOps:g,finance:E,render:b,resolveCssUrls:C,services:P}){const{state:S,updateState:w}=f,listingId=()=>m.getListingId(window.location.href);async function updateFooterData(){const t=e(listingId);if(t.capture(),y.reveals?.length&&(await n(y.reveals),t.isStale()))return;const g=E.scrapeAndApply();if(!g)return console.error("❌ Malformed listing data — missing a contract field, refusing to render"),void b.updateElement("prop-name","Data error — see console");const C=g.unitCount??4;w({numberOfUnits:C});const h=document.getElementById("ln-units-input");h&&(h.value=C),a(S,w,C),b.updateElement("prop-name",g.name),b.updateElement("prop-price",S.priceWasDefaulted?"No price":g.price),b.updateElement("prop-contact",g.contact),b.updateElement("prop-phone",g.phone),b.updateElement("prop-dom",u(g.listingDate)),b.updatePriceLabel(),b.updateCapRateLabel(),b.syncUnitsFieldForType(S.currentPropertyType,g.bedroomCount),function(e){const t=document.getElementById("prop-name");t&&e.name&&"Not found"!==e.name&&(t.style.cursor="pointer",t.style.textDecoration="underline",t.onclick=()=>{const t=`https://www.google.com/maps/search/${encodeURIComponent(e.name)}`;window.open(t,"_blank")});const n={getCurrentPrice:b.getCurrentPrice,recalculateFinancials:E.recalculateFinancials,state:S,updatePercentageLabels:b.updatePercentageLabels,updatePriceLabel:b.updatePriceLabel,updateState:w},a=document.getElementById("prop-price");r(a,a?.closest(".metric")?.querySelector(".metric-label"),n);const s=document.getElementById("prop-cap");o(s,s?.closest(".metric")?.querySelector(".metric-label"),n);const u=document.getElementById("prop-down");c(u,u?.closest(".metric")?.querySelector(".metric-label"),n);const d=document.getElementById("prop-noi");i(d,d?.closest(".metric")?.querySelector(".metric-label"),n),l(document.getElementById("prop-noi-awning")),p(document.getElementById("ln-discount-btn"),n)}(g),function(e){const isPresent=e=>"string"==typeof e&&""!==e.trim()&&"Not found"!==e,applyLateFields=()=>{const e=m.scrape();if(!e)return!1;const t=d(e.contact),n=d(e.phone),a=d(e.listingDate);return b.updateElement("prop-contact",t),b.updateElement("prop-phone",n),b.updateElement("prop-dom",u(a)),isPresent(t)&&isPresent(n)&&isPresent(a)};if(applyLateFields())return;let t=Math.ceil(1e4/300);const tick=()=>{e.isStale()||applyLateFields()||t--<=0||setTimeout(tick,300)};setTimeout(tick,300)}(t);const F=S.isUsingEstimatedCapRate?`${S.currentEstimatedCapRate}%`:g.capRate,T=await s(f,g.price,F,S.currentPropertyType,g.name);if(t.isStale())return;b.applyFinancials(T),b.updateActiveCapDisplay();const I=await P.loadLeadStatus(g.name);if(t.isStale())return;b.updateElement("prop-lead-status",I.leadStatus),b.updateLeadStatusTooltip(I);const D=await P.loadStrValue(g.name,t);t.isStale()||D&&"str"===S.currentPropertyType&&(w({baseNOI:null}),await E.recalculateFinancials(),t.isStale())||(await P.loadDebt(g.name,t),t.isStale()||b.updateEquityDisplay())}let h=!1,F=null;return{runPipeline:function(){h=!1,F&&(F.disconnect(),F=null),t({callbacks:{onExportClick:g.handleExportClick,onInterestRateTypeChange:()=>E.recalculateFinancials(),onPropertyTypeChange:()=>{E.handlePropertyTypeChange(),b.updateCapRateLabel();const e=m.scrape();b.syncUnitsFieldForType(S.currentPropertyType,e?.bedroomCount),E.recalculateFinancials()},state:S,updateState:w},cssUrls:C(y.cssFiles),defaultPropertyType:y.defaultPropertyType});const stopObserver=()=>{F&&(F.disconnect(),F=null)},tryImmediateUpdate=(e=!1)=>!!(()=>{const e=document.getElementById("prop-name"),t=document.getElementById("prop-price");return!!(e&&t&&e.textContent.trim()&&t.textContent.trim())})()&&(!(!e&&!(()=>{const e=m.scrape();return!!e&&"Not found"!==e.price&&!e.priceWasDefaulted})())&&((async()=>{h||(h=!0,await updateFooterData())})(),!0));if(tryImmediateUpdate())return;F=new MutationObserver(()=>{tryImmediateUpdate()&&stopObserver()}),F.observe(document.body,{childList:!0,subtree:!0});let e=0;const fallbackPoll=()=>{h||(tryImmediateUpdate(e>=8e3)?stopObserver():(e+=300,setTimeout(fallbackPoll,300)))};setTimeout(fallbackPoll,300)},updateFooterData:updateFooterData}}export{createPipeline};
|
|
2
2
|
//# sourceMappingURL=pipeline.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline.js","sources":["../../../src/browser/widget/pipeline.js"],"sourcesContent":["// Pipeline unit: the re-runnable footer pipeline — builds the shared panel, wires the clickable\r\n// elements, runs the main async update (scrape -> financials -> lead status -> STR -> equity with\r\n// the navigation guard between awaits), and drives the immediate/observer/load entry points.\r\n// Extracted verbatim from createAnalyzer (T12).\r\n\r\nimport { createNavigationGuard } from \"./createNavigationGuard.js\";\r\nimport { createPanel } from \"./createPanel.js\";\r\nimport { runReveals } from \"./runReveals.js\";\r\nimport {\r\n setupAwningLinkHandler,\r\n setupCapRateClickHandler,\r\n setupDiscountButtonHandler,\r\n setupDownPaymentClickHandler,\r\n setupNoiClickHandler,\r\n setupPriceClickHandler,\r\n} from \"../ui/click-handlers.js\";\r\nimport { calculateFinancials } from \"../financial/calculateFinancials.js\";\r\nimport { calculateDOM } from \"../../date/utilities.js\";\r\n\r\nexport function createPipeline({ adapter, config, ctx, exportOps, finance, render, resolveCssUrls, services }) {\r\n const { state, updateState } = ctx;\r\n const listingId = () => adapter.getListingId(window.location.href);\r\n\r\n function setupClickableElements(data) {\r\n const nameElement = document.getElementById(\"prop-name\");\r\n if (nameElement && data.name && data.name !== \"Not found\") {\r\n nameElement.style.cursor = \"pointer\";\r\n nameElement.style.textDecoration = \"underline\";\r\n nameElement.onclick = () => {\r\n const searchUrl = `https://www.google.com/maps/search/${encodeURIComponent(data.name)}`;\r\n window.open(searchUrl, \"_blank\");\r\n };\r\n }\r\n\r\n // The shared click-handlers read callbacks.state / callbacks.updateState — the engine's\r\n // ctx is injected here (no global-state coupling).\r\n const callbacks = {\r\n getCurrentPrice: render.getCurrentPrice,\r\n recalculateFinancials: finance.recalculateFinancials,\r\n state,\r\n updatePercentageLabels: render.updatePercentageLabels,\r\n updatePriceLabel: render.updatePriceLabel,\r\n updateState,\r\n };\r\n\r\n const priceElement = document.getElementById(\"prop-price\");\r\n setupPriceClickHandler(priceElement, priceElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n const capElement = document.getElementById(\"prop-cap\");\r\n setupCapRateClickHandler(capElement, capElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n const downElement = document.getElementById(\"prop-down\");\r\n setupDownPaymentClickHandler(downElement, downElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n setupNoiClickHandler(noiElement, noiElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n setupAwningLinkHandler(document.getElementById(\"prop-noi-awning\"));\r\n\r\n setupDiscountButtonHandler(document.getElementById(\"ln-discount-btn\"), callbacks);\r\n }\r\n\r\n async function updateFooterData() {\r\n // The listing this run is for. On an SPA the page can navigate mid-flight; after each\r\n // await we drop out if the identity changed, so a stale run never writes onto another\r\n // listing's panel. On a full-reload site getListingId is stable, so isStale() is always\r\n // false and this is a no-op.\r\n const guard = createNavigationGuard(listingId);\r\n guard.capture();\r\n\r\n // Click-to-reveal any data gated behind a button (broker phone/email, OM access) so the\r\n // pure scrape() below reads it. Platform-declared (config.reveals); a no-op when absent.\r\n if (config.reveals?.length) {\r\n await runReveals(config.reveals);\r\n if (guard.isStale()) return;\r\n }\r\n\r\n const data = finance.scrapeAndApply();\r\n if (!data) {\r\n console.error(\"❌ Malformed listing data — missing a contract field, refusing to render\");\r\n render.updateElement(\"prop-name\", \"Data error — see console\");\r\n return;\r\n }\r\n\r\n const unitCount = data.unitCount ?? 4;\r\n updateState({ numberOfUnits: unitCount });\r\n const unitsInput = document.getElementById(\"ln-units-input\");\r\n if (unitsInput) unitsInput.value = unitCount;\r\n\r\n if (unitCount > 11) {\r\n const irDropdown = document.getElementById(\"ln-interest-rate-type\");\r\n if (irDropdown && irDropdown.value !== \"dscr_commercial\") {\r\n irDropdown.value = \"dscr_commercial\";\r\n updateState({ currentInterestRateType: \"dscr_commercial\" });\r\n }\r\n }\r\n\r\n render.updateElement(\"prop-name\", data.name);\r\n // Display guard (H2): a defaulted price shows \"No price\"; the metrics fall through to N/A.\r\n render.updateElement(\"prop-price\", state.priceWasDefaulted ? \"No price\" : data.price);\r\n render.updateElement(\"prop-contact\", data.contact);\r\n render.updateElement(\"prop-phone\", data.phone);\r\n render.updateElement(\"prop-dom\", calculateDOM(data.listingDate));\r\n\r\n render.updatePriceLabel();\r\n render.updateCapRateLabel();\r\n render.syncUnitsFieldForType(state.currentPropertyType, data.bedroomCount);\r\n setupClickableElements(data);\r\n\r\n const calculationCapRate = state.isUsingEstimatedCapRate ? `${state.currentEstimatedCapRate}%` : data.capRate;\r\n const financials = await calculateFinancials(ctx, data.price, calculationCapRate, state.currentPropertyType, data.name);\r\n if (guard.isStale()) return;\r\n render.applyFinancials(financials);\r\n render.updateActiveCapDisplay();\r\n\r\n const loiData = await services.loadLeadStatus(data.name);\r\n if (guard.isStale()) return;\r\n render.updateElement(\"prop-lead-status\", loiData.leadStatus);\r\n render.updateLeadStatusTooltip(loiData);\r\n\r\n // STR revenue seam: the footer already shows the 5.5%-of-price estimate. If the backend\r\n // returns real data, recompute the STR NOI with it. Dormant until that backend ships.\r\n const strResult = await services.loadStrValue(data.name, guard);\r\n if (guard.isStale()) return;\r\n if (strResult && state.currentPropertyType === \"str\") {\r\n updateState({ baseNOI: null });\r\n await finance.recalculateFinancials();\r\n if (guard.isStale()) return;\r\n }\r\n\r\n await services.loadDebt(data.name, guard);\r\n if (guard.isStale()) return;\r\n render.updateEquityDisplay();\r\n }\r\n\r\n // One running pipeline at a time. Re-runnable so the SPA watcher can rebuild per listing;\r\n // the observer is tracked so a re-run detaches the previous one.\r\n let footerUpdated = false;\r\n let pipelineObserver = null;\r\n\r\n function runPipeline() {\r\n footerUpdated = false;\r\n if (pipelineObserver) {\r\n pipelineObserver.disconnect();\r\n pipelineObserver = null;\r\n }\r\n\r\n createPanel({\r\n callbacks: {\r\n onExportClick: exportOps.handleExportClick,\r\n onInterestRateTypeChange: () => finance.recalculateFinancials(),\r\n onPropertyTypeChange: () => {\r\n finance.handlePropertyTypeChange();\r\n render.updateCapRateLabel();\r\n const listing = adapter.scrape();\r\n render.syncUnitsFieldForType(state.currentPropertyType, listing?.bedroomCount);\r\n finance.recalculateFinancials();\r\n },\r\n state,\r\n updateState,\r\n },\r\n cssUrls: resolveCssUrls(config.cssFiles),\r\n defaultPropertyType: config.defaultPropertyType,\r\n });\r\n\r\n const runUpdateOnce = async () => {\r\n if (footerUpdated) return;\r\n footerUpdated = true;\r\n await updateFooterData();\r\n };\r\n\r\n const tryImmediateUpdate = () => {\r\n const nameEl = document.getElementById(\"prop-name\");\r\n const priceEl = document.getElementById(\"prop-price\");\r\n if (nameEl && priceEl && nameEl.textContent.trim() && priceEl.textContent.trim()) {\r\n runUpdateOnce();\r\n return true;\r\n }\r\n return false;\r\n };\r\n\r\n if (tryImmediateUpdate()) return;\r\n\r\n pipelineObserver = new MutationObserver((mutations, obs) => {\r\n if (tryImmediateUpdate()) {\r\n obs.disconnect();\r\n pipelineObserver = null;\r\n }\r\n });\r\n pipelineObserver.observe(document.body, { childList: true, subtree: true });\r\n\r\n // Safety fallback before the page has loaded; SPA navigations fire after load, so the\r\n // observer (not load) drives those.\r\n if (document.readyState !== \"complete\") {\r\n window.addEventListener(\"load\", () => {\r\n setTimeout(() => {\r\n if (!footerUpdated) {\r\n runUpdateOnce();\r\n if (pipelineObserver) {\r\n pipelineObserver.disconnect();\r\n pipelineObserver = null;\r\n }\r\n }\r\n }, 5000);\r\n });\r\n }\r\n }\r\n\r\n return { runPipeline, updateFooterData };\r\n}\r\n"],"names":["createPipeline","adapter","config","ctx","exportOps","finance","render","resolveCssUrls","services","state","updateState","listingId","getListingId","window","location","href","async","updateFooterData","guard","createNavigationGuard","capture","reveals","length","runReveals","isStale","data","scrapeAndApply","console","error","updateElement","unitCount","numberOfUnits","unitsInput","document","getElementById","value","irDropdown","currentInterestRateType","name","priceWasDefaulted","price","contact","phone","calculateDOM","listingDate","updatePriceLabel","updateCapRateLabel","syncUnitsFieldForType","currentPropertyType","bedroomCount","nameElement","style","cursor","textDecoration","onclick","searchUrl","encodeURIComponent","open","callbacks","getCurrentPrice","recalculateFinancials","updatePercentageLabels","priceElement","setupPriceClickHandler","closest","querySelector","capElement","setupCapRateClickHandler","downElement","setupDownPaymentClickHandler","noiElement","setupNoiClickHandler","setupAwningLinkHandler","setupDiscountButtonHandler","setupClickableElements","calculationCapRate","isUsingEstimatedCapRate","currentEstimatedCapRate","capRate","financials","calculateFinancials","applyFinancials","updateActiveCapDisplay","loiData","loadLeadStatus","leadStatus","updateLeadStatusTooltip","strResult","loadStrValue","baseNOI","loadDebt","updateEquityDisplay","footerUpdated","pipelineObserver","runPipeline","disconnect","createPanel","onExportClick","handleExportClick","onInterestRateTypeChange","onPropertyTypeChange","handlePropertyTypeChange","listing","scrape","cssUrls","cssFiles","defaultPropertyType","runUpdateOnce","tryImmediateUpdate","nameEl","priceEl","textContent","trim","MutationObserver","mutations","obs","observe","body","childList","subtree","readyState","addEventListener","setTimeout"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"pipeline.js","sources":["../../../src/browser/widget/pipeline.js"],"sourcesContent":["// Pipeline unit: the re-runnable footer pipeline — builds the shared panel, wires the clickable\r\n// elements, runs the main async update (scrape -> financials -> lead status -> STR -> equity with\r\n// the navigation guard between awaits), and drives the immediate/observer/load entry points.\r\n// Extracted verbatim from createAnalyzer (T12).\r\n\r\nimport { createNavigationGuard } from \"./createNavigationGuard.js\";\r\nimport { createPanel } from \"./createPanel.js\";\r\nimport { runReveals } from \"./runReveals.js\";\r\nimport { syncInterestRateForUnits } from \"./interestRateSync.js\";\r\nimport {\r\n setupAwningLinkHandler,\r\n setupCapRateClickHandler,\r\n setupDiscountButtonHandler,\r\n setupDownPaymentClickHandler,\r\n setupNoiClickHandler,\r\n setupPriceClickHandler,\r\n} from \"../ui/click-handlers.js\";\r\nimport { calculateFinancials } from \"../financial/calculateFinancials.js\";\r\nimport { calculateDOM } from \"../../date/utilities.js\";\r\nimport { normalizeWhitespace } from \"../../formatting/text.js\";\r\n\r\n// Some sites (e.g. Zillow) client-render parts of a listing — the listing-agent attribution and\r\n// the price-history table — a beat AFTER first paint, so the pipeline's single initial scrape\r\n// reads \"Not found\" for the fields they carry (contact, phone, listing date). After the first\r\n// render we poll the pure scrape() for just those display fields and fill them in as they arrive,\r\n// until all are present or this budget elapses. Poll-count based (like runReveals' waitForSelector)\r\n// so it stays bounded and predictable under heavy DOM churn.\r\nconst LATE_FIELD_TIMEOUT = 10000;\r\nconst LATE_FIELD_POLL_INTERVAL = 300;\r\n\r\n// The main render waits for the page to expose a scrapeable PRICE before it commits — price is the\r\n// field every financial metric derives from. On a full page load the server-rendered JSON-LD has it\r\n// immediately; on an SPA overlay (Zillow search -> listing) it is client-painted a beat after the\r\n// navigation fires, so an eager scrape would read no price and paint N/A everywhere with no recovery.\r\n// If the price never becomes scrapeable (a genuinely price-less/off-market listing) the timeout lets\r\n// the render proceed anyway, so the panel never hangs on \"Loading...\" — it shows the honest no-price state.\r\nconst DATA_READY_TIMEOUT = 8000;\r\nconst DATA_READY_POLL_INTERVAL = 300;\r\n\r\nexport function createPipeline({ adapter, config, ctx, exportOps, finance, render, resolveCssUrls, services }) {\r\n const { state, updateState } = ctx;\r\n const listingId = () => adapter.getListingId(window.location.href);\r\n\r\n function setupClickableElements(data) {\r\n const nameElement = document.getElementById(\"prop-name\");\r\n if (nameElement && data.name && data.name !== \"Not found\") {\r\n nameElement.style.cursor = \"pointer\";\r\n nameElement.style.textDecoration = \"underline\";\r\n nameElement.onclick = () => {\r\n const searchUrl = `https://www.google.com/maps/search/${encodeURIComponent(data.name)}`;\r\n window.open(searchUrl, \"_blank\");\r\n };\r\n }\r\n\r\n // The shared click-handlers read callbacks.state / callbacks.updateState — the engine's\r\n // ctx is injected here (no global-state coupling).\r\n const callbacks = {\r\n getCurrentPrice: render.getCurrentPrice,\r\n recalculateFinancials: finance.recalculateFinancials,\r\n state,\r\n updatePercentageLabels: render.updatePercentageLabels,\r\n updatePriceLabel: render.updatePriceLabel,\r\n updateState,\r\n };\r\n\r\n const priceElement = document.getElementById(\"prop-price\");\r\n setupPriceClickHandler(priceElement, priceElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n const capElement = document.getElementById(\"prop-cap\");\r\n setupCapRateClickHandler(capElement, capElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n const downElement = document.getElementById(\"prop-down\");\r\n setupDownPaymentClickHandler(downElement, downElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n\r\n const noiElement = document.getElementById(\"prop-noi\");\r\n setupNoiClickHandler(noiElement, noiElement?.closest(\".metric\")?.querySelector(\".metric-label\"), callbacks);\r\n setupAwningLinkHandler(document.getElementById(\"prop-noi-awning\"));\r\n\r\n setupDiscountButtonHandler(document.getElementById(\"ln-discount-btn\"), callbacks);\r\n }\r\n\r\n // Progressive fill for fields a site renders after first paint (see LATE_FIELD_* above).\r\n // Re-reads ONLY the scrape-derived display fields (contact, phone, listing date) via the pure\r\n // adapter.scrape() — never scrapeAndApply, so it touches no state and re-applies no cap rate —\r\n // and updates only those three elements; price/NOI/financials and all network calls are left\r\n // alone. Stops as soon as every field is present (so a server-rendered site like LoopNet, where\r\n // the first read already has them, never starts a poll), when the budget elapses, or when the\r\n // page navigated to another listing (guard). Whitespace is normalized here to match the\r\n // contract's single normalization point in finance.scrapeAndApply (e.g. a broker name that the\r\n // markup splits across lines).\r\n function watchLateFields(guard) {\r\n const isPresent = (value) => typeof value === \"string\" && value.trim() !== \"\" && value !== \"Not found\";\r\n\r\n const applyLateFields = () => {\r\n const data = adapter.scrape();\r\n if (!data) return false;\r\n const contact = normalizeWhitespace(data.contact);\r\n const phone = normalizeWhitespace(data.phone);\r\n const listingDate = normalizeWhitespace(data.listingDate);\r\n render.updateElement(\"prop-contact\", contact);\r\n render.updateElement(\"prop-phone\", phone);\r\n render.updateElement(\"prop-dom\", calculateDOM(listingDate));\r\n return isPresent(contact) && isPresent(phone) && isPresent(listingDate);\r\n };\r\n\r\n if (applyLateFields()) return;\r\n\r\n let remaining = Math.ceil(LATE_FIELD_TIMEOUT / LATE_FIELD_POLL_INTERVAL);\r\n const tick = () => {\r\n if (guard.isStale()) return;\r\n if (applyLateFields() || remaining-- <= 0) return;\r\n setTimeout(tick, LATE_FIELD_POLL_INTERVAL);\r\n };\r\n setTimeout(tick, LATE_FIELD_POLL_INTERVAL);\r\n }\r\n\r\n async function updateFooterData() {\r\n // The listing this run is for. On an SPA the page can navigate mid-flight; after each\r\n // await we drop out if the identity changed, so a stale run never writes onto another\r\n // listing's panel. On a full-reload site getListingId is stable, so isStale() is always\r\n // false and this is a no-op.\r\n const guard = createNavigationGuard(listingId);\r\n guard.capture();\r\n\r\n // Click-to-reveal any data gated behind a button (broker phone/email, OM access) so the\r\n // pure scrape() below reads it. Platform-declared (config.reveals); a no-op when absent.\r\n if (config.reveals?.length) {\r\n await runReveals(config.reveals);\r\n if (guard.isStale()) return;\r\n }\r\n\r\n const data = finance.scrapeAndApply();\r\n if (!data) {\r\n console.error(\"❌ Malformed listing data — missing a contract field, refusing to render\");\r\n render.updateElement(\"prop-name\", \"Data error — see console\");\r\n return;\r\n }\r\n\r\n const unitCount = data.unitCount ?? 4;\r\n updateState({ numberOfUnits: unitCount });\r\n const unitsInput = document.getElementById(\"ln-units-input\");\r\n if (unitsInput) unitsInput.value = unitCount;\r\n\r\n syncInterestRateForUnits(state, updateState, unitCount);\r\n\r\n render.updateElement(\"prop-name\", data.name);\r\n // Display guard (H2): a defaulted price shows \"No price\"; the metrics fall through to N/A.\r\n render.updateElement(\"prop-price\", state.priceWasDefaulted ? \"No price\" : data.price);\r\n render.updateElement(\"prop-contact\", data.contact);\r\n render.updateElement(\"prop-phone\", data.phone);\r\n render.updateElement(\"prop-dom\", calculateDOM(data.listingDate));\r\n\r\n render.updatePriceLabel();\r\n render.updateCapRateLabel();\r\n render.syncUnitsFieldForType(state.currentPropertyType, data.bedroomCount);\r\n setupClickableElements(data);\r\n\r\n // Fields some sites render after first paint (agent contact/phone, listing date) start as\r\n // \"Not found\" above; fill them in progressively as they arrive without blocking what follows.\r\n watchLateFields(guard);\r\n\r\n const calculationCapRate = state.isUsingEstimatedCapRate ? `${state.currentEstimatedCapRate}%` : data.capRate;\r\n const financials = await calculateFinancials(ctx, data.price, calculationCapRate, state.currentPropertyType, data.name);\r\n if (guard.isStale()) return;\r\n render.applyFinancials(financials);\r\n render.updateActiveCapDisplay();\r\n\r\n const loiData = await services.loadLeadStatus(data.name);\r\n if (guard.isStale()) return;\r\n render.updateElement(\"prop-lead-status\", loiData.leadStatus);\r\n render.updateLeadStatusTooltip(loiData);\r\n\r\n // STR revenue seam: the footer already shows the 5.5%-of-price estimate. If the backend\r\n // returns real data, recompute the STR NOI with it. Dormant until that backend ships.\r\n const strResult = await services.loadStrValue(data.name, guard);\r\n if (guard.isStale()) return;\r\n if (strResult && state.currentPropertyType === \"str\") {\r\n updateState({ baseNOI: null });\r\n await finance.recalculateFinancials();\r\n if (guard.isStale()) return;\r\n }\r\n\r\n await services.loadDebt(data.name, guard);\r\n if (guard.isStale()) return;\r\n render.updateEquityDisplay();\r\n }\r\n\r\n // One running pipeline at a time. Re-runnable so the SPA watcher can rebuild per listing;\r\n // the observer is tracked so a re-run detaches the previous one.\r\n let footerUpdated = false;\r\n let pipelineObserver = null;\r\n\r\n function runPipeline() {\r\n footerUpdated = false;\r\n if (pipelineObserver) {\r\n pipelineObserver.disconnect();\r\n pipelineObserver = null;\r\n }\r\n\r\n createPanel({\r\n callbacks: {\r\n onExportClick: exportOps.handleExportClick,\r\n onInterestRateTypeChange: () => finance.recalculateFinancials(),\r\n onPropertyTypeChange: () => {\r\n finance.handlePropertyTypeChange();\r\n render.updateCapRateLabel();\r\n const listing = adapter.scrape();\r\n render.syncUnitsFieldForType(state.currentPropertyType, listing?.bedroomCount);\r\n finance.recalculateFinancials();\r\n },\r\n state,\r\n updateState,\r\n },\r\n cssUrls: resolveCssUrls(config.cssFiles),\r\n defaultPropertyType: config.defaultPropertyType,\r\n });\r\n\r\n const runUpdateOnce = async () => {\r\n if (footerUpdated) return;\r\n footerUpdated = true;\r\n await updateFooterData();\r\n };\r\n\r\n const stopObserver = () => {\r\n if (pipelineObserver) {\r\n pipelineObserver.disconnect();\r\n pipelineObserver = null;\r\n }\r\n };\r\n\r\n // The panel's own elements are built (createPanel's async append finished).\r\n const panelReady = () => {\r\n const nameEl = document.getElementById(\"prop-name\");\r\n const priceEl = document.getElementById(\"prop-price\");\r\n return !!(nameEl && priceEl && nameEl.textContent.trim() && priceEl.textContent.trim());\r\n };\r\n\r\n // The page exposes a real, scrapeable price (see DATA_READY_* above). Pure read — no state writes.\r\n const priceReady = () => {\r\n const listing = adapter.scrape();\r\n return !!listing && listing.price !== \"Not found\" && !listing.priceWasDefaulted;\r\n };\r\n\r\n // Run the main update once the panel is built AND the price is scrapeable. `force` (the timeout\r\n // path) commits even without a price so a price-less listing renders its honest no-price state.\r\n const tryImmediateUpdate = (force = false) => {\r\n if (!panelReady()) return false;\r\n if (!force && !priceReady()) return false;\r\n runUpdateOnce();\r\n return true;\r\n };\r\n\r\n if (tryImmediateUpdate()) return;\r\n\r\n pipelineObserver = new MutationObserver(() => {\r\n if (tryImmediateUpdate()) stopObserver();\r\n });\r\n pipelineObserver.observe(document.body, { childList: true, subtree: true });\r\n\r\n // Bounded fallback for SPA overlays (already readyState \"complete\", so the load event never\r\n // fires) and for listings whose price never paints: poll until the price is scrapeable, then\r\n // force the render at the timeout so the panel never hangs on \"Loading...\".\r\n let waited = 0;\r\n const fallbackPoll = () => {\r\n if (footerUpdated) return;\r\n if (tryImmediateUpdate(waited >= DATA_READY_TIMEOUT)) {\r\n stopObserver();\r\n return;\r\n }\r\n waited += DATA_READY_POLL_INTERVAL;\r\n setTimeout(fallbackPoll, DATA_READY_POLL_INTERVAL);\r\n };\r\n setTimeout(fallbackPoll, DATA_READY_POLL_INTERVAL);\r\n }\r\n\r\n return { runPipeline, updateFooterData };\r\n}\r\n"],"names":["createPipeline","adapter","config","ctx","exportOps","finance","render","resolveCssUrls","services","state","updateState","listingId","getListingId","window","location","href","async","updateFooterData","guard","createNavigationGuard","capture","reveals","length","runReveals","isStale","data","scrapeAndApply","console","error","updateElement","unitCount","numberOfUnits","unitsInput","document","getElementById","value","syncInterestRateForUnits","name","priceWasDefaulted","price","contact","phone","calculateDOM","listingDate","updatePriceLabel","updateCapRateLabel","syncUnitsFieldForType","currentPropertyType","bedroomCount","nameElement","style","cursor","textDecoration","onclick","searchUrl","encodeURIComponent","open","callbacks","getCurrentPrice","recalculateFinancials","updatePercentageLabels","priceElement","setupPriceClickHandler","closest","querySelector","capElement","setupCapRateClickHandler","downElement","setupDownPaymentClickHandler","noiElement","setupNoiClickHandler","setupAwningLinkHandler","setupDiscountButtonHandler","setupClickableElements","isPresent","trim","applyLateFields","scrape","normalizeWhitespace","remaining","Math","ceil","tick","setTimeout","watchLateFields","calculationCapRate","isUsingEstimatedCapRate","currentEstimatedCapRate","capRate","financials","calculateFinancials","applyFinancials","updateActiveCapDisplay","loiData","loadLeadStatus","leadStatus","updateLeadStatusTooltip","strResult","loadStrValue","baseNOI","loadDebt","updateEquityDisplay","footerUpdated","pipelineObserver","runPipeline","disconnect","createPanel","onExportClick","handleExportClick","onInterestRateTypeChange","onPropertyTypeChange","handlePropertyTypeChange","listing","cssUrls","cssFiles","defaultPropertyType","stopObserver","tryImmediateUpdate","force","nameEl","priceEl","textContent","panelReady","priceReady","runUpdateOnce","MutationObserver","observe","body","childList","subtree","waited","fallbackPoll"],"mappings":"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"}
|