Add in-app camera business-card intake (#7) (v0.1.0:100)

A mobile, in-app twin of the Matrix business-card flow (M3): photograph a
card in the app and it becomes a reviewed fundraising-grid add/note, with a
human approving every write.

Server — POST /api/intake/card (authenticated member+, read-only): lazily
imports the bot's nio-free parse + spark core, vision-transcribes the photo
(local VL via Spark Control — nothing to Claude), runs the same email/phone/
LinkedIn integrity rule + fuzzy matcher, and returns a proposal plus exact
match / fuzzy candidates. No write happens here.

Frontend — a camera button in the mobile top bar (left of the quick-log
pencil) → take or pick a photo → <canvas> downscale to JPEG (also normalizes
iPhone HEIC) → the endpoint → an editable review sheet (proposal fields +
existing-investor picker). Save reuses /api/fundraising/log-communication
tagged source="app_card".

No schema change, no migration, no new dependency, no Matrix-bot change. The
camera/canvas/OCR path is on-device-only (jsdom has no canvas); covered by
test_intake_card.py (stubbed vision+parse) + the render/mount smokes.
This commit is contained in:
Keysat
2026-06-20 14:15:03 -05:00
parent 2a4c2c25a0
commit 463f624548
6 changed files with 615 additions and 4 deletions
+223
View File
@@ -2596,6 +2596,23 @@
.quicklog-change { flex: none; background: none; border: none; color: var(--accent-light); font-size: 13px; cursor: pointer; font-family: inherit; }
.quicklog-warn { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin-bottom: 14px; }
/* In-app business-card capture (#7) — top-bar camera button + transcribe/review sheet.
Reuses .quicklog-btn (incl. the iOS svg sizing fix) + the shared .sheet-* form fields. */
.card-reading { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 30px 4px 24px; }
.card-reading-text { font-size: 14px; color: var(--text-secondary); }
.card-error { font-size: 14px; color: var(--text-secondary); line-height: 1.55; padding: 6px 2px 16px; }
.card-inv-pick { display: flex; flex-direction: column; gap: 6px; margin: -4px 0 14px; }
.card-inv-opt {
width: 100%; text-align: left; cursor: pointer; font-family: inherit;
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 10px;
color: var(--text-primary); font-size: 14px;
}
.card-inv-opt.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-light); box-shadow: 0 0 0 1px var(--accent); }
.card-inv-opt-sub { flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent); }
.card-field-grid { display: flex; gap: 10px; }
.card-field-grid > .sheet-field { flex: 1; min-width: 0; }
/* Full-screen detail: read-only sections + edit-entry buttons. */
.fs-detail-chips { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.fs-action-row { display: flex; gap: 8px; flex-wrap: wrap; }
@@ -14230,6 +14247,211 @@
);
};
// In-app business-card intake (#7): a top-bar camera button → take/pick a photo →
// vision-transcribe + parse on the box (POST /api/intake/card; local VL via Spark Control,
// nothing to Claude) → an editable review sheet the human approves. Nothing is written until
// Save; the approve write reuses log-communication tagged source='app_card'. Mirrors the
// Matrix card flow (M3) but in-app. See docs/handoffs/in-app-card-intake-plan.md.
const MAX_CARD_DIM = 2000; // downscale longest edge before upload — keeps the payload under the
// StartOS reverse-proxy body cap; the VL model downscales to ~2 MP anyway.
// File → off-DOM <canvas> scaled to MAX_CARD_DIM → JPEG base64 (no data: prefix). Native, no
// library. Re-encoding to JPEG also sidesteps iPhone HEIC-in-vLLM (Safari decodes HEIC to the canvas).
const cardImageToB64 = (file) => new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
const longest = Math.max(img.width, img.height) || 1;
const scale = Math.min(1, MAX_CARD_DIM / longest);
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
try {
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve((canvas.toDataURL('image/jpeg', 0.85).split(',')[1]) || '');
} catch (e) { reject(e); }
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('unreadable image')); };
img.src = url;
});
const MobileCardCapture = ({ token, onShowToast }) => {
const fileRef = useRef(null);
const [open, setOpen] = useState(false);
const [stage, setStage] = useState('reading'); // 'reading' | 'error' | 'review'
const [errorMsg, setErrorMsg] = useState('');
const [form, setForm] = useState(null); // editable proposal fields
const [match, setMatch] = useState(null); // exact existing investor, or null
const [candidates, setCandidates] = useState([]); // fuzzy near-matches when no exact match
const [attachId, setAttachId] = useState(null); // grid row id to attach to; null = add as new
const [busy, setBusy] = useState(false);
// Reset value first so re-picking the SAME file still fires onChange.
const pickFile = () => { const el = fileRef.current; if (el) { el.value = ''; el.click(); } };
const onFile = async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
setOpen(true); setStage('reading'); setErrorMsg(''); setForm(null);
setMatch(null); setCandidates([]); setAttachId(null); setBusy(false);
let image_b64;
try {
image_b64 = await cardImageToB64(file);
} catch (_) {
setStage('error'); setErrorMsg("Couldn't read that image — try another photo."); return;
}
try {
const resp = await api('/api/intake/card', { method: 'POST',
body: JSON.stringify({ image_b64, mime: 'image/jpeg' }) }, token);
const d = (resp && resp.data) || {};
if (!d.ok) { // 200 soft-fail: the model saw no readable card
setStage('error');
setErrorMsg('Could not read the card. Use a clearer, well-lit photo that fills the frame.');
return;
}
const p = d.proposal || {};
setForm({
investorName: p.investor_name || '', contactName: p.contact_name || '',
contactEmail: p.contact_email || '', contactTitle: p.contact_title || '',
city: p.city || '', phone: p.phone || '', mobile: p.mobile || '',
linkedinUrl: p.linkedin_url || '', note: p.note || '',
});
setMatch(d.match || null);
setCandidates(Array.isArray(d.candidates) ? d.candidates : []);
setAttachId(d.match ? d.match.id : null); // default: attach to an exact match
setStage('review');
} catch (err) {
setStage('error');
const reason = err && err.payload && err.payload.data && err.payload.data.reason;
setErrorMsg(reason === 'vision_unavailable'
? 'The card reader is unavailable right now — try again in a moment.'
: getErrorMessage(err, 'Failed to read the card'));
}
};
const close = () => setOpen(false);
const setField = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const investorOptions = match ? [match] : candidates;
const save = async () => {
if (!form) return;
const name = (form.investorName || '').trim();
const cName = (form.contactName || '').trim();
const cEmail = (form.contactEmail || '').trim();
if (!attachId && !name) { onShowToast('Investor name is required', 'error'); return; }
if (!cName && !cEmail) { onShowToast('Add a contact name or email', 'error'); return; }
setBusy(true);
const note = (form.note || '').trim();
// Mirror the bot's build_commit_payload: blank subject when there's a note (so the note
// shows in the grid line), a provenance label otherwise; contact carries the extra fields.
const body = {
contact: {
name: cName, email: cEmail, title: (form.contactTitle || '').trim(),
city: (form.city || '').trim(), linkedin_url: (form.linkedinUrl || '').trim(),
phone: (form.phone || '').trim(), mobile: (form.mobile || '').trim(),
},
type: 'note', body: note, subject: note ? '' : 'Business card',
append_note: true, source: 'app_card',
};
if (attachId) body.row_id = attachId;
else { body.investor_name = name; body.create_investor_if_missing = true; }
try {
await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify(body) }, token);
onShowToast(attachId ? 'Logged to existing investor' : `Added ${name}`, 'success');
close();
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to save'), 'error'); }
finally { setBusy(false); }
};
return (
<>
<button className="quicklog-btn" onClick={pickFile} aria-label="Scan business card" title="Scan business card">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
</button>
{/* Omit `capture` so iOS offers Take Photo / Photo Library / Browse (take a photo OR pick one). */}
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} aria-hidden="true" tabIndex={-1} onChange={onFile} />
<BottomSheet open={open} onClose={close} title="Scan business card">
{stage === 'reading' && (
<div className="card-reading">
<Spinner />
<div className="card-reading-text">📇 Reading the card…</div>
</div>
)}
{stage === 'error' && (
<>
<div className="card-error">{errorMsg}</div>
<button className="sheet-submit" onClick={pickFile}>Retake</button>
</>
)}
{stage === 'review' && form && (
<>
{investorOptions.length > 0 && (
<div className="sheet-field">
<label className="sheet-field-label">{match ? 'Existing investor found' : 'Possible existing matches'}</label>
<div className="card-inv-pick">
{investorOptions.map((o) => (
<button type="button" key={o.id} className={`card-inv-opt ${attachId === o.id ? 'active' : ''}`} onClick={() => setAttachId(o.id)}>
<span>{o.investor_name || o.name || 'Investor'}</span>
{attachId === o.id && <span className="card-inv-opt-sub">attach</span>}
</button>
))}
<button type="button" className={`card-inv-opt ${attachId == null ? 'active' : ''}`} onClick={() => setAttachId(null)}>
<span>Add as new investor</span>
</button>
</div>
</div>
)}
<div className="sheet-field">
<label className="sheet-field-label">Investor / firm</label>
<input className="sheet-input" value={form.investorName} onChange={(e) => setField('investorName', e.target.value)} placeholder="e.g. Acme Capital" disabled={!!attachId} />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Contact name</label>
<input className="sheet-input" value={form.contactName} onChange={(e) => setField('contactName', e.target.value)} placeholder="Full name" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Email</label>
<input className="sheet-input" type="email" value={form.contactEmail} onChange={(e) => setField('contactEmail', e.target.value)} placeholder="name@firm.com" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Title</label>
<input className="sheet-input" value={form.contactTitle} onChange={(e) => setField('contactTitle', e.target.value)} placeholder="e.g. Managing Partner" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">City</label>
<input className="sheet-input" value={form.city} onChange={(e) => setField('city', e.target.value)} placeholder="e.g. New York" />
</div>
<div className="card-field-grid">
<div className="sheet-field">
<label className="sheet-field-label">Phone</label>
<input className="sheet-input" value={form.phone} onChange={(e) => setField('phone', e.target.value)} placeholder="Office" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Mobile</label>
<input className="sheet-input" value={form.mobile} onChange={(e) => setField('mobile', e.target.value)} placeholder="Cell" />
</div>
</div>
<div className="sheet-field">
<label className="sheet-field-label">LinkedIn</label>
<input className="sheet-input" value={form.linkedinUrl} onChange={(e) => setField('linkedinUrl', e.target.value)} placeholder="linkedin.com/in/…" />
</div>
<div className="sheet-field">
<label className="sheet-field-label">Note (optional)</label>
<textarea className="sheet-textarea" value={form.note} onChange={(e) => setField('note', e.target.value)} placeholder="How you met, context…" />
</div>
<button className="sheet-submit" onClick={save} disabled={busy}>{busy ? 'Saving…' : (attachId ? 'Log to investor' : 'Add investor')}</button>
</>
)}
</BottomSheet>
</>
);
};
// Quick-log — the dc top-bar pencil (GridApp:53-55): log a communication against any investor
// without first opening its detail. Two steps: pick an investor (search + recent-first pool) →
// inline log form. Writes via the one-row /api/fundraising/log-communication path (same write
@@ -14648,6 +14870,7 @@
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
</div>
<div className="mobile-only" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<MobileCardCapture token={token} onShowToast={showToast} />
<MobileQuickLog token={token} onShowToast={showToast} />
<ThemeToggle theme={theme} onToggle={toggleTheme} variant="icon" />
<div style={{ position: 'relative' }}>