← back to Commercialrealestate
CRCP: Frank onload 4-unit multifamily default filter + non-warrantable condos section + per-agent private notes
cf0bd41042599455cf8102dbd5fa6a11e0c754f4 · 2026-08-19 11:11:39 -0700 · Steve
- crcp-notes.js: generalize to a scope factory; add agent/broker profile notes (/api/agent-notes, data/agent-notes.json) alongside existing condo notes, per-user + updated_at timestamp
- agent-notes.js (new): reusable private-notes panel drop-in; shows last-modified date + relative update, autosaves on blur/Cmd+Enter, sign-in-aware
- agent.html + broker.html: mount the notes panel keyed by stable profile id
- crcp-accounts.js: seed Frank (Arcstone818 company) a default 'is_default' 4-Unit Multifamily saved search; add POST /api/saved-searches/:id/default; surface company on session
- deals-flow.html: auto-apply the user's default saved search on load when no URL filter is present
- condos.html: dedicated Non-Warrantable Condos section (honest FHA/warrantability proxy) with live bucket counts + real HUD-lapsed re-cert examples + one-click filter
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A public/agent-notes.jsM public/agent.htmlM public/broker.htmlM public/condos.htmlM public/deals-flow.htmlM scripts/crcp-accounts.jsM scripts/crcp-notes.js
Diff
commit cf0bd41042599455cf8102dbd5fa6a11e0c754f4
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Aug 19 11:11:39 2026 -0700
CRCP: Frank onload 4-unit multifamily default filter + non-warrantable condos section + per-agent private notes
- crcp-notes.js: generalize to a scope factory; add agent/broker profile notes (/api/agent-notes, data/agent-notes.json) alongside existing condo notes, per-user + updated_at timestamp
- agent-notes.js (new): reusable private-notes panel drop-in; shows last-modified date + relative update, autosaves on blur/Cmd+Enter, sign-in-aware
- agent.html + broker.html: mount the notes panel keyed by stable profile id
- crcp-accounts.js: seed Frank (Arcstone818 company) a default 'is_default' 4-Unit Multifamily saved search; add POST /api/saved-searches/:id/default; surface company on session
- deals-flow.html: auto-apply the user's default saved search on load when no URL filter is present
- condos.html: dedicated Non-Warrantable Condos section (honest FHA/warrantability proxy) with live bucket counts + real HUD-lapsed re-cert examples + one-click filter
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/agent-notes.js | 163 +++++++++++++++++++++++++++++++++++++++++++++++
public/agent.html | 6 ++
public/broker.html | 6 +-
public/condos.html | 74 ++++++++++++++++++++-
public/deals-flow.html | 19 ++++--
scripts/crcp-accounts.js | 39 ++++++++++++
scripts/crcp-notes.js | 137 ++++++++++++++++++++++-----------------
7 files changed, 378 insertions(+), 66 deletions(-)
diff --git a/public/agent-notes.js b/public/agent-notes.js
new file mode 100644
index 0000000..58d0676
--- /dev/null
+++ b/public/agent-notes.js
@@ -0,0 +1,163 @@
+/* agent-notes.js — reusable private-notes panel for CRCP profile pages (agent / broker / firm).
+ *
+ * Steve 2026-08-19: "on each agent page, provide space for NOTES and record any modification date
+ * and update." One drop-in any profile page includes; it renders a per-user, timestamped notes
+ * panel keyed by the profile's stable id and surfaces the last-modified date + relative "updated".
+ *
+ * Backed by the crcp-notes agent scope (GET /api/agent-notes → {notes:{id:{note,updated_at}}},
+ * POST /api/agent-notes/:id {note}). Notes are PRIVATE to the signed-in user; when nobody is signed
+ * in the panel shows a sign-in hint and stays read-only (never throws, page still renders).
+ *
+ * Usage (call once the page knows the entity id):
+ * <script src="/agent-notes.js" defer></script>
+ * CRCPNotes.mount({ id: 'agent:' + agentId, title: 'Notes on ' + name, mount: '#agentNotes' });
+ *
+ * Options: { id (required), base='/api/agent-notes', title='Private Notes', mount=element|selector,
+ * subject='' } — subject is an optional line shown under the title (e.g. the agent name).
+ * $0, local, no deps. Idempotent: re-mounting with the same id refreshes in place.
+ */
+(function () {
+ 'use strict';
+ const esc = s => (s == null ? '' : String(s)).replace(/&/g, '&').replace(/</g, '<')
+ .replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
+ const api = (u, o) => fetch(u, Object.assign({ headers: { 'Content-Type': 'application/json' } }, o || {})).then(r => r.json());
+
+ // Human "modification date + relative update" from an ISO stamp (Steve's admin-card rule: show
+ // both the absolute date+time AND keep the precise ISO in a title attribute).
+ function fmtWhen(iso) {
+ if (!iso) return '';
+ const d = new Date(iso); if (isNaN(d)) return '';
+ const abs = d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+ const secs = Math.max(0, (Date.now() - d.getTime()) / 1000);
+ let rel;
+ if (secs < 60) rel = 'just now';
+ else if (secs < 3600) rel = Math.floor(secs / 60) + 'm ago';
+ else if (secs < 86400) rel = Math.floor(secs / 3600) + 'h ago';
+ else if (secs < 2592000) rel = Math.floor(secs / 86400) + 'd ago';
+ else rel = abs;
+ return { abs, rel, iso };
+ }
+
+ // One-time styles (scoped to .cn-* so they don't collide with a host page).
+ function ensureStyles() {
+ if (document.getElementById('cn-styles')) return;
+ const css = `
+ .cn-wrap{border:1px solid var(--line,#2a313c);border-radius:14px;background:var(--card,#161b22);padding:14px 16px;margin:16px 0}
+ .cn-head{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px 12px;margin-bottom:8px}
+ .cn-head h3{margin:0;font-size:14px;color:var(--ink,#e6edf3)}
+ .cn-subj{color:var(--mut,#8b949e);font-size:12px}
+ .cn-when{margin-left:auto;color:var(--mut,#8b949e);font-size:11.5px}
+ .cn-when b{color:var(--gold,#ffa600);font-weight:600}
+ .cn-ta{width:100%;min-height:96px;resize:vertical;background:var(--bg2,#0b0e13);color:var(--ink,#e6edf3);
+ border:1px solid var(--line,#2a313c);border-radius:10px;padding:10px 12px;font:inherit;font-size:13px;line-height:1.5}
+ .cn-ta:focus{outline:none;border-color:var(--blue,#58a6ff)}
+ .cn-ta[disabled]{opacity:.6;cursor:not-allowed}
+ .cn-bar{display:flex;align-items:center;gap:10px;margin-top:8px}
+ .cn-save{background:var(--acc,#3fb950);color:var(--onacc,#0e1116);border:0;border-radius:8px;
+ padding:7px 14px;font-size:12px;font-weight:700;cursor:pointer}
+ .cn-save[disabled]{opacity:.5;cursor:not-allowed}
+ .cn-status{font-size:11.5px;color:var(--mut,#8b949e)}
+ .cn-status.ok{color:var(--acc,#3fb950)}
+ .cn-status.err{color:var(--red,#f85149)}
+ .cn-hint{font-size:11.5px;color:var(--mut,#8b949e);margin-top:6px}
+ .cn-hint a{color:var(--blue,#58a6ff);cursor:pointer}`;
+ const el = document.createElement('style'); el.id = 'cn-styles'; el.textContent = css;
+ document.head.appendChild(el);
+ }
+
+ function resolveMount(mount) {
+ if (mount && mount.nodeType === 1) return mount;
+ if (typeof mount === 'string') { const el = document.querySelector(mount); if (el) return el; }
+ // Fallbacks: a page-declared #agentNotes host, else the main .wrap, else <body>.
+ return document.getElementById('agentNotes') || document.querySelector('.wrap') || document.body;
+ }
+
+ const mounted = new Map(); // id -> panel root, so re-mount refreshes instead of duplicating
+
+ function mount(opts) {
+ opts = opts || {};
+ if (!opts.id) { console.warn('[agent-notes] mount() needs an id'); return; }
+ ensureStyles();
+ const base = opts.base || '/api/agent-notes';
+ const title = opts.title || 'Private Notes';
+ const host = resolveMount(opts.mount);
+
+ let root = mounted.get(opts.id);
+ if (!root) {
+ root = document.createElement('section'); root.className = 'cn-wrap';
+ mounted.set(opts.id, root);
+ }
+ root.innerHTML =
+ `<div class="cn-head"><h3>📝 ${esc(title)}</h3>` +
+ (opts.subject ? `<span class="cn-subj">${esc(opts.subject)}</span>` : '') +
+ `<span class="cn-when" id="cn-when"></span></div>` +
+ `<textarea class="cn-ta" id="cn-ta" placeholder="Private notes on this profile — call attempts, financing angle, follow-ups… (visible only to you)"></textarea>` +
+ `<div class="cn-bar"><button class="cn-save" id="cn-save">Save note</button>` +
+ `<span class="cn-status" id="cn-status"></span></div>` +
+ `<div class="cn-hint" id="cn-hint" hidden></div>`;
+ if (!root.isConnected) host.appendChild(root);
+
+ const ta = root.querySelector('#cn-ta');
+ const saveBtn = root.querySelector('#cn-save');
+ const statusEl = root.querySelector('#cn-status');
+ const whenEl = root.querySelector('#cn-when');
+ const hintEl = root.querySelector('#cn-hint');
+ let signedIn = false, lastSavedValue = '', dirty = false;
+
+ function setWhen(iso) {
+ const w = fmtWhen(iso);
+ whenEl.innerHTML = w ? `last updated <b>${esc(w.rel)}</b>` : '';
+ if (w) whenEl.title = 'Modified ' + w.abs + ' · ' + w.iso;
+ }
+ function status(msg, cls) { statusEl.textContent = msg || ''; statusEl.className = 'cn-status' + (cls ? ' ' + cls : ''); }
+
+ function lockSignedOut() {
+ signedIn = false; ta.disabled = true; saveBtn.disabled = true;
+ hintEl.hidden = false;
+ hintEl.innerHTML = 'Sign in to keep private notes on this profile. ' +
+ (window.CRCPNotes && window.CRCPNotes.onSignIn ? '<a id="cn-signin">Sign in</a>' : '');
+ const link = hintEl.querySelector('#cn-signin');
+ if (link) link.onclick = () => { try { window.CRCPNotes.onSignIn(); } catch (_) {} };
+ }
+
+ // Load the current note for this id.
+ api(base).then(r => {
+ signedIn = !!r.signed_in;
+ if (!signedIn) { lockSignedOut(); return; }
+ hintEl.hidden = true; ta.disabled = false; saveBtn.disabled = false;
+ const rec = (r.notes || {})[opts.id];
+ ta.value = lastSavedValue = (rec && rec.note) || '';
+ setWhen(rec && rec.updated_at);
+ }).catch(() => { status('Could not load notes', 'err'); });
+
+ function save() {
+ if (!signedIn) return;
+ const note = ta.value;
+ if (note === lastSavedValue) { status('No changes', ''); return; }
+ saveBtn.disabled = true; status('Saving…', '');
+ api(base + '/' + encodeURIComponent(opts.id), { method: 'POST', body: JSON.stringify({ note }) })
+ .then(r => {
+ if (r && r.ok) {
+ lastSavedValue = note; dirty = false;
+ const iso = (r.note && r.note.updated_at) || (note.trim() ? new Date().toISOString() : '');
+ setWhen(iso);
+ status(note.trim() ? 'Saved ✓' : 'Cleared', 'ok');
+ } else if (r && r.signed_in === false) {
+ lockSignedOut();
+ } else { status('Save failed', 'err'); }
+ })
+ .catch(() => status('Save failed', 'err'))
+ .finally(() => { saveBtn.disabled = !signedIn; });
+ }
+
+ saveBtn.addEventListener('click', save);
+ ta.addEventListener('input', () => { dirty = true; status('', ''); });
+ ta.addEventListener('blur', () => { if (dirty) save(); }); // autosave on blur
+ // ⌘/Ctrl+Enter saves without leaving the textarea.
+ ta.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); save(); } });
+
+ return root;
+ }
+
+ window.CRCPNotes = { mount, fmtWhen };
+})();
diff --git a/public/agent.html b/public/agent.html
index 09ba9da..d31ef0b 100644
--- a/public/agent.html
+++ b/public/agent.html
@@ -62,6 +62,7 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
<div class="toolbar"><h3>Firm listings <span class="count" id="firmcount"></span> <span style="font-weight:400;font-size:11px;color:var(--mut)">— from <span id="firmname"></span>'s own site (firm inventory, not this agent's individual book)</span></h3></div>
<div id="firmgrid" class="grid"></div>
</div>
+ <div id="agentNotes"></div>
<div class="note" id="prov"></div>
</div>
<script>
@@ -139,8 +140,13 @@ else fetch('/api/agent-profile?'+(ID?'id='+encodeURIComponent(ID):'name='+encode
$('#firmwrap').hidden=false;
}
$('#prov').innerHTML=`Profile built from Designer/CRCP's own broker graph — agent resolved by name; current + past (closed/sold) listings from our records; <b>Firm listings</b> scraped directly from the firm's own website (openclaw + local model), attributed at the firm level. No aggregator (Crexi/LoopNet/etc.) data is used or shown.`;
+ // Private per-user notes on this agent (Steve 2026-08-19). Stable key: the broker DB id when we
+ // resolved one (names collide), else the name. updated_at drives the "last updated" stamp.
+ const noteKey='agent:'+(a.id!=null?a.id:('name:'+(NAME||ID||'unknown')));
+ if(window.CRCPNotes) CRCPNotes.mount({id:noteKey, title:'Private Notes', subject:a.name||NAME, mount:'#agentNotes'});
}).catch(()=>{ $('#hero').innerHTML=`<div class="who"><h2>${esc(NAME)}</h2><div class="meta">Could not load this agent.</div></div>`; });
</script>
+<script src="/agent-notes.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/crcp-theme.js" defer></script>
</body></html>
diff --git a/public/broker.html b/public/broker.html
index 320f0f4..088d1f1 100644
--- a/public/broker.html
+++ b/public/broker.html
@@ -87,6 +87,7 @@
<div class="topbar"><a class="back" href="/brokers.html">← back to broker directory</a></div>
<div class="wrap" id="root"><div class="load">Loading broker profile…</div></div>
+<script src="/agent-notes.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script src="/crcp-theme.js" defer></script>
<script>
@@ -241,12 +242,15 @@ function render(b){
<div class="indexed">Source: ${esc(b.source||'crexi')} · CRCP broker #${esc(b.id)}</div>
</div>
</div>
- </div>`;
+ </div>
+ <div id="agentNotes"></div>`;
document.title = (b.name||'Broker')+' — CRCP Profile';
const sortSel=document.getElementById('lsort'); if(sortSel){ sortSel.value=PREF.sort; sortSel.onchange=()=>{PREF.sort=sortSel.value;localStorage.setItem('brokerLxSort',PREF.sort);renderListings();}; }
const dens=document.getElementById('ldens'); if(dens){ dens.oninput=()=>{PREF.cols=+dens.value;localStorage.setItem('brokerLxCols',PREF.cols);renderListings();}; }
renderListings();
+ // Private per-user notes on this broker (Steve 2026-08-19), keyed by the stable CRCP broker id.
+ if(window.CRCPNotes && b.id!=null) CRCPNotes.mount({id:'broker:'+b.id, title:'Private Notes', subject:b.name||('Broker #'+b.id), mount:'#agentNotes'});
}
function loadById(id){
diff --git a/public/condos.html b/public/condos.html
index f781f2f..29c5d3c 100644
--- a/public/condos.html
+++ b/public/condos.html
@@ -166,6 +166,32 @@
</script>
<div class="banner" id="banner">⚖️ Loading warrantability label…</div>
+<!-- Non-Warrantable Condos section (Steve 2026-08-19): a dedicated, honest FHA/warrantability
+ summary above the grid. Populated from the live classified inventory (DATA) by renderNonWarrant(). -->
+<style>
+ .nw-sec{margin:14px 22px 0;border:1px solid var(--line);border-radius:14px;background:var(--card);padding:16px 18px}
+ .nw-sec h2{margin:0 0 4px;font-size:16px;color:var(--ink);display:flex;align-items:center;gap:8px}
+ .nw-sec .lede{color:var(--mut);font-size:12.5px;line-height:1.55;margin:0 0 12px;max-width:900px}
+ .nw-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:12px}
+ .nw-tile{border:1px solid var(--line);border-radius:11px;background:var(--bg2);padding:11px 13px;cursor:pointer;transition:border-color .12s}
+ .nw-tile:hover{border-color:var(--blue)}
+ .nw-tile .n{font-size:22px;font-weight:800;line-height:1.1}
+ .nw-tile .lbl{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;margin-top:2px}
+ .nw-tile .sub{font-size:11px;color:var(--mut);margin-top:4px;line-height:1.4}
+ .nw-tile.approved .n{color:var(--acc)} .nw-tile.expired .n{color:var(--gold)}
+ .nw-tile.flagged .n{color:var(--red)} .nw-tile.notlisted .n{color:var(--mut)}
+ .nw-cta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:6px}
+ .nw-btn{background:var(--red);color:#fff;border:0;border-radius:9px;padding:9px 16px;font-size:12.5px;font-weight:700;cursor:pointer}
+ .nw-btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}
+ .nw-note{font-size:11px;color:var(--mut)}
+ .nw-ex{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}
+ .nw-ex h3{margin:0 0 7px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.4px}
+ .nw-row{display:flex;flex-wrap:wrap;gap:4px 12px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.04);font-size:12.5px}
+ .nw-row .a{font-weight:600;color:var(--ink)} .nw-row .why{color:var(--gold);font-size:11.5px}
+ .nw-row .why.nl{color:var(--mut)}
+</style>
+<section id="nwSection" class="nw-sec" hidden></section>
+
<div class="shell">
<aside class="rail">
<div class="rsec" id="fieldsSec"><h4>Fields / columns — show / hide · reorder</h4>
@@ -575,6 +601,52 @@ function apply(){
}
// ---- boot ----
+// ── Non-Warrantable Condos section ─────────────────────────────────────────────
+// Steve 2026-08-19: "create a unwarrantable condos section with real fha and warrant info."
+// HONEST labeling (the classifier's hard rule): "non-warrantable" here = NOT on the current
+// HUD FHA-approved list — a financing-eligibility PROXY, not a lender-verified Fannie/Freddie
+// warrantability call. We split it into its real buckets so a loan officer sees WHY each is risk.
+const nwMoney=n=>(n==null||isNaN(+n))?'—':'$'+Math.round(+n).toLocaleString();
+function setWarr(v){ F.warr=v; $$('#fWarr .chip').forEach(x=>x.classList.toggle('active',x.dataset.warr===v)); apply();
+ const g=$('#grid'); if(g&&g.scrollIntoView) g.scrollIntoView({behavior:'smooth',block:'start'}); }
+function nwReason(c){
+ const ws=c.warrantable_status||c.warrant_signal;
+ if(ws==='fha_expired'){ const exp=c.fha_expiration_date||''; return {cls:'',txt:'FHA approval LAPSED'+(exp?' '+esc(exp):'')+' — re-certification required'}; }
+ if(ws==='heuristic_flag'){ const sig=(c.warrant_signals&&c.warrant_signals.signals)||[]; const n=sig[0]; return {cls:'',txt:esc((n&&n.note)||'hard non-warrantable signal flagged (condotel / investor / cash-only)')}; }
+ return {cls:'nl',txt:'Not on the FHA-approved list — verify warrantability with the lender'};
+}
+function renderNonWarrant(){
+ const sec=$('#nwSection'); if(!sec) return;
+ if(!DATA.length){ sec.hidden=true; return; }
+ const cnt={fha_approved:0,fha_expired:0,heuristic_flag:0,not_listed:0};
+ DATA.forEach(c=>{ const s=c.warrantable_status||c.warrant_signal||'not_listed'; if(cnt[s]!=null)cnt[s]++; });
+ const total=DATA.length, nonWarr=total-(cnt.fha_approved||0);
+ const pct=total?Math.round(1000*nonWarr/total)/10:0;
+ // Real HUD-lapsed examples — the most actionable: a project that WAS FHA-approved and lapsed is a
+ // re-certification opportunity. Highest-price first (biggest loan). Uses real warrant_source data.
+ const expired=DATA.filter(c=>(c.warrantable_status||c.warrant_signal)==='fha_expired')
+ .sort((a,b)=>(+b.price||0)-(+a.price||0)).slice(0,5);
+ const tile=(cls,n,lbl,sub,warr)=>`<div class="nw-tile ${cls}" data-setwarr="${warr}" title="Show only these"><div class="n">${n.toLocaleString()}</div><div class="lbl">${lbl}</div><div class="sub">${sub}</div></div>`;
+ sec.hidden=false;
+ sec.innerHTML=
+ `<h2>🚫 Non-Warrantable Condos <span style="font-weight:400;font-size:12px;color:var(--mut)">— ${nonWarr.toLocaleString()} of ${total.toLocaleString()} (${pct}%) not FHA-approved</span></h2>`+
+ `<p class="lede">A <b>non-warrantable</b> condo can't be financed with a standard conforming/FHA loan, so a buyer needs a portfolio or non-QM product (bigger down, higher rate). This is an <b>FHA/VA-approval-based proxy</b>, <b>not</b> a lender-verified Fannie/Freddie warrantability call — always confirm with the lender. Below, the ${nonWarr.toLocaleString()} non-FHA-approved condos split into their real risk buckets:</p>`+
+ `<div class="nw-tiles">`+
+ tile('approved',cnt.fha_approved,'FHA-Approved','Financeable (proxy) — on the HUD list','fha_approved')+
+ tile('expired',cnt.fha_expired,'FHA-Expired','Was approved, cert lapsed — re-cert needed','fha_expired')+
+ tile('notlisted',cnt.not_listed,'Not-Listed','Not on the FHA list — verify w/ lender','not_listed')+
+ tile('flagged',cnt.heuristic_flag,'Flagged','Hard non-warrantable signal fired','heuristic_flag')+
+ `</div>`+
+ `<div class="nw-cta"><button class="nw-btn" id="nwShowAll">Show all ${nonWarr.toLocaleString()} non-warrantable →</button>`+
+ `<button class="nw-btn ghost" id="nwShowApproved">Show FHA-approved only</button>`+
+ `<span class="nw-note">Click any tile to filter the grid to that bucket.</span></div>`+
+ (expired.length?`<div class="nw-ex"><h3>⚠ FHA-approval LAPSED — re-certification opportunities (real HUD list matches)</h3>`+
+ expired.map(c=>{ const r=nwReason(c); return `<div class="nw-row"><span class="a">${esc(c.address||c.project_name||'—')}</span><span>${esc(c.city||'')}</span><span>${nwMoney(c.price)}</span><span class="why ${r.cls}">${r.txt}</span></div>`; }).join('')+
+ `</div>`:'');
+ sec.querySelectorAll('[data-setwarr]').forEach(el=>el.onclick=()=>setWarr(el.dataset.setwarr==='fha_approved'?'fha_approved':el.dataset.setwarr));
+ const sa=sec.querySelector('#nwShowAll'); if(sa) sa.onclick=()=>setWarr('unwarrantable');
+ const ap=sec.querySelector('#nwShowApproved'); if(ap) ap.onclick=()=>setWarr('fha_approved');
+}
async function boot(){
try{ const r=await (await fetch('/api/condo-notes')).json(); NOTES=r.notes||{}; }catch(_){}
try{ const r=await (await fetch('/api/condos')).json(); DATA=r.condos||[]; if(r.label) bannerDefault='⚖️ '+r.label; }catch(_){}
@@ -601,7 +673,7 @@ async function boot(){
if(sortParam && ['price_desc','price_asc','pps_desc','year_desc','hoa_asc','proj_az','city_az','expiry_soon'].includes(sortParam)){
SORTKEY=sortParam; const sel=$('#csort'); if(sel) sel.value=SORTKEY; try{localStorage.setItem('condoSort',SORTKEY);}catch(_){}
}
- buildRail(); apply();
+ buildRail(); apply(); renderNonWarrant();
}
// ---- wiring ----
diff --git a/public/deals-flow.html b/public/deals-flow.html
index deba379..6af392c 100644
--- a/public/deals-flow.html
+++ b/public/deals-flow.html
@@ -355,9 +355,9 @@ const ROLE_META={
other:{label:'Member',tip:'',qa:[['📊 Deal flow','/deals-flow.html'],['🏷️ Listings','/mls.html']]}
};
const ROLE_OPTS=[['loan_officer','Loan Officer','mortgage / lending'],['listing_agent','Listing Agent','brokerage — listing'],['buyers_agent','Buyer’s Agent','brokerage — buy-side'],['investor','Investor','principal / capital'],['appraiser','Appraiser','valuation'],['other','Something else','']];
-function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);const rl=ME.role&&ROLE_META[ME.role]?ROLE_META[ME.role].label:'';el.innerHTML=`<b style="color:var(--ink,#fff)">${who}</b>${rl?` · <span style="color:var(--acc)">${esc(rl)}</span>`:''}${ME.perm==='admin'?` · <a href="/admin.html" style="color:var(--gold)">Admin</a>`:''} · <a id="signOutLink" style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('#signOutLink').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=openLogin;}}
+function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);const rl=ME.role&&ROLE_META[ME.role]?ROLE_META[ME.role].label:'';const co=ME.company?` · <span style="color:var(--gold)">${esc(ME.company)}</span>`:'';el.innerHTML=`<b style="color:var(--ink,#fff)">${who}</b>${co}${rl?` · <span style="color:var(--acc)">${esc(rl)}</span>`:''}${ME.perm==='admin'?` · <a href="/admin.html" style="color:var(--gold)">Admin</a>`:''} · <a id="signOutLink" style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('#signOutLink').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=openLogin;}}
function renderPersona(){const p=$('#persona');if(!(ME&&ME.email&&ME.role)){p.style.display='none';return;}const m=ROLE_META[ME.role]||ROLE_META.other;const who=esc(ME.name||ME.username||'there');p.style.display='';p.innerHTML=`<h2>Welcome back, ${who}</h2><span class="role">${esc(m.label)}</span><div class="qa">${m.qa.map(([t,h])=>`<a href="${h}">${t}</a>`).join('')}</div>${m.tip?`<p class="tip">${esc(m.tip)}</p>`:''}<span class="edit" id="editRole">change role</span>`;const er=$('#editRole');if(er)er.onclick=openRole;}
-async function loadMe(){try{ME=await api('/api/me');}catch(e){ME=null;}renderAuth();renderPersona();$('#savedSec').style.display=(ME&&ME.email)?'':'none';if(ME&&ME.email){loadSaved();loadWatch();if(!ME.role)openRole();}}
+async function loadMe(){try{ME=await api('/api/me');}catch(e){ME=null;}renderAuth();renderPersona();$('#savedSec').style.display=(ME&&ME.email)?'':'none';if(ME&&ME.email){await loadSaved();loadWatch();maybeApplyDefault();if(!ME.role)openRole();}}
// ── sign-in modal (username + password) ──
function openLogin(){$('#liErr').textContent='';$('#liUser').value='';$('#liPass').value='';$('#loginModal').classList.add('on');setTimeout(()=>$('#liUser').focus(),40);}
const signIn=openLogin; // back-compat: save/watch/star gates call signIn() to prompt sign-in
@@ -396,11 +396,17 @@ loadTicker();
if(lbl)lbl.addEventListener('click',()=>{if(t.classList.contains('is-dismissed'))setHidden(false);});
try{if(sessionStorage.getItem('crcpTickerHidden')==='1')t.classList.add('is-dismissed');}catch(e){}
})();
-async function loadSaved(){const r=await api('/api/saved-searches');const s=r.saved||[];window._saved=s;$('#fSaved').innerHTML=s.length?s.map(x=>`<span class="chip" data-sid="${x.id}" title="apply this search">${esc(x.name)}<span class="ct" data-del="${x.id}" title="delete">✕</span></span>`).join(''):'<span style="font-size:11px;color:var(--mut)">none yet — set filters, then ★ Save search</span>';}
+async function loadSaved(){const r=await api('/api/saved-searches');const s=r.saved||[];window._saved=s;$('#fSaved').innerHTML=s.length?s.map(x=>`<span class="chip${x.is_default?' active':''}" data-sid="${x.id}" title="${x.is_default?'default — auto-applied on load · click to apply':'apply this search'}">${x.is_default?'★ ':''}${esc(x.name)}<span class="ct" data-del="${x.id}" title="delete">✕</span></span>`).join(''):'<span style="font-size:11px;color:var(--mut)">none yet — set filters, then ★ Save search</span>';}
async function loadWatch(){const r=await api('/api/watchlist');WATCH=new Set(r.ids||[]);render();}
$('#saveBtn').addEventListener('click',async()=>{if(!ME||!ME.email){signIn();return;}const name=prompt('Name this saved search:','My deal filter');if(name===null)return;const filters={src:[...F.src],use:[...F.use],year:[...F.year],conf:[...F.conf],pMin:F.pMin,uMin:F.uMin,q};await api('/api/saved-searches',{method:'POST',body:JSON.stringify({name,filters})});loadSaved();});
$('#watchBtn').addEventListener('click',()=>{if(!ME||!ME.email){signIn();return;}watchOnly=!watchOnly;$('#watchBtn').style.borderColor=watchOnly?'var(--gold)':'var(--line)';render();});
-$('#fSaved').addEventListener('click',async e=>{const del=e.target.closest('[data-del]');if(del){await fetch('/api/saved-searches/'+del.dataset.del,{method:'DELETE'});loadSaved();return;}const c=e.target.closest('[data-sid]');if(!c)return;const s=(window._saved||[]).find(x=>x.id===c.dataset.sid);if(!s)return;const f=s.filters||{};F.src=new Set(f.src||[]);F.use=new Set(f.use||[]);F.year=new Set(f.year||[]);F.conf=new Set(f.conf||[]);F.pMin=f.pMin??null;F.uMin=f.uMin??null;q=f.q||'';$('#q').value=q;render();});
+$('#fSaved').addEventListener('click',async e=>{const del=e.target.closest('[data-del]');if(del){await fetch('/api/saved-searches/'+del.dataset.del,{method:'DELETE'});loadSaved();return;}const c=e.target.closest('[data-sid]');if(!c)return;const s=(window._saved||[]).find(x=>x.id===c.dataset.sid);if(!s)return;window._defaultApplied=true;applySaved(s);});
+// Apply a saved search's filters into the live rail state (chips + min-inputs + URL). Shared by the
+// saved-chip click AND the on-load default (Steve 2026-08-19: Frank's "4-Unit Multifamily" default).
+function applySaved(s){const f=(s&&s.filters)||{};F.src=new Set(f.src||[]);F.use=new Set(f.use||[]);F.year=new Set(f.year||[]);F.conf=new Set(f.conf||[]);F.exact={};F.pMin=(f.pMin==null?null:+f.pMin);F.uMin=(f.uMin==null?null:+f.uMin);q=f.q||'';const qi=$('#q');if(qi)qi.value=q;const um=$('#uMin');if(um)um.value=(f.uMin!=null?f.uMin:'');const pm=$('#pMin');if(pm)pm.value=(f.pMin!=null?f.pMin:'');syncRailActive('s',F.src);syncRailActive('u',F.use);syncRailActive('y',F.year);syncRailActive('c',F.conf);writeURLFilters();render();}
+function filtersEmpty(){return !F.src.size&&!F.use.size&&!F.year.size&&!F.conf.size&&!Object.keys(F.exact).length&&F.pMin==null&&F.uMin==null&&!q;}
+// On a fresh load with no URL filters, auto-apply the user's default saved search (once).
+function maybeApplyDefault(){if(window._defaultApplied||window._hadURLFilters)return;if(!filtersEmpty())return;const def=(window._saved||[]).find(x=>x.is_default);if(!def)return;window._defaultApplied=true;applySaved(def);}
// href-drill rule: card values drill to their filtered view (reuses the rail F filters + keeps rail chips in sync)
function syncRailActive(attr,set){const map={s:'#fSrc',u:'#fUse',y:'#fYear',c:'#fConf'},el=$(map[attr]);if(el)el.querySelectorAll('.chip').forEach(ch=>ch.classList.toggle('active',set.has(ch.dataset[attr])));}
// ── property-detail drill: fetch the free public-record dossier + render inline ──
@@ -507,8 +513,9 @@ Promise.all([
DATA=c.concat(e).concat(f).concat(u);
buildRail();
// Apply any URL filter state (from a shared/bookmarked card drill-link) before the first render,
- // then reflect it into the rail chips so the active filters are visible.
- readURLFilters();
+ // then reflect it into the rail chips so the active filters are visible. Record whether the URL
+ // carried filters so loadMe() knows whether to auto-apply the user's default saved search on load.
+ window._hadURLFilters=readURLFilters();
syncRailActive('s',F.src);syncRailActive('u',F.use);syncRailActive('y',F.year);syncRailActive('c',F.conf);
render();loadMe();
}).catch(()=>{$('#count').textContent='failed to load deal data';});
diff --git a/scripts/crcp-accounts.js b/scripts/crcp-accounts.js
index 61b501a..b494ee5 100644
--- a/scripts/crcp-accounts.js
+++ b/scripts/crcp-accounts.js
@@ -41,6 +41,7 @@ module.exports = function mountAccounts(app, ROOT) {
// orthogonal axes, deliberately not merged (Frank is job=loan_officer AND perm=standard).
return { email: s.email, tier: u.tier || 'free', username: u.username || null,
name: u.name || null, role: u.role || null, industry: u.industry || null,
+ company: u.company || null,
perm: u.perm === 'admin' ? 'admin' : 'standard' };
}
// Require an admin session on an admin-only route. Returns the user object, or null after
@@ -203,6 +204,33 @@ module.exports = function mountAccounts(app, ROOT) {
db.users[fkey].perm = 'standard'; dirty = true;
}
+ // Frank's firm handle (Steve 2026-08-19: "new user name Frank/Arcstone818"). Recorded as his
+ // company so it surfaces on the auth line; env-overridable. Idempotent — never clobbers a value
+ // Frank later edits himself.
+ const fcompany = String(process.env.CRCP_SEED_COMPANY || 'Arcstone818').replace(/[<>]/g, '').slice(0, 80);
+ if (db.users[fkey] && !db.users[fkey].company) { db.users[fkey].company = fcompany; dirty = true; }
+
+ // Frank's default landing filter (Steve 2026-08-19: "Onload, show 4 unit multifamily saved
+ // filter"). Seed a "4-Unit Multifamily" saved search flagged is_default so deals-flow.html
+ // auto-applies it on load when no URL filter is present. Idempotent: only seeds when Frank has
+ // NO saved searches at all, so a later user-created list is never disturbed. Filters map to the
+ // deals-flow model — Residential use (2-4 unit income property is classed Residential) with
+ // ≥4 units = the fourplex band a loan officer prospects. Reversible (delete the record).
+ db.saved[fkey] = db.saved[fkey] || [];
+ if (db.saved[fkey].length === 0) {
+ db.saved[fkey].unshift({
+ id: rid().slice(0, 12),
+ name: '4-Unit Multifamily',
+ filters: { use: ['Residential'], uMin: 4 },
+ is_default: true,
+ created: new Date().toISOString(),
+ last_seen: new Date().toISOString().slice(0, 10),
+ seeded: true,
+ });
+ dirty = true;
+ console.log('[crcp-accounts] seeded default "4-Unit Multifamily" saved search for ' + fkey);
+ }
+
// Admin — the account-management login. Only ever auto-seeded if NO admin exists yet, so a
// later manual demotion/reshuffle of admins is never silently re-created behind Steve's back.
const hasAdmin = Object.values(db.users).some(u => u && u.perm === 'admin');
@@ -293,6 +321,17 @@ module.exports = function mountAccounts(app, ROOT) {
const db = load(); db.saved[u.email] = (db.saved[u.email] || []).filter(s => s.id !== req.params.id); save(db);
res.json({ ok: true });
});
+ // Mark ONE saved search as the default (the one deals-flow.html auto-applies on load). Clears the
+ // flag on the user's other searches so exactly one is default. Pass id='' / 'none' to clear all.
+ app.post('/api/saved-searches/:id/default', (req, res) => {
+ const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' });
+ const db = load(); const list = db.saved[u.email] = db.saved[u.email] || [];
+ const id = req.params.id;
+ let hit = false;
+ list.forEach(s => { const on = (s.id === id); s.is_default = on; if (on) hit = true; });
+ save(db);
+ res.json({ ok: true, default: hit ? id : null });
+ });
// ── watchlist (star a deal) ────────────────────────────────────────────────────
app.get('/api/watchlist', (req, res) => { const u = userOf(req); if (!u) return res.status(401).json({ error: 'sign in' }); res.json({ ids: (load().watch[u.email]) || [] }); });
diff --git a/scripts/crcp-notes.js b/scripts/crcp-notes.js
index 5a24350..222e599 100644
--- a/scripts/crcp-notes.js
+++ b/scripts/crcp-notes.js
@@ -1,12 +1,17 @@
-// crcp-notes.js — PER-USER private per-listing scratch notes for the CRCP deal-flow tool.
+// crcp-notes.js — PER-USER private scratch notes for the CRCP deal-flow tool.
// Hybrid CRM model (Steve 2026-08-06, TK-10301): a loan officer's scratch notes on a specific
-// listing are PRIVATE to that user, while the contact rolodex (agent-contacts in serve.js) stays a
+// entity are PRIVATE to that user, while the contact rolodex (agent-contacts in serve.js) stays a
// shared team asset. This module owns the notes half.
//
-// Store: data/condo-notes.json. NEW shape is namespaced by user key:
-// { "_perUser": true, "users": { "<userKey>": { "<listingId>": { note, updated_at } } } }
-// The OLD shape was a flat { "<listingId>": { note, updated_at } } shared by everyone. On first
-// load we auto-migrate that flat map under the FIRST user (Frank) so no note is lost.
+// SCOPES (2026-08-19): the same per-user note engine now backs two independent surfaces —
+// • condo listings → data/condo-notes.json (routes: /api/condo-notes[/:id]) — original, unchanged
+// • agent/broker profiles → data/agent-notes.json (routes: /api/agent-notes[/:id]) — NEW
+// Each scope is a SEPARATE JSON file so an agent id can never collide with a condo listing id.
+//
+// Store shape (per scope file), namespaced by user key:
+// { "_perUser": true, "users": { "<userKey>": { "<entityId>": { note, updated_at } } } }
+// The condo store may still carry the OLD flat shape { "<listingId>": { note, updated_at } } from
+// before per-user notes; on first load we auto-migrate that flat map under the FIRST user (Frank).
//
// Auth: notes are attributed to the signed-in account (crcp_sid session, via userOf). Reads with no
// session return an empty set (page still renders); writes with no session return 401 so the client
@@ -18,66 +23,82 @@ const fs = require('fs');
const path = require('path');
module.exports = function mountNotes(app, ROOT, userOf) {
- const FILE = path.join(ROOT, 'data', 'condo-notes.json');
const clip = (s, n) => String(s == null ? '' : s).slice(0, n);
// Which user owns migrated legacy notes — the first/only real user at migration time.
const FRANK = String(process.env.CRCP_SEED_USER || 'frank').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '');
- // Load + normalize to the per-user shape. Auto-migrates a legacy flat map under FRANK's key.
- function loadStore() {
- let raw;
- try { raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { _perUser: true, users: {} }; }
- if (raw && raw._perUser && raw.users && typeof raw.users === 'object') return raw;
- // Legacy flat { id: {note, updated_at} } → wrap under FRANK. Guard against an empty/oddball file.
- const legacy = (raw && typeof raw === 'object') ? raw : {};
- const hasLegacy = Object.values(legacy).some(v => v && typeof v === 'object' && 'note' in v);
- return { _perUser: true, users: hasLegacy ? { [FRANK]: legacy } : {} };
- }
+ // A scope = one JSON file + its route pair. `migrateLegacyFlat` only applies to the condo store
+ // (the agent store is new, so it never has a legacy flat shape to rescue).
+ function mountScope({ file, base, label, migrateLegacyFlat }) {
+ const FILE = path.join(ROOT, 'data', file);
+
+ // Load + normalize to the per-user shape. Auto-migrates a legacy flat map under FRANK's key.
+ function loadStore() {
+ let raw;
+ try { raw = JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { _perUser: true, users: {} }; }
+ if (raw && raw._perUser && raw.users && typeof raw.users === 'object') return raw;
+ if (!migrateLegacyFlat) return { _perUser: true, users: {} };
+ // Legacy flat { id: {note, updated_at} } → wrap under FRANK. Guard against an empty/oddball file.
+ const legacy = (raw && typeof raw === 'object') ? raw : {};
+ const hasLegacy = Object.values(legacy).some(v => v && typeof v === 'object' && 'note' in v);
+ return { _perUser: true, users: hasLegacy ? { [FRANK]: legacy } : {} };
+ }
+
+ // Serialized read-modify-write with atomic rename (mirrors the agent-contacts / accounts store).
+ let _q = Promise.resolve();
+ function withStore(mutator) {
+ const run = _q.then(() => {
+ const store = loadStore();
+ const out = mutator(store);
+ const tmp = FILE + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
+ fs.renameSync(tmp, FILE);
+ return out;
+ });
+ _q = run.then(() => {}, () => {});
+ return run;
+ }
- // Serialized read-modify-write with atomic rename (mirrors the agent-contacts / accounts store).
- let _q = Promise.resolve();
- function withStore(mutator) {
- const run = _q.then(() => {
- const store = loadStore();
- const out = mutator(store);
- const tmp = FILE + '.tmp';
- fs.writeFileSync(tmp, JSON.stringify(store, null, 2));
- fs.renameSync(tmp, FILE);
- return out;
+ const notesFor = (store, key) => (store.users && store.users[key]) || {};
+
+ // GET <base> → the signed-in user's notes map. Admin may pass ?user=<key>.
+ app.get(base, (req, res) => {
+ const u = userOf(req);
+ if (!u) return res.json({ notes: {}, signed_in: false });
+ let key = u.email;
+ if (req.query.user && u.perm === 'admin') {
+ key = String(req.query.user).trim().toLowerCase().replace(/[^a-z0-9._@.\-]/g, '');
+ }
+ res.json({ notes: notesFor(loadStore(), key), signed_in: true, user: key });
});
- _q = run.then(() => {}, () => {});
- return run;
- }
- const notesFor = (store, key) => (store.users && store.users[key]) || {};
+ // POST <base>/:id { note } → upsert (empty note deletes). Requires a session. Returns the saved
+ // record incl. updated_at so the client can render "last updated <date>" (the modification date).
+ app.post(base + '/:id', (req, res) => {
+ const u = userOf(req);
+ if (!u) return res.status(401).json({ error: 'sign in to save notes', signed_in: false });
+ const id = clip(req.params.id, 120); if (!id) return res.status(400).json({ error: 'no id' });
+ const note = clip((req.body || {}).note, 8000);
+ const key = u.email;
+ withStore(store => {
+ store.users = store.users || {};
+ const bucket = store.users[key] = store.users[key] || {};
+ if (note.trim()) bucket[id] = { note, updated_at: new Date().toISOString() };
+ else delete bucket[id];
+ return bucket[id];
+ }).then(rec => res.json({ ok: true, id, note: rec || null }))
+ .catch(e => res.status(500).json({ error: String(e && e.message || e) }));
+ });
- // GET /api/condo-notes → the signed-in user's notes. Admin may pass ?user=<key>.
- app.get('/api/condo-notes', (req, res) => {
- const u = userOf(req);
- if (!u) return res.json({ notes: {}, signed_in: false });
- let key = u.email;
- if (req.query.user && u.perm === 'admin') {
- key = String(req.query.user).trim().toLowerCase().replace(/[^a-z0-9._@.\-]/g, '');
- }
- res.json({ notes: notesFor(loadStore(), key), signed_in: true, user: key });
- });
+ console.log(`[crcp-notes] ${label} mounted at ${base} (per-user, timestamped)`);
+ }
- // POST /api/condo-notes/:id { note } → upsert (empty note deletes). Requires a session.
- app.post('/api/condo-notes/:id', (req, res) => {
- const u = userOf(req);
- if (!u) return res.status(401).json({ error: 'sign in to save notes', signed_in: false });
- const id = clip(req.params.id, 120); if (!id) return res.status(400).json({ error: 'no id' });
- const note = clip((req.body || {}).note, 8000);
- const key = u.email;
- withStore(store => {
- store.users = store.users || {};
- const bucket = store.users[key] = store.users[key] || {};
- if (note.trim()) bucket[id] = { note, updated_at: new Date().toISOString() };
- else delete bucket[id];
- return bucket[id];
- }).then(rec => res.json({ ok: true, id, note: rec || null }))
- .catch(e => res.status(500).json({ error: String(e && e.message || e) }));
- });
+ // Condo listing notes — original surface, file + routes unchanged (back-compat).
+ mountScope({ file: 'condo-notes.json', base: '/api/condo-notes', label: 'condo-listing notes', migrateLegacyFlat: true });
+ // Agent / broker profile notes — NEW surface (Steve 2026-08-19: "on each agent page, provide space
+ // for NOTES and record any modification date and update"). Keyed by the profile's stable id
+ // (e.g. agent:<id|name>, broker:<id>). updated_at is the "modification date" the client shows.
+ mountScope({ file: 'agent-notes.json', base: '/api/agent-notes', label: 'agent/broker profile notes', migrateLegacyFlat: false });
- console.log('[crcp-notes] per-user private listing notes mounted (hybrid CRM: private notes + shared contacts)');
+ console.log('[crcp-notes] per-user private notes mounted (hybrid CRM: private notes + shared contacts)');
};
← 258bd68 firm-site scraper: add missing sleep() helper (pageText cras
·
back to Commercialrealestate
·
CRCP: 'Pocket listing' badge for off-market rows with no con 95ff71b →