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:
@@ -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" />
|
||||
|
||||
@@ -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 <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)
|
||||
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
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 () => {} },
|
||||
})
|
||||
Reference in New Issue
Block a user