← back to Commercialrealestate
public/agent-notes.js
164 lines
/* 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 };
})();