From 463f6245483b48949e301c1dec00bebe07b1b10c Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 20 Jun 2026 14:15:03 -0500 Subject: [PATCH] Add in-app camera business-card intake (#7) (v0.1.0:100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 → 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. --- backend/server.py | 89 +++++++ backend/test_intake_card.py | 275 ++++++++++++++++++++++ frontend/index.html | 223 ++++++++++++++++++ start9/0.4/startos/utils.ts | 5 +- start9/0.4/startos/versions/index.ts | 5 +- start9/0.4/startos/versions/v0.1.0.100.ts | 22 ++ 6 files changed, 615 insertions(+), 4 deletions(-) create mode 100644 backend/test_intake_card.py create mode 100644 start9/0.4/startos/versions/v0.1.0.100.ts diff --git a/backend/server.py b/backend/server.py index 6ff733b..2ac3cf8 100644 --- a/backend/server.py +++ b/backend/server.py @@ -2517,6 +2517,8 @@ class CRMHandler(BaseHTTPRequestHandler): return self.handle_create_reminder(user, body) if path == '/api/query/nl': return self.handle_nl_query(user, body) + if path == '/api/intake/card': + return self.handle_intake_card(user, body) if path == '/api/fundraising/collab/heartbeat': return self.handle_fundraising_collab_heartbeat(user, body) if path == '/api/admin/users': @@ -4097,6 +4099,93 @@ class CRMHandler(BaseHTTPRequestHandler): conn.close() return self.send_json({"data": {"match": match, "candidates": candidates}}) + def handle_intake_card(self, user, body): + """In-app business-card intake (#7): a mobile photo -> vision-transcribe (local VL via + Spark Control) -> the SAME text-parse + fuzzy match the Matrix card flow runs (M3), + minus Matrix, surfaced as a mobile sheet. READ-ONLY: nothing is written here. The + proposal it returns is edited + approved by a human, and that approve write reuses + POST /api/fundraising/log-communication tagged source='app_card'. Authenticated member+ + (a human approves every write — same draft->approve gate as the bot, not bot-/admin-only). + + Body: {image_b64 (raw base64; a data: URI is also tolerated), mime?}. On success returns + status 200 {ok:true, transcription, proposal, match, candidates}; soft-fails also return + 200 (or 502) with {ok:false, reason}: 'unreadable' (model saw no card / <5 chars), + 'vision_unavailable' (502 — Spark/model down, or the intake core failed to import on a + dev box without it). See docs/handoffs/in-app-card-intake-plan.md + the matrix-intake guide.""" + image_b64 = str(body.get('image_b64') or '').strip() + mime = (str(body.get('mime') or 'image/jpeg').strip() or 'image/jpeg')[:64] + # Tolerate a full data-URI (clients often send the whole `data:...;base64,XXXX`): + # llm.chat_vision builds the `data:` wrapper itself, so strip any prefix to raw base64. + if image_b64.startswith('data:'): + comma = image_b64.find(',') + if comma != -1: + image_b64 = image_b64[comma + 1:].strip() + if not image_b64: + return self.send_error_json("image_b64 is required") + # Size guard: the client downscales to ~2 MP before base64, so a legit card is well under + # this; reject larger up front so a huge upload can't tie up a worker thread. (base64 + # inflates ~4/3; 12 MB b64 ~= 9 MB image — generous for a downscaled card.) + if len(image_b64) > 12_000_000: + return self.send_error_json("Image too large; retake a smaller photo", 413) + try: + base64.b64decode(image_b64, validate=True) + except Exception: + return self.send_error_json("image_b64 is not valid base64") + + # Lazily import the nio-free intake core (parse + spark) reused from the Matrix bot, + # guarded like _summarize_email_gist's `import llm`: a dev box without the modules or with + # Spark unreachable still boots and this endpoint just 502s. Import ONLY parse + spark + # (both nio-free) — never the bot's crm_client/settings/bot. (matrix-intake guide: the + # bot imports by bare name; parse/spark don't collide with ingest's modules.) + try: + _intake_dir = os.path.join(BASE_DIR, "matrix_intake") + if _intake_dir not in sys.path: + sys.path.insert(0, _intake_dir) + import parse as _intake_parse # noqa: E402 + import spark as _intake_spark # noqa: E402 + except Exception: + return self.send_json({"data": {"ok": False, "reason": "vision_unavailable"}}, 502) + + # The one added step vs the text path: vision-transcribe. The model only TRANSCRIBES; the + # text parser then extracts — so the email/phone/LinkedIn integrity rule (a value is kept + # only if it literally appears in the transcription, never minted) protects card intake too. + try: + transcription = _intake_spark.transcribe_card(image_b64, mime) + except Exception: + return self.send_json({"data": {"ok": False, "reason": "vision_unavailable"}}, 502) + if len((transcription or "").strip()) < 5: + return self.send_json({"data": {"ok": False, "reason": "unreadable"}}) + + # Frame as a new-investor note so the extractor reads it that way (same framing as M3). + framed = "New investor — from a business card:\n" + transcription + try: + proposal = _intake_parse.parse_message(framed, roster=None) + except Exception: + return self.send_json({"data": {"ok": False, "reason": "vision_unavailable"}}, 502) + + # Same new-vs-existing resolution the bot uses (crm_client.match): q = firm-or-person. + q = str(proposal.get("investor_name") or proposal.get("contact_name") or "").strip() + email = str(proposal.get("contact_email") or "").strip() + match = None + candidates = [] + if q or email: + conn = get_db() + try: + match = find_intake_match(conn, q, email) + candidates = find_intake_candidates(conn, q, email) if match is None else [] + finally: + conn.close() + + # Drop internal control keys (e.g. _source_text) before handing the proposal to the client. + proposal_out = {k: v for k, v in proposal.items() if not k.startswith("_")} + return self.send_json({"data": { + "ok": True, + "transcription": transcription, + "proposal": proposal_out, + "match": match, + "candidates": candidates, + }}) + def handle_update_communication(self, user, comm_id, body): conn = get_db() existing = conn.execute("SELECT id FROM communications WHERE id = ?", (comm_id,)).fetchone() diff --git a/backend/test_intake_card.py b/backend/test_intake_card.py new file mode 100644 index 0000000..5b05cf6 --- /dev/null +++ b/backend/test_intake_card.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Tests for the in-app business-card intake endpoint (#7): POST /api/intake/card. + +The endpoint reuses the Matrix card flow's nio-free core — vision-transcribe (spark) -> text +parse (parse) -> the same fuzzy matcher (find_intake_match / find_intake_candidates) — minus +Matrix, surfaced for a mobile sheet. The real vision/OCR path is live-smoke only (same as the +Matrix M3 path), so here we STUB the two network legs and assert the wiring + contract: + - happy path: transcribe -> parse -> proposal + match/candidates, status 200 ok:true; + - the email-integrity rule rides along (a model-minted address NOT in the transcription is + dropped in favor of the one literally present), exactly as on the text/Matrix path; + - new-vs-existing: an exact firm name returns `match`; a near-spelling returns `candidates`; + - soft-fails: an unreadable image -> ok:false/unreadable; vision down -> 502/vision_unavailable; + - guards: missing/invalid image -> 400; unauthenticated -> 401; + - provenance: the approve write reuses log-communication tagged source="app_card". +Synthetic data only. + +Run: cd backend && python3 test_intake_card.py +""" +import base64 +import http.client +import json +import os +import sqlite3 +import sys +import tempfile +import threading +from http.server import ThreadingHTTPServer + +_DATA = tempfile.mkdtemp() +os.environ["CRM_DATA_DIR"] = _DATA +os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db") + +_BACKEND = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, _BACKEND) +sys.path.insert(0, os.path.join(_BACKEND, "ingest")) # llm +sys.path.insert(0, os.path.join(_BACKEND, "matrix_intake")) # spark, parse + +import server # noqa: E402 +import llm # noqa: E402 (ingest/llm.py — patched so spark.parse_json hits no network) +import spark # noqa: E402 (matrix_intake/spark.py — transcribe_card stubbed) +import parse # noqa: E402 (matrix_intake/parse.py — parse_message defaults to spark.parse_json) + +FAILS = [] + +# The handler imports `spark`/`parse` lazily and looks up transcribe_card on the module at call +# time, so patching the module attribute here takes effect. parse.parse_message binds its default +# parse_fn=spark.parse_json at import, and spark.parse_json calls llm.chat_json dynamically — so +# patching llm.chat_json (not spark.parse_json) is what reaches the parse leg. +_STATE = {"transcription": "", "raw": {}, "boom": False} + + +def _fake_transcribe(image_b64, mime="image/jpeg", chat_fn=None): + if _STATE["boom"]: + raise RuntimeError("spark control unreachable") + return _STATE["transcription"] + + +def _fake_chat_json(prompt, system=None, max_tokens=200): + return dict(_STATE["raw"]) + + +spark.transcribe_card = _fake_transcribe +llm.chat_json = _fake_chat_json + + +def check(cond, msg): + print((" PASS " if cond else " FAIL ") + msg) + if not cond: + FAILS.append(msg) + + +class _Quiet(server.CRMHandler): + def log_message(self, *a): + pass + + +def _req(port, method, path, token=None, body=None): + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + headers = {} + if token: + headers["Authorization"] = "Bearer " + token + payload = None + if body is not None: + payload = json.dumps(body) + headers["Content-Type"] = "application/json" + conn.request(method, path, body=payload, headers=headers) + resp = conn.getresponse() + raw = resp.read().decode("utf-8", "replace") + conn.close() + data = None + if raw: + try: + data = json.loads(raw) + except ValueError: + pass + return resp.status, data + + +GRID = { + "columns": [], + "rows": [ + {"id": "rowAcme", "investor_name": "Acme Capital", "notes": "", + "contacts": [{"name": "Jane Doe", "email": "jane@acme.com", "title": "GP"}]}, + ], +} + +_IMG = base64.b64encode(b"not-a-real-image-just-valid-base64").decode() + + +def seed(): + c = sqlite3.connect(os.environ["CRM_DB_PATH"]) + c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) " + "VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)") + c.execute("INSERT INTO fundraising_state (id, grid_json, views_json, version) " + "VALUES ('main', ?, '[]', 1) " + "ON CONFLICT(id) DO UPDATE SET grid_json = excluded.grid_json", (json.dumps(GRID),)) + c.commit() + c.close() + + +def main(): + server.init_db() + seed() + token = server.create_token("u1", "grant", "admin") + + httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet) + port = httpd.server_address[1] + threading.Thread(target=httpd.serve_forever, daemon=True).start() + try: + print("\n[happy path: transcribe -> parse -> proposal, new investor, no match]") + _STATE["transcription"] = ("Sam Lee\nPartner\nBeacon Ventures\n" + "sam@beacon.vc\nMobile: +1 555 987 6543") + _STATE["raw"] = {"intent": "new_investor", "investor_name": "Beacon Ventures", + "contact_name": "Sam Lee", "contact_title": "Partner", + "mobile": "+1 555 987 6543", "contact_email": "sam@beacon.vc"} + _STATE["boom"] = False + st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG}) + data = (d or {}).get("data", {}) + p = data.get("proposal", {}) + check(st == 200 and data.get("ok") is True, f"200 ok:true (got {st}, {data})") + check(p.get("investor_name") == "Beacon Ventures" and p.get("contact_name") == "Sam Lee", + f"proposal carries firm + person (got {p})") + check(p.get("contact_email") == "sam@beacon.vc", f"email kept (got {p.get('contact_email')})") + check(p.get("mobile") == "+1 555 987 6543", f"mobile kept (got {p.get('mobile')})") + check("transcription" in data and data["match"] is None and data["candidates"] == [], + f"transcription returned, unknown firm -> no match/candidates (got {data})") + check(not any(k.startswith("_") for k in p), f"internal control keys stripped (got {list(p)})") + + print("\n[email integrity: a model-minted address NOT in the card is dropped]") + _STATE["transcription"] = "Ann Roe\nDir\nOmega LP\nann@omega.fund" # the only address present + _STATE["raw"] = {"intent": "new_investor", "investor_name": "Omega LP", + "contact_name": "Ann Roe", "contact_email": "evil@phish.example"} + st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG}) + p = (d or {}).get("data", {}).get("proposal", {}) + check(p.get("contact_email") == "ann@omega.fund", + f"source address wins over the minted one (got {p.get('contact_email')})") + + print("\n[match: exact firm name returns the grid row id]") + _STATE["transcription"] = "Jane Doe\nGP\nAcme Capital" # no email -> match on name + _STATE["raw"] = {"intent": "new_investor", "investor_name": "Acme Capital", + "contact_name": "Jane Doe", "contact_title": "GP"} + st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG}) + m = (d or {}).get("data", {}).get("match") + check(m and m.get("id") == "rowAcme", f"exact firm -> match rowAcme (got {m})") + + print("\n[match by card email: exact contact email returns the grid row id]") + _STATE["transcription"] = "Jane Doe\nGP\nAcme Capital Group\njane@acme.com" + _STATE["raw"] = {"intent": "new_investor", "investor_name": "Acme Capital Group", + "contact_name": "Jane Doe"} + st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG}) + m = (d or {}).get("data", {}).get("match") + check(m and m.get("id") == "rowAcme" and m.get("matched_on") == "email", + f"card email -> exact match rowAcme on email (got {m})") + + print("\n[fuzzy: a near-spelling returns a candidate, no exact match]") + # Typo in the DISTINCTIVE token ('Acme'->'Acne') so the fuzzy matcher surfaces it; a typo + # in a generic descriptor (e.g. 'Capitol') wouldn't, since those are stripped first. + _STATE["transcription"] = "Jane Doe\nGP\nAcne Capital" # no email -> name-only fuzzy + _STATE["raw"] = {"intent": "new_investor", "investor_name": "Acne Capital", + "contact_name": "Jane Doe"} + st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG}) + data = (d or {}).get("data", {}) + cids = [c["id"] for c in data.get("candidates", [])] + check(data.get("match") is None and "rowAcme" in cids, + f"near-spelling -> candidate rowAcme, no exact (got {data})") + + print("\n[no firm and no person: readable but unactionable -> ok:true, no DB lookup, no 500]") + _STATE["transcription"] = "some faded scribbles, no usable fields" # >=5 chars, no email/firm + _STATE["raw"] = {"intent": "unclear"} + st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG}) + data = (d or {}).get("data", {}) + check(st == 200 and data.get("ok") is True + and data.get("match") is None and data.get("candidates") == [], + f"unclear proposal -> ok:true, no match/candidates, not 500 (got {st}, {data})") + + print("\n[parse leg down: parse_message raises -> 502/vision_unavailable]") + _orig_pm = parse.parse_message + parse.parse_message = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("qwen down")) + try: + _STATE["transcription"] = "Jane Doe\nGP\nAcme Capital" + st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG}) + data = (d or {}).get("data", {}) + check(st == 502 and data.get("reason") == "vision_unavailable", + f"parse error -> 502 vision_unavailable (got {st}, {data})") + finally: + parse.parse_message = _orig_pm + + print("\n[unreadable: model saw no card -> ok:false/unreadable, 200]") + _STATE["transcription"] = "" # transcribe_card returns '' on the NONE sentinel + st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG}) + data = (d or {}).get("data", {}) + check(st == 200 and data.get("ok") is False and data.get("reason") == "unreadable", + f"empty transcription -> unreadable (got {st}, {data})") + + print("\n[vision down: transcribe raises -> 502/vision_unavailable]") + _STATE["boom"] = True + st, d = _req(port, "POST", "/api/intake/card", token, {"image_b64": _IMG}) + data = (d or {}).get("data", {}) + check(st == 502 and data.get("reason") == "vision_unavailable", + f"spark error -> 502 vision_unavailable (got {st}, {data})") + _STATE["boom"] = False + + print("\n[data-URI tolerated: a full data: prefix is stripped to raw base64]") + _STATE["transcription"] = "Sam Lee\nPartner\nBeacon Ventures" + _STATE["raw"] = {"intent": "new_investor", "investor_name": "Beacon Ventures", + "contact_name": "Sam Lee"} + st, d = _req(port, "POST", "/api/intake/card", token, + {"image_b64": "data:image/jpeg;base64," + _IMG}) + check(st == 200 and (d or {}).get("data", {}).get("ok") is True, + f"data-URI accepted (got {st})") + + print("\n[guard: missing image -> 400]") + st, _ = _req(port, "POST", "/api/intake/card", token, {}) + check(st == 400, f"no image_b64 -> 400 (got {st})") + + print("\n[guard: malformed base64 -> 400]") + st, _ = _req(port, "POST", "/api/intake/card", token, {"image_b64": "%%%not base64%%%"}) + check(st == 400, f"invalid base64 -> 400 (got {st})") + + print("\n[guard: oversized image -> 413 (size check runs before decode)]") + st, _ = _req(port, "POST", "/api/intake/card", token, {"image_b64": "A" * 12_000_001}) + check(st == 413, f"over the 12 MB b64 cap -> 413 (got {st})") + + print("\n[guard: unauthenticated -> 401]") + st, _ = _req(port, "POST", "/api/intake/card", None, {"image_b64": _IMG}) + check(st == 401, f"no token -> 401 (got {st})") + + print("\n[provenance: the approve write reuses log-communication tagged source=app_card]") + st, d = _req(port, "POST", "/api/fundraising/log-communication", token, { + "investor_name": "Beacon Ventures", + "contact": {"name": "Sam Lee", "email": "sam@beacon.vc", "title": "Partner"}, + "create_investor_if_missing": True, + "type": "note", "subject": "", "body": "scanned business card", + "source": "app_card", + }) + check(st in (200, 201), f"app_card create -> 201 (got {st})") + c = sqlite3.connect(os.environ["CRM_DB_PATH"]) + rows = c.execute("SELECT changes FROM audit_log WHERE entity_type='communication' AND action='create'").fetchall() + c.close() + sources = [json.loads(r[0]).get("source") for r in rows if r[0]] + check("app_card" in sources, f"audit carries source=app_card (got {sources})") + finally: + httpd.shutdown() + + print() + if FAILS: + print(f"FAILED ({len(FAILS)}):") + for f in FAILS: + print(f" - {f}") + sys.exit(1) + print("ALL PASS (in-app card intake endpoint)") + + +if __name__ == "__main__": + main() diff --git a/frontend/index.html b/frontend/index.html index 244e8be..01f88d4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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 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 ( + <> + + {/* Omit `capture` so iOS offers Take Photo / Photo Library / Browse (take a photo OR pick one). */} + + + {stage === 'reading' && ( +
+ +
📇 Reading the card…
+
+ )} + {stage === 'error' && ( + <> +
{errorMsg}
+ + + )} + {stage === 'review' && form && ( + <> + {investorOptions.length > 0 && ( +
+ +
+ {investorOptions.map((o) => ( + + ))} + +
+
+ )} +
+ + setField('investorName', e.target.value)} placeholder="e.g. Acme Capital" disabled={!!attachId} /> +
+
+ + setField('contactName', e.target.value)} placeholder="Full name" /> +
+
+ + setField('contactEmail', e.target.value)} placeholder="name@firm.com" /> +
+
+ + setField('contactTitle', e.target.value)} placeholder="e.g. Managing Partner" /> +
+
+ + setField('city', e.target.value)} placeholder="e.g. New York" /> +
+
+
+ + setField('phone', e.target.value)} placeholder="Office" /> +
+
+ + setField('mobile', e.target.value)} placeholder="Cell" /> +
+
+
+ + setField('linkedinUrl', e.target.value)} placeholder="linkedin.com/in/…" /> +
+
+ +