Add mobile email-approval bell (#6) (v0.1.0:102)

An admin-only bell in the mobile top bar (left of the camera) surfaces the
SAME pending email-capture proposals the web "Email Capture" panel and the
Matrix review room decide — a third surface over the existing endpoints, no
new backend.

- Count badge (iPhone-style) from a 45s poll of GET /api/activity/proposals;
  dim when there are none.
- Tap → card list of proposals → tap one → review screen (investor name,
  direction/date, subject, summary, editable proposed note) → Approve & log to
  grid (POST .../{id}/approve {note}) or Reject (POST .../{id}/dismiss).
- Bidirectional sync is automatic: an app decision flips the proposal status
  and the bot's poll redacts the Matrix thread; a Matrix/web decision drops the
  proposal from the pending list the bell polls, clearing the badge.
- No LLM round-trip (edit-then-approve, like the web panel). Mobile-gated
  (isMobile && admin) so the hidden desktop top bar never polls the endpoint.

Frontend-only; no schema, migration, or dependency change.
This commit is contained in:
Keysat
2026-06-20 15:36:56 -05:00
parent b04f83e1d1
commit 14c951de57
4 changed files with 161 additions and 4 deletions
+128
View File
@@ -2586,6 +2586,31 @@
as a ~1px dot). Explicit CSS dimensions + flex:none fix it — the same reason the working
.bottom-tab-icon (sized box) and .sort-pill (flex:none + text) icons render. */
.quicklog-btn svg { width: 18px; height: 18px; flex: none; }
/* #6 — email-approval bell: dim at rest, accent + a red count badge when alerts are waiting. */
.bell-btn { position: relative; color: var(--text-subtle); }
.bell-btn.has-alerts { color: var(--accent-light); }
.bell-badge {
position: absolute; top: -3px; right: -3px;
min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; line-height: 1;
border-radius: 999px; background: var(--danger-soft); color: #fff; /* always white on the red dot — theme-stable */
font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 700;
display: inline-flex; align-items: center; justify-content: center;
border: 1.5px solid var(--bg-panel-elevated);
}
.bell-card {
width: 100%; text-align: left; cursor: pointer; font-family: inherit;
background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px;
padding: 12px 13px; margin-bottom: 8px; display: flex; flex-direction: column; gap: 5px; color: var(--text-primary);
}
.bell-card:active { background: var(--bg-hover); }
.bell-card-row1 { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.bell-card-name { font-size: 15px; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bell-card-dir { flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
.bell-card-subject { font-size: 13px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bell-card-note { font-size: 12px; color: var(--text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.bell-meta { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); margin: -4px 0 10px; }
.bell-summary { font-size: 13px; color: var(--text-secondary); line-height: 1.45; margin: 0 0 14px; padding: 10px 12px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 8px; }
.bell-back { width: 100%; margin-top: 10px; background: transparent; border: none; color: var(--accent-light); font-size: 14px; font-family: inherit; cursor: pointer; padding: 8px; }
.quicklog-hint { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin: 0 0 12px; }
.quicklog-pool { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; }
@@ -14417,6 +14442,105 @@
img.src = url;
});
// #6 — email-capture approval bell (mobile top bar, admin-only). The web "Email Capture"
// panel + the Matrix review room both decide the SAME pending proposals; this is a third
// surface over the SAME endpoints (GET /api/activity/proposals + POST .../{id}/approve|dismiss),
// so sync is automatic: deciding here flips the proposal status and the bot's poll redacts the
// Matrix thread; a Matrix/web decision drops the proposal from the pending list our poll reads,
// clearing the badge. No new endpoint, no LLM round-trip (edit-then-approve, like the web panel).
const MobileEmailBell = ({ token, onShowToast }) => {
const [proposals, setProposals] = useState(null); // null = not yet loaded
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState(null); // the proposal being reviewed
const [noteDraft, setNoteDraft] = useState('');
const [busy, setBusy] = useState(false);
const busyRef = useRef(false); // synchronous in-flight guard (setBusy is async — a fast double-tap could double-POST)
const load = useCallback(async () => {
try {
const r = await api('/api/activity/proposals', {}, token);
setProposals(Array.isArray(r.proposals) ? r.proposals : []);
} catch (_) { /* leave the last good count; a transient failure shouldn't blank the bell */ }
}, [token]);
// Poll while mounted so the badge reflects new captures AND decisions made elsewhere
// (a proposal approved/dismissed in the Matrix room or the web panel drops out of the
// pending list on the next poll, clearing the alert here — the "vice versa" sync).
useEffect(() => {
load();
const id = setInterval(load, 45000);
return () => clearInterval(id);
}, [load]);
const count = proposals ? proposals.length : 0;
const openSheet = () => { setSelected(null); setOpen(true); load(); };
const closeSheet = () => { setOpen(false); setSelected(null); };
const openReview = (p) => { setSelected(p); setNoteDraft(p.proposed_note || ''); };
const decide = async (decision) => {
const p = selected; if (!p || busyRef.current) return;
if (decision === 'approve' && !noteDraft.trim()) { onShowToast('The note is empty', 'error'); return; }
busyRef.current = true; setBusy(true);
try {
const body = JSON.stringify(decision === 'approve' ? { note: noteDraft } : {});
await api(`/api/activity/proposals/${p.id}/${decision}`, { method: 'POST', body }, token);
onShowToast(decision === 'approve' ? 'Logged to the grid' : 'Dismissed', 'success');
setProposals((ps) => (ps || []).filter((x) => x.id !== p.id));
setSelected(null);
load(); // reconcile with the server (also catches anything decided in parallel)
} catch (err) { onShowToast(getErrorMessage(err, 'Action failed'), 'error'); }
finally { setBusy(false); busyRef.current = false; }
};
const dirLabel = (d) => (d === 'sent' ? 'Sent' : 'Received');
return (
<>
<button className={`quicklog-btn bell-btn${count > 0 ? ' has-alerts' : ''}`} onClick={openSheet}
aria-label={count ? `Email approvals (${count})` : 'Email approvals'} title="Email approvals">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
{count > 0 && <span className="bell-badge">{count > 99 ? '99+' : count}</span>}
</button>
<BottomSheet open={open} onClose={closeSheet} title={selected ? 'Review log' : 'Email approvals'}>
{selected ? (
<>
<div className="sheet-subcaption">{selected.investor_name || 'Unmatched investor'}</div>
<div className="bell-meta">{dirLabel(selected.direction)}{selected.email_date ? ` · ${formatDateLong(selected.email_date)}` : ''}</div>
{selected.email_subject && <div className="bell-card-subject" style={{ marginBottom: '10px' }}>{selected.email_subject}</div>}
{selected.summary && <div className="bell-summary">{selected.summary}</div>}
<div className="sheet-field">
<label className="sheet-field-label">Proposed note — edit before approving, or reject</label>
<textarea className="sheet-textarea" value={noteDraft} onChange={(e) => setNoteDraft(e.target.value)} rows={6} />
</div>
<button className="sheet-submit" disabled={busy || !noteDraft.trim()} onClick={() => decide('approve')}>{busy ? 'Saving…' : 'Approve & log to grid'}</button>
<button className="sheet-remove" disabled={busy} onClick={() => decide('dismiss')}>Reject</button>
<button className="bell-back" type="button" disabled={busy} onClick={() => setSelected(null)}> Back to list</button>
</>
) : proposals == null ? (
<div className="quicklog-empty">Loading…</div>
) : proposals.length === 0 ? (
<div className="quicklog-empty">No emails waiting to be logged.</div>
) : (
<>
<div className="quicklog-hint">Each is a proposed note from captured email. Approve to log it to the grid, or reject.</div>
{proposals.map((p) => (
<button key={p.id} className="bell-card" onClick={() => openReview(p)}>
<div className="bell-card-row1">
<span className="bell-card-name">{p.investor_name || 'Unmatched investor'}</span>
<span className="bell-card-dir">{dirLabel(p.direction)}</span>
</div>
<div className="bell-card-subject">{p.email_subject || '(no subject)'}</div>
<div className="bell-card-note">{p.summary || p.proposed_note || ''}</div>
</button>
))}
</>
)}
</BottomSheet>
</>
);
};
const MobileCardCapture = ({ token, onShowToast }) => {
const fileRef = useRef(null);
const [open, setOpen] = useState(false);
@@ -14718,6 +14842,7 @@
const { token, user, logout } = useAuth();
const [page, setPage] = useState('fundraising-grid');
const [accountMenuOpen, setAccountMenuOpen] = useState(false); // mobile top-bar account popover
const isMobile = useIsMobile(); // gate mobile-only top-bar widgets that poll (the email bell)
// P6 light theme — single source of truth; the pre-paint boot script (head) already
// set documentElement.dataset.theme from localStorage. Dark stays the default.
const [theme, setTheme] = useState(() => document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');
@@ -15019,6 +15144,9 @@
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
</div>
<div className="mobile-only" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* Email-approval bell — admin-only (the approve/dismiss endpoints are
require_admin); mobile-gated so it doesn't poll from the hidden desktop top bar. */}
{isMobile && user?.role === 'admin' && <MobileEmailBell token={token} onShowToast={showToast} />}
<MobileCardCapture token={token} onShowToast={showToast} />
<MobileQuickLog token={token} onShowToast={showToast} />
<ThemeToggle theme={theme} onToggle={toggleTheme} variant="icon" />