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:
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user