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 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. */ .bottom-tab-icon (sized box) and .sort-pill (flex:none + text) icons render. */
.quicklog-btn svg { width: 18px; height: 18px; flex: none; } .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-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-pool { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; } .quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; }
@@ -14417,6 +14442,105 @@
img.src = url; 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 MobileCardCapture = ({ token, onShowToast }) => {
const fileRef = useRef(null); const fileRef = useRef(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -14718,6 +14842,7 @@
const { token, user, logout } = useAuth(); const { token, user, logout } = useAuth();
const [page, setPage] = useState('fundraising-grid'); const [page, setPage] = useState('fundraising-grid');
const [accountMenuOpen, setAccountMenuOpen] = useState(false); // mobile top-bar account popover 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 // P6 light theme — single source of truth; the pre-paint boot script (head) already
// set documentElement.dataset.theme from localStorage. Dark stays the default. // set documentElement.dataset.theme from localStorage. Dark stays the default.
const [theme, setTheme] = useState(() => document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'); const [theme, setTheme] = useState(() => document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');
@@ -15019,6 +15144,9 @@
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''} {user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
</div> </div>
<div className="mobile-only" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <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} /> <MobileCardCapture token={token} onShowToast={showToast} />
<MobileQuickLog token={token} onShowToast={showToast} /> <MobileQuickLog token={token} onShowToast={showToast} />
<ThemeToggle theme={theme} onToggle={toggleTheme} variant="icon" /> <ThemeToggle theme={theme} onToggle={toggleTheme} variant="icon" />
+3 -2
View File
@@ -66,8 +66,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
// * 0.1.0:98 (business-card intake [Matrix bot] captures a contact's phone, mobile/cell, city + LinkedIn from a scanned card onto the contact record — cell to Mobile, office to Phone, fax skipped. Server half: _upsert_contact_from_fundraising now accepts phone+mobile on the contact dict [city+linkedin already worked]. The bot's transcription/extraction/card changes ship on the Spark [git pull + rebuild]. No schema change [contacts columns already exist]; no user-facing CRM change) // * 0.1.0:98 (business-card intake [Matrix bot] captures a contact's phone, mobile/cell, city + LinkedIn from a scanned card onto the contact record — cell to Mobile, office to Phone, fax skipped. Server half: _upsert_contact_from_fundraising now accepts phone+mobile on the contact dict [city+linkedin already worked]. The bot's transcription/extraction/card changes ship on the Spark [git pull + rebuild]. No schema change [contacts columns already exist]; no user-facing CRM change)
// * 0.1.0:99 (Grant device-test round 2, CRM half: intake fuzzy match scores DISTINCTIVE tokens only [no more "Investment Group"/"Capital"/"Family Office" false look-alikes]; mobile grid "Last contact"/staleness sort is reversible; mobile Edit-investor prefills a contact's email [GET /api/fundraising/state heals a blank grid pill from the linked classic contact, fill-only]; mobile quick-log pencil icon renders [CSS sizing on the sole flex-child svg]. The Matrix intake thread-redaction change ships on the Spark, not here. No schema change; no migration) // * 0.1.0:99 (Grant device-test round 2, CRM half: intake fuzzy match scores DISTINCTIVE tokens only [no more "Investment Group"/"Capital"/"Family Office" false look-alikes]; mobile grid "Last contact"/staleness sort is reversible; mobile Edit-investor prefills a contact's email [GET /api/fundraising/state heals a blank grid pill from the linked classic contact, fill-only]; mobile quick-log pencil icon renders [CSS sizing on the sole flex-child svg]. The Matrix intake thread-redaction change ships on the Spark, not here. No schema change; no migration)
// * 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency) // * 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency)
// * Current: 0.1.0:101 (Mobile UX batch 1 [Grant device feedback]: [1] inline ✕ clear button on the Grid/Contacts search + reminder/quick-log investor pickers [ClearableInput]; [2] Grid investor-detail contact pills are tappable — name deep-links to the Contacts detail [new Grid→Contacts one-shot action], email opens mailto; [4a] mobile Pipeline is a full-height flex column so the whole area above the now bottom-pinned dots is the swipe target, each stage page scrolling its cards; [4b] expected-amount entry — optional amount when adding to the pipeline from the Grid detail [feeds pipeline/link], editable amount on the Pipeline card detail [PUT /api/opportunities/{id}]; [5] bottom sheets lift above the on-screen keyboard [visualViewport] so the reminder investor-picker results stay visible. Grid contact-name search [#3] already worked. CSS+React only; no schema change; no migration; no new dependency) // * 0.1.0:101 (Mobile UX batch 1 [Grant device feedback]: [1] inline ✕ clear button on the Grid/Contacts search + reminder/quick-log investor pickers [ClearableInput]; [2] Grid investor-detail contact pills are tappable — name deep-links to the Contacts detail [new Grid→Contacts one-shot action], email opens mailto; [4a] mobile Pipeline is a full-height flex column so the whole area above the now bottom-pinned dots is the swipe target, each stage page scrolling its cards; [4b] expected-amount entry — optional amount when adding to the pipeline from the Grid detail [feeds pipeline/link], editable amount on the Pipeline card detail [PUT /api/opportunities/{id}]; [5] bottom sheets lift above the on-screen keyboard [visualViewport] so the reminder investor-picker results stay visible. Grid contact-name search [#3] already worked. CSS+React only; no schema change; no migration; no new dependency)
export const PACKAGE_VERSION = '0.1.0:101' // * Current: 0.1.0:102 (Mobile email-approval bell [#6]: an admin-only bell in the mobile top bar [left of the camera] with an iPhone-style count badge surfaces the SAME pending email-capture proposals the web "Email Capture" panel + the Matrix review room decide. Tap → card list of proposals → tap one → review screen [investor name + subject + summary + editable proposed note] → Approve & log to grid / Reject. Reuses the existing GET /api/activity/proposals + POST .../{id}/approve|dismiss [require_admin]; bidirectional sync is automatic — an app decision flips the proposal status and the bot's poll redacts the Matrix thread, while a Matrix/web decision drops the proposal from the pending list the bell polls [45s], clearing the badge. No LLM round-trip [edit-then-approve like the web panel]; mobile-gated so the hidden desktop top bar doesn't poll. Frontend-only; no schema change; no migration; no new dependency)
export const PACKAGE_VERSION = '0.1.0:102'
export const DATA_MOUNT_PATH = '/data' export const DATA_MOUNT_PATH = '/data'
export const WEB_PORT = 8080 export const WEB_PORT = 8080
+3 -2
View File
@@ -62,8 +62,9 @@ import { v_0_1_0_98 } from './v0.1.0.98'
import { v_0_1_0_99 } from './v0.1.0.99' import { v_0_1_0_99 } from './v0.1.0.99'
import { v_0_1_0_100 } from './v0.1.0.100' import { v_0_1_0_100 } from './v0.1.0.100'
import { v_0_1_0_101 } from './v0.1.0.101' import { v_0_1_0_101 } from './v0.1.0.101'
import { v_0_1_0_102 } from './v0.1.0.102'
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_0_1_0_101, current: v_0_1_0_102,
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99, v_0_1_0_100], other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99, v_0_1_0_100, v_0_1_0_101],
}) })
+27
View File
@@ -0,0 +1,27 @@
import { VersionInfo } from '@start9labs/start-sdk'
// Mobile email-approval bell (#6) — a third surface over the EXISTING email-capture proposal
// flow, alongside the web "Email Capture" panel and the Matrix review room. Admin-only,
// frontend-only; no backend, schema, migration, or dependency change.
// - A bell in the mobile top bar (left of the camera) shows an iPhone-style count badge of
// pending proposals; dim when there are none.
// - Tap → a card list of proposals → tap one → a review screen (investor name, direction/date,
// subject, one-line summary, and the editable proposed note) → Approve & log to grid / Reject.
// - Reuses GET /api/activity/proposals + POST /api/activity/proposals/{id}/approve|dismiss
// (both require_admin). No LLM round-trip — edit-then-approve, exactly like the web panel.
// - Sync is automatic and bidirectional: deciding here flips the proposal status and the bot's
// poll redacts the Matrix thread; a Matrix- or web-side decision drops the proposal from the
// pending list the bell polls (every 45s), clearing the badge. Mobile-gated so the hidden
// desktop top bar never polls the admin endpoint.
export const v_0_1_0_102 = VersionInfo.of({
version: '0.1.0:102',
releaseNotes: {
en_US: [
'Email approvals on your phone: a bell in the mobile top bar shows how many captured-email',
'notes are waiting to be logged. Tap to review each one (investor + the proposed note),',
'edit it if needed, then Approve to log it to the grid or Reject — staying in sync with the',
'web panel and the Matrix review room.',
].join(' '),
},
migrations: { up: async () => {}, down: async () => {} },
})