← 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, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  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 };
})();