diff --git a/frontend/index.html b/frontend/index.html
index b7a4f07..14dcd56 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -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 (
+ <>
+ 0 ? ' has-alerts' : ''}`} onClick={openSheet}
+ aria-label={count ? `Email approvals (${count})` : 'Email approvals'} title="Email approvals">
+
+
+
+ {count > 0 && {count > 99 ? '99+' : count} }
+
+
+ {selected ? (
+ <>
+ {selected.investor_name || 'Unmatched investor'}
+ {dirLabel(selected.direction)}{selected.email_date ? ` · ${formatDateLong(selected.email_date)}` : ''}
+ {selected.email_subject && {selected.email_subject}
}
+ {selected.summary && {selected.summary}
}
+
+ Proposed note — edit before approving, or reject
+
+ decide('approve')}>{busy ? 'Saving…' : 'Approve & log to grid'}
+ decide('dismiss')}>Reject
+ setSelected(null)}>‹ Back to list
+ >
+ ) : proposals == null ? (
+ Loading…
+ ) : proposals.length === 0 ? (
+ No emails waiting to be logged.
+ ) : (
+ <>
+ Each is a proposed note from captured email. Approve to log it to the grid, or reject.
+ {proposals.map((p) => (
+ openReview(p)}>
+
+ {p.investor_name || 'Unmatched investor'}
+ {dirLabel(p.direction)}
+
+ {p.email_subject || '(no subject)'}
+ {p.summary || p.proposed_note || ''}
+
+ ))}
+ >
+ )}
+
+ >
+ );
+ };
+
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' : ''}
+ {/* 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' && }
diff --git a/start9/0.4/startos/utils.ts b/start9/0.4/startos/utils.ts
index 2f966ad..7b854ce 100644
--- a/start9/0.4/startos/utils.ts
+++ b/start9/0.4/startos/utils.ts
@@ -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: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 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)
-export const PACKAGE_VERSION = '0.1.0:101'
+// * 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)
+// * 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 WEB_PORT = 8080
diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts
index d6ab010..c147342 100644
--- a/start9/0.4/startos/versions/index.ts
+++ b/start9/0.4/startos/versions/index.ts
@@ -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_100 } from './v0.1.0.100'
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({
- current: v_0_1_0_101,
- 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],
+ 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, v_0_1_0_101],
})
diff --git a/start9/0.4/startos/versions/v0.1.0.102.ts b/start9/0.4/startos/versions/v0.1.0.102.ts
new file mode 100644
index 0000000..c8eecb0
--- /dev/null
+++ b/start9/0.4/startos/versions/v0.1.0.102.ts
@@ -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 () => {} },
+})