Mobile Phase 2: read-only Contacts surface + shared BottomSheet/useIsMobile

Builds the mobile-first Contacts surface (<768px): a read-only A-Z directory
(sticky last-name letter headers) + segmented All/Investors/Prospects tabs +
pinned search -> full-screen detail (info with tap-to-copy email, opportunities,
comm history) -> a sort bottom-sheet. Contacts stays read-only on mobile per
design/BRIEF.md §3b (create/edit live on the Grid).

Lands the shared mobile primitives, deferred from Phase 1 and designed against
this first consumer (no dead code): <BottomSheet> (built on the Phase-1
.bottom-sheet CSS; scrim/Escape/pointer drag-to-dismiss) and useIsMobile()
(768px matchMedia). ContactsPage becomes a rules-of-hooks-safe wrapper that
mounts MobileContactsPage or the renamed-but-untouched DesktopContactsPage, so
desktop is unchanged. New CSS is JS-gated to the mobile component; grew the
:root/mobile var set per DESIGN §9 instead of hand-picking hexes.

Verified: render-smoke green + a throwaway jsdom interaction harness mounting
the real app at 375px (list/grouping/sort-sheet/detail/back, 14/14). Deploy-
pending (folds into the next s9pk with P0/P1/view-reorder).
This commit is contained in:
Keysat
2026-06-19 13:57:05 -05:00
parent 4ed16ca828
commit 984b950f80
3 changed files with 547 additions and 11 deletions
+5 -4
View File
@@ -75,7 +75,7 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
- **Env:** secrets in `.env` (gitignored); names in `.env.example`. Verified names: `ANTHROPIC_API_KEY`, `SPARK_CONTROL_URL`, `SPARK_CONTROL_VERIFY_TLS`, `QDRANT_URL`, `X_API_KEY`, `CRM_DB_PATH`, `CRM_DEV_DB_PATH`. Also used: `CRM_SECRET_KEY` (beta/prod), `CRM_HOST`/`CRM_PORT`, `CRM_DATA_DIR`; digest mailer: `CRM_DIGEST_SENDER` (DWD impersonation sender) + `SMTP_HOST`/`SMTP_PORT`/`SMTP_SECURITY`/`SMTP_FROM`/`SMTP_USERNAME`/`SMTP_PASSWORD` (SMTP fallback); daily digest (Phase B): `CRM_DIGEST_ENABLED` + `CRM_DIGEST_SEND_HOUR` **only seed the first-boot default** — the live control is the DB policy (`app_settings.digest_policy`, set in Settings → Admin).
- **Config placement:** operational/feature toggles live in the **admin panel**, DB-backed via `app_settings` (read-merge through a `load_*_policy(conn)` helper shared by the API + any scheduler; precedence DB-row → env-seed → default), so they're discoverable and take effect live. Reserve StartOS actions / env for **secrets and deploy-time config** (SMTP creds, API keys, DWD sender). Precedent: `digest_policy` (`GET/PATCH /api/admin/digest/policy`), `fundraising_backup_policy`.
- **Agent/bot API access — three roles now (`admin`/`member`/`bot`).** `require_admin` is the only hard gate; everything else is "authenticated" (member, admin, *and* bot all pass). The **`bot` role** (added v0.1.0:89) is authenticated-but-never-admin: `require_bot_or_admin` gates agent-facing endpoints (e.g. `/api/intake/email-proposals*`) so a bot credential reaches *only* what it needs, never user-management/settings/security. Provision it via Settings → Admin edit-user dropdown (kept out of the teammate-invite form). **Two axes to keep separate as more agent capability lands:** the role controls *reach* (which endpoints); the per-feature human draft→approve gate controls *autonomy* (acting unattended). Money/merge/delete mutations stay behind the approval gate regardless of role. Don't build a finer capability/scope system until real NL-mutation endpoints exist to scope against.
- **Design:** before building or changing any user-facing UI, read `design/DESIGN.md` and `design/tokens.tokens.json` and conform to them. The **mobile-first redesign landed** (Claude Design round-trip distilled into the contract 2026-06-19): the authority for mobile/responsive work is **`DESIGN.md` §8** + the tokens `mobile` and `color.light` groups; `design/BRIEF.md` is the input brief and `design/_imports/2026-06-19/` the provenance + per-surface interaction reference (the comps are Claude Design runtime prototypes — re-author each surface in the app's React idiom + real API, not drop-in). A **light theme** is adopted as a planned, toggle-gated feature (dark stays default). (Note: inline `style={{}}` objects can't respond to media queries; responsive layout belongs in the CSS `<style>` block. The **mobile foundation primitives are built**`.bottom-tab-bar`, the `.bottom-sheet` primitive, `.mobile-only`/`.desktop-only`, and `:root` mobile vars — build new mobile surfaces on those. The inline-style→CSS migration is **scoped, per-surface** (~114 styles across 4 surfaces+shell, not ~1,300), folded into each surface's build; see `ROADMAP.md`.)
- **Design:** before building or changing any user-facing UI, read `design/DESIGN.md` and `design/tokens.tokens.json` and conform to them. The **mobile-first redesign landed** (Claude Design round-trip distilled into the contract 2026-06-19): the authority for mobile/responsive work is **`DESIGN.md` §8** + the tokens `mobile` and `color.light` groups; `design/BRIEF.md` is the input brief and `design/_imports/2026-06-19/` the provenance + per-surface interaction reference (the comps are Claude Design runtime prototypes — re-author each surface in the app's React idiom + real API, not drop-in). A **light theme** is adopted as a planned, toggle-gated feature (dark stays default). (Note: inline `style={{}}` objects can't respond to media queries; responsive layout belongs in the CSS `<style>` block. The **mobile foundation primitives are built** CSS: `.bottom-tab-bar`, the `.bottom-sheet` primitive, `.mobile-only`/`.desktop-only`, `:root` mobile vars; React (Phase 2): **`<BottomSheet>`** (scrim/Escape/drag-to-dismiss) + **`useIsMobile()`** (768px) + the **`MobileDetailRow`**/`.fs-detail` full-screen-detail + `.contact-card`/`.az-header` list patterns — **build new mobile surfaces on these** (P3 Grid reuses them directly; swap surfaces via a rules-of-hooks-safe `useIsMobile()` wrapper that mounts a `Mobile*`/`Desktop*` pair, never a per-component hook toggle). The inline-style→CSS migration is **scoped, per-surface** (~114 styles across 4 surfaces+shell, not ~1,300), folded into each surface's build; see `ROADMAP.md`.)
- **Commit style:** imperative subject, concise body explaining the *why*; put the package version in the subject (`… (v0.1.0:NN)`) for shippable changes. **No AI co-author / attribution trailers** — commits are authored by the user.
## Always
@@ -107,13 +107,14 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
## Current state
_**Box live at v0.1.0:94**; `main` ahead by Phase 0 + Phase 1 (committed `634fc42`, **deploy-pending**). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign implementation** (design distilled into the contract; Phases 01 built, Contacts next). Full mobile plan + backlog/debt: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
_**Box live at v0.1.0:94**; `main` ahead by Phase 0 + Phase 1 (committed `634fc42`, **deploy-pending**) + **Phase 2 Contacts (built, uncommitted)**. **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign implementation** (design distilled into the contract; Phases 02 built, **P3 Grid next** — the crux). Full mobile plan + backlog/debt: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
- **Mobile redesign — design DONE, implementation underway.** Plan + scoping in `ROADMAP.md` "Mobile-first implementation": the inline-style→CSS migration is **~114 styles across 4 surfaces+shell** (not ~1,300), divisible per-surface; two axes (responsive layout→classes; theming inline-hex→`var()`). Sequence: P0 data layer → P1 foundation → **P2 Contacts** → P3 Grid → P4 Pipeline → P5 Reminders → P6 light theme.
- **Phase 0 (pipeline-stages/flags data layer) — BUILT, committed `e46dd36`, deploy-pending.** Enum→4 stages (`lead/engaged/diligence/commitment`) + migration `0007` (no-op on the live DB — 0 opps) + read-only `existing_investor`/`last_activity_at`/`staleness` injected into grid GET (stripped on write). Visible star/staleness column + Stale view deferred to P3.
- **Phase 1 (mobile foundation) — BUILT, committed `634fc42`, deploy-pending.** `:root` mobile vars + `.bottom-tab-bar` (4 tabs wired in `App`) + mobile account popover + `.bottom-sheet`/`.mobile-only`/`.desktop-only` CSS — all `display:none` desktop (zero desktop change). `<BottomSheet>` component + `useIsMobile()` + per-surface 15px bump deferred to P2.
- **Phase 2 (Contacts surface) — BUILT 2026-06-19, uncommitted, deploy-pending.** Read-only mobile AZ directory (sticky letter headers, last-name sort) + segmented All/Investors/Prospects tabs + pinned search → **full-screen detail** (`.fs-detail`: info w/ tap-to-copy email, opportunities, comm history) → **sort BottomSheet** (the sheet's first, read-only consumer). **Landed the shared primitives:** `<BottomSheet>` (scrim/Escape/pointer drag-to-dismiss; built on Phase-1 `.bottom-sheet` CSS) + `useIsMobile()` (768px `matchMedia`). `ContactsPage` is now a rules-of-hooks-safe wrapper → `Desktop`/`MobileContactsPage` (**desktop untouched**). Read-only per `BRIEF.md` §3b — no writes. Verified: render-smoke green + a throwaway jsdom interaction harness at 375px (14/14: list/grouping/sort-sheet/detail/back). **Browser/real-phone check still pending** (like P1).
- **Also deploy-pending:** drag-reorder grid views (frontend-only) — bundle into the next s9pk build.
- **Live:** W2 NL query (v94; remaining: in-room smoke + web "Ask" box); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach drafts — all draft-only.
- **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean.
- **Next:** 1) **Phase 2 Contacts** (read-only list→detail→sheet validator; lands `<BottomSheet>` + `useIsMobile()`); 2) **P3 Grid** (crux; writes via one-row `log-communication` + pipeline link→stage, never whole-grid PUT; render the existing-investor star + staleness); 3) **P4 Pipeline → P5 Reminders → P6 light theme**; 4) **deploy** P0+P1+view-reorder in one s9pk (**authorize first**); 5) W2 web Ask box + smoke; 6) W3 bot grid-mutations; 7) W1b nurture-gap; then P2 debt.
- **Open / risks:** P0+P1 **built but not deployed**; P1 mobile shell **browser-untested** (render-smoke only — verify the bottom bar on a real phone, like view-reorder); the 4 mobile surfaces unbuilt (Grid heaviest, ~70 styles + the two-call stage write). W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
- **Next:** 1) **P3 Grid** (the crux; reuses Phase 2's `<BottomSheet>`/`useIsMobile()`; writes via one-row `log-communication` + pipeline link→stage, never whole-grid PUT; render the existing-investor star + staleness); 2) **P4 Pipeline → P5 Reminders → P6 light theme**; 3) **deploy** P0+P1+P2+view-reorder in one s9pk (**authorize first**); 4) W2 web Ask box + smoke; 5) W3 bot grid-mutations; 6) W1b nurture-gap; then P2 debt.
- **Open / risks:** P0+P1+P2 **built but not deployed** (P2 also uncommitted); P1/P2 mobile surfaces **browser-untested** on a real phone (render-smoke + jsdom interaction smoke only — verify the bottom bar + Contacts list/detail on a device, like view-reorder); **3 of 4 mobile surfaces still unbuilt** (Grid heaviest, ~70 styles + the two-call stage write; then Pipeline, Reminders). W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
+17 -6
View File
@@ -335,12 +335,23 @@ migration into each surface's build, behind one shared foundation step. No upfro
has no base font-size, so it lands as each surface is re-authored (Phases 25); (c) the
`[data-theme="light"]` block → Phase 6 (dead without the toggle). Browser-interaction (the bar on a real
phone) untested, like view-reorder.
- **Phase 2 — Contacts (pattern-validator spike, BEFORE the Grid).** ~17 inline styles; read-only AZ
list + segmented tabs + search → full-screen read-only detail. Proves the list→detail→sheet pattern
and the per-surface migration mechanics on the lowest-risk surface before the crux. *(Reorders the
earlier "Grid first" draft — de-risk the pattern cheaply, then attack the Grid.)* Also lands the
**`<BottomSheet>` React component + `useIsMobile()` hook** (deferred from Phase 1, first consumed here,
built against the Phase 1 `.bottom-sheet` CSS) and this surface's **15px type bump**.
- **Phase 2 — Contacts (pattern-validator spike, BEFORE the Grid) — BUILT 2026-06-19 (deploy pending).**
Read-only AZ directory (sticky letter headers, sorted/sectioned by last name) + segmented
All/Investors/Prospects tabs + pinned search → **full-screen read-only detail** (`.fs-detail`, promotes
the slide-over: contact info w/ tap-to-copy email, opportunities, communication history) → **sort
BottomSheet** (the sheet primitive's first, read-only consumer: Name AZ / ZA / Recently-contacted —
restores the column-sort the card list loses). Proves the list→detail→sheet pattern + per-surface
migration mechanics on the lowest-risk surface before the crux. *(Reordered ahead of the earlier "Grid
first" draft.)* **Lands the shared primitives** (deferred from Phase 1, designed against this first
consumer — no dead code): **`<BottomSheet>`** (scrim/Escape/**pointer drag-to-dismiss**, mount enter/exit
animation, built on the Phase-1 `.bottom-sheet` CSS) + **`useIsMobile()`** (768px `matchMedia`; surfaces
swap via a rules-of-hooks-safe wrapper — `ContactsPage``Desktop`/`MobileContactsPage`, **zero desktop
change**). This surface's **15px body bump** lands on `.mobile-screen`. Writes: **none** — Contacts is
read-only on mobile per `BRIEF.md` §3b (create/edit live on the Grid). Grew the `:root`/mobile var set
(`--bg-input`, `--accent-light`, mobile card/control radii + card/screen/detail-title fonts) per DESIGN §9.
Verified: render-smoke green + a throwaway jsdom interaction harness (mounted the real app at 375px,
stubbed `/api/contacts` — list/grouping/sort-sheet/detail/back all asserted, 14/14). **No browser/real-phone
check yet** (same deferral as Phase 1 + view-reorder). **Deploy:** folds into the next s9pk build.
- **Phase 3 — Fundraising Grid (the crux).** ~70 inline styles → classes. Card list + bottom-sheet view
picker + search; full-screen detail with per-field bottom-sheet edits (name, contact pills, stage,
reminder, log note) + the `+`-create flow with client-side dedup typeahead. **Writes per `BRIEF.md`
+525 -1
View File
@@ -38,6 +38,8 @@
--accent-soft: #3b82c422;
--text-subtle: #70859b;
--border-strong: #35506a;
--bg-input: #0d1622; /* recessed input/table-header surface (tokens color.bg.input) */
--accent-light: #93c5fd; /* accent text on dark tinted fills (tokens color.accent.light) */
/* Mobile-first foundation (DESIGN §3/§8, tokens `mobile` group). Sizing/radii used
by the bottom tab bar + bottom-sheet primitive; per-surface type bumps land with
each surface (Phases 25), not as a global body rule (components set own px). */
@@ -45,9 +47,14 @@
--mobile-touch-target: 44px;
--mobile-input-h: 46px;
--mobile-sheet-radius: 20px;
--mobile-card-radius: 10px;
--mobile-control-radius: 8px;
--mobile-screen-pad-x: 16px;
--mobile-card-gap: 10px;
--mobile-font-body: 15px;
--mobile-font-card-title: 16px;
--mobile-font-screen-title: 21px;
--mobile-font-detail-title: 22px;
--mobile-font-sheet-title: 18px;
--mobile-font-tab-label: 10px;
}
@@ -2005,6 +2012,130 @@
}
.account-popover-logout:hover { background: var(--bg-hover); }
/* Larger grab region for the bottom-sheet (handle + title). touch-action:none so a
downward drag dismisses the sheet instead of scrolling the page behind it. */
.sheet-grab { touch-action: none; cursor: grab; }
.sheet-grab:active { cursor: grabbing; }
/* ─── Phase 2 — Contacts mobile surface (list → detail → sheet) ──────────────
Rendered only when useIsMobile() is true (the mobile component is JS-gated), so
these never apply on desktop; kept here with the mobile foundation. The 13→15px
body bump (DESIGN §3) lands on .mobile-screen, per-surface as planned. */
.mobile-screen { font-size: var(--mobile-font-body); }
.mobile-caption { font-size: 12px; color: var(--text-subtle); margin: -2px 0 12px; }
.mobile-toolbar { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.mobile-search {
width: 100%; height: var(--mobile-input-h);
background: var(--bg-input); color: var(--text-primary);
border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
font-size: var(--mobile-font-body); font-family: inherit; padding: 0 12px;
}
.mobile-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.mobile-seg { display: flex; gap: 6px; }
.mobile-seg-tab {
flex: 1; min-height: var(--mobile-touch-target);
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-control-radius);
color: var(--text-subtle); font-size: 13px; font-weight: 600;
font-family: inherit; cursor: pointer;
}
.mobile-seg-tab.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-light); }
.mobile-sortbar { display: flex; justify-content: space-between; align-items: center; }
.mobile-count { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); }
.mobile-sort-btn {
background: transparent; border: none; color: var(--text-muted);
font-size: 13px; font-family: inherit; cursor: pointer; padding: 6px 2px;
display: inline-flex; align-items: center; gap: 6px;
}
/* AZ directory: sticky letter headers over a card list. */
.az-header {
position: sticky; top: 0; z-index: 1;
background: var(--bg-base);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-subtle);
padding: 10px 2px 6px;
}
.contact-card {
display: flex; align-items: center; gap: 12px;
width: 100%; text-align: left; color: inherit;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-card-radius);
padding: 12px; margin-bottom: var(--mobile-card-gap); cursor: pointer;
box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
}
.contact-card:active { border-color: var(--border-strong); }
.mobile-avatar {
flex: none; width: 38px; height: 38px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
background: var(--accent-soft); color: var(--accent-light);
font-weight: 600; font-size: 15px;
}
.mobile-avatar.lg { width: 52px; height: 52px; font-size: 20px; }
.contact-card-main { flex: 1; min-width: 0; }
.contact-card-name {
display: block; font-size: var(--mobile-font-card-title); font-weight: 600;
color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.contact-card-sub {
display: block; font-size: 13px; color: var(--text-muted); margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.contact-card-meta { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.contact-card-date { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); }
/* Sort sheet rows (the BottomSheet's first consumer — read-only). */
.sheet-option {
display: flex; align-items: center; justify-content: space-between;
width: 100%; text-align: left; background: transparent;
border: none; border-bottom: 1px solid var(--border);
color: var(--text-secondary); font-size: var(--mobile-font-body);
font-family: inherit; padding: 15px 2px; cursor: pointer;
}
.sheet-option:last-child { border-bottom: none; }
.sheet-option.active { color: var(--accent-light); }
.sheet-option-check { color: var(--accent); }
/* Full-screen read-only detail — promotes the desktop slide-over (DESIGN §8). */
.fs-detail {
position: fixed; inset: 0; z-index: 210;
background: var(--bg-base);
display: flex; flex-direction: column;
animation: screenIn 0.2s ease;
}
@keyframes screenIn { from { opacity: 0; transform: translateX(14px); } to { opacity: 1; transform: translateX(0); } }
.fs-detail-header {
display: flex; align-items: center; flex: none;
padding: 12px; border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #121d2b 0%, #101926 100%);
}
.fs-detail-back {
background: transparent; border: none; color: var(--accent);
font-size: 15px; font-family: inherit; cursor: pointer;
padding: 6px 8px 6px 0; display: inline-flex; align-items: center; gap: 4px;
}
.fs-detail-body {
flex: 1; overflow-y: auto; font-size: var(--mobile-font-body);
padding: 16px var(--mobile-screen-pad-x) calc(env(safe-area-inset-bottom, 0px) + 28px);
}
.fs-detail-id { display: flex; align-items: center; gap: 14px; margin-bottom: 22px; }
.fs-detail-title { font-size: var(--mobile-font-detail-title); font-weight: 600; }
.fs-detail-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 3px; }
.fs-section { margin-bottom: 22px; }
.fs-section-label {
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-subtle);
margin-bottom: 10px;
}
.fs-row { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.fs-row:last-child { border-bottom: none; }
.fs-row-label { font-size: 13px; color: var(--text-muted); flex: none; }
.fs-row-value { font-size: var(--mobile-font-body); color: var(--text-secondary); text-align: right; word-break: break-word; }
.fs-row-value.mono { font-family: 'IBM Plex Mono', monospace; }
.fs-copy-hint { color: var(--accent); margin-left: 6px; font-size: 12px; }
/* Visibility utilities — base = desktop; flipped under the breakpoint. */
.mobile-only { display: none; }
@@ -3307,6 +3438,132 @@
</div>
);
/* ─── Shared mobile primitives (landed in Phase 2, reused by Phases 35) ────────── */
// True below the 768px breakpoint (matches the CSS .bottom-tab-bar / .mobile-only switch).
// Used to swap whole surfaces (mobile component vs desktop component), never to toggle
// hooks within one component — keep the rules-of-hooks-safe pattern (switch at the wrapper).
const MOBILE_MEDIA_QUERY = '(max-width: 768px)';
const useIsMobile = () => {
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && window.matchMedia
? window.matchMedia(MOBILE_MEDIA_QUERY).matches : false
);
useEffect(() => {
if (!window.matchMedia) return;
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
const onChange = (e) => setIsMobile(e.matches);
setIsMobile(mql.matches);
if (mql.addEventListener) mql.addEventListener('change', onChange);
else mql.addListener(onChange); // Safari < 14
return () => {
if (mql.removeEventListener) mql.removeEventListener('change', onChange);
else mql.removeListener(onChange);
};
}, []);
return isMobile;
};
// Drag-to-dismiss bottom sheet — replaces the centered modal + right slide-over on mobile
// (DESIGN §4/§8). Styling is the Phase-1 .bottom-sheet/.sheet-scrim/.sheet-handle CSS; this
// adds the mount/enter-exit animation, scrim/Escape dismiss, and pointer drag-down close.
const BottomSheet = ({ open, onClose, title, children }) => {
const [mounted, setMounted] = useState(open);
const [shown, setShown] = useState(false);
const [dragY, setDragY] = useState(0);
const dragRef = useRef(null);
useEffect(() => {
if (open) {
setMounted(true);
setDragY(0);
const id = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(id);
}
setShown(false);
const t = setTimeout(() => setMounted(false), 300); // match --motion.sheet exit
return () => clearTimeout(t);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!mounted) return null;
const onPointerDown = (e) => {
dragRef.current = { startY: e.clientY };
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {}
};
const onPointerMove = (e) => {
if (!dragRef.current) return;
const dy = e.clientY - dragRef.current.startY;
if (dy > 0) setDragY(dy);
};
const endDrag = () => {
if (!dragRef.current) return;
dragRef.current = null;
const shouldClose = dragY > 90;
setDragY(0); // reset so the class-based slide (snap-back or dismiss) plays
if (shouldClose) onClose();
};
const sheetStyle = dragY > 0 ? { transform: `translateY(${dragY}px)`, transition: 'none' } : undefined;
return (
<>
<div className={`sheet-scrim ${shown ? 'open' : ''}`} onClick={onClose} />
<div className={`bottom-sheet ${shown ? 'open' : ''}`} style={sheetStyle} role="dialog" aria-modal="true">
<div
className="sheet-grab"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="sheet-handle" />
{title && <div className="sheet-title">{title}</div>}
</div>
<div className="sheet-body">{children}</div>
</div>
</>
);
};
// Read-only labelled row for full-screen mobile detail; hides empty values. `copyable`
// makes the value tap-to-copy (used for email).
const MobileDetailRow = ({ label, value, mono, copyable, onShowToast }) => {
if (!value) return null;
const onCopy = copyable ? () => {
try {
if (navigator.clipboard) {
navigator.clipboard.writeText(value);
if (onShowToast) onShowToast('Copied', 'success');
}
} catch (_) {}
} : undefined;
return (
<div className="fs-row">
<span className="fs-row-label">{label}</span>
<span
className={`fs-row-value ${mono ? 'mono' : ''}`}
onClick={onCopy}
style={copyable ? { cursor: 'pointer' } : undefined}
>
{value}{copyable && <span className="fs-copy-hint">copy</span>}
</span>
</div>
);
};
const contactTypeBadgeClass = (type) => ({
investor: 'badge-investor', prospect: 'badge-prospect',
advisor: 'badge-advisor', other: 'badge-other'
}[type] || 'badge-other');
const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -3846,7 +4103,9 @@
);
};
const ContactsPage = ({ token, onShowToast }) => {
// Desktop Contacts surface (table + slide-over). Unchanged; rendered on >768px via the
// ContactsPage switch below. Mobile (<768px) renders MobileContactsPage instead.
const DesktopContactsPage = ({ token, onShowToast }) => {
const CONTACTS_PAGE_SIZE = 100;
const [contacts, setContacts] = useState([]);
const [contactsTotal, setContactsTotal] = useState(0);
@@ -4045,6 +4304,271 @@
);
};
// Mobile Contacts (<768px): read-only AZ directory full-screen detail sort sheet.
// Phase 2 of the mobile-first redesign — the lowest-risk surface, validating the
// list→detail→sheet pattern + the shared BottomSheet/useIsMobile before the Grid (Phase 3).
// Contacts is READ-ONLY on mobile (BRIEF §3b): create/edit live on the Grid, never here.
const SORT_OPTIONS = [
{ id: 'name-asc', label: 'Name (AZ)', short: 'AZ' },
{ id: 'name-desc', label: 'Name (ZA)', short: 'ZA' },
{ id: 'recent', label: 'Recently contacted', short: 'Recent' },
];
const MobileContactDetail = ({ contact, token, onClose, onShowToast }) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await api(`/api/contacts/${contact.id}`, {}, token);
if (!cancelled) setDetails(r.data);
} catch (err) {
if (!cancelled) onShowToast(getErrorMessage(err, 'Failed to load contact'), 'error');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [contact.id, token]);
const base = details || contact;
const name = `${base.first_name || ''} ${base.last_name || ''}`.trim() || base.email || 'Contact';
const initial = (base.last_name || base.first_name || name || '?').charAt(0).toUpperCase();
const org = base.organization || base.organization_name || '';
const type = base.contact_type || 'other';
const location = details
? ([details.city, details.state, details.country].filter(Boolean).join(', ') || details.location_query || '')
: '';
return (
<div className="fs-detail" role="dialog" aria-modal="true">
<div className="fs-detail-header">
<button className="fs-detail-back" onClick={onClose}> Contacts</button>
</div>
<div className="fs-detail-body">
<div className="fs-detail-id">
<span className="mobile-avatar lg">{initial}</span>
<span style={{ minWidth: 0, flex: 1 }}>
<div className="fs-detail-title">{name}</div>
<div className="fs-detail-subtitle">{org || '—'}</div>
</span>
<span className={`badge ${contactTypeBadgeClass(type)}`}>{type}</span>
</div>
{loading ? <SkeletonBlock lines={6} /> : (
<>
<div className="fs-section">
<div className="fs-section-label">Contact</div>
<MobileDetailRow label="Email" value={base.email} mono copyable onShowToast={onShowToast} />
<MobileDetailRow label="Phone" value={base.phone} mono />
<MobileDetailRow label="Title" value={base.title} />
<MobileDetailRow label="Organization" value={org} />
<MobileDetailRow label="Lead Source" value={base.source} />
<MobileDetailRow label="LinkedIn" value={base.linkedin_url} />
<MobileDetailRow label="Location" value={location} />
</div>
{details && details.opportunities && details.opportunities.length > 0 && (
<div className="fs-section">
<div className="fs-section-label">Opportunities</div>
{details.opportunities.map((o) => (
<div className="fs-row" key={o.id}>
<span className="fs-row-label">{o.name}</span>
<span className="fs-row-value mono">{o.stage} · {formatCurrencyLong(o.expected_amount)}</span>
</div>
))}
</div>
)}
<div className="fs-section">
<div className="fs-section-label">Communication History</div>
{details && details.communications && details.communications.length > 0 ? (
<div className="timeline">
{details.communications.map((cm) => (
<div key={cm.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header">{cm.type}</div>
<div className="timeline-meta">{formatDate(cm.communication_date)}</div>
{cm.subject && <div className="timeline-body">{cm.subject}</div>}
</div>
</div>
))}
</div>
) : (
<div style={{ color: 'var(--text-subtle)', fontSize: '13px' }}>No communications logged.</div>
)}
</div>
</>
)}
</div>
</div>
);
};
const MobileContactsPage = ({ token, onShowToast }) => {
const [contacts, setContacts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [tab, setTab] = useState('all');
const [search, setSearch] = useState('');
const [sort, setSort] = useState('name-asc');
const [sortOpen, setSortOpen] = useState(false);
const [selected, setSelected] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
// One fetch of the full directory (server cap 500); tab + search + sort are
// applied client-side so switching is instant and needs no refetch.
const r = await api('/api/contacts?sort=last_name&order=asc&limit=500', {}, token);
if (!cancelled) { setContacts(r.data || []); setError(''); }
} catch (err) {
if (!cancelled) setError(getErrorMessage(err, 'Failed to load contacts'));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [token]);
const displayName = (c) => `${c.first_name || ''} ${c.last_name || ''}`.trim() || c.email || 'Unknown';
// Directory convention (matches the desktop default + phone Contacts): order and section
// by last name, even though the card shows "First Last".
const sortBasis = (c) => (c.last_name || c.first_name || c.email || '').trim();
const lastContactTs = (c) => (c.last_contact_date ? new Date(c.last_contact_date).getTime() : 0);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const list = contacts.filter((c) => {
if (tab === 'investors' && c.contact_type !== 'investor') return false;
if (tab === 'prospects' && c.contact_type !== 'prospect') return false;
if (!q) return true;
const org = c.organization || c.organization_name || '';
return displayName(c).toLowerCase().includes(q)
|| (c.email || '').toLowerCase().includes(q)
|| org.toLowerCase().includes(q);
});
if (sort === 'recent') {
list.sort((a, b) => lastContactTs(b) - lastContactTs(a));
} else {
const dir = sort === 'name-desc' ? -1 : 1;
list.sort((a, b) => dir * sortBasis(a).localeCompare(sortBasis(b), undefined, { sensitivity: 'base' }));
}
return list;
}, [contacts, tab, search, sort]);
// AZ letter groups only in name-sort modes; "recently contacted" is a flat list.
const groups = useMemo(() => {
if (sort === 'recent') return null;
const m = new Map();
for (const c of filtered) {
let letter = (sortBasis(c).charAt(0) || '#').toUpperCase();
if (!/[A-Z]/.test(letter)) letter = '#';
if (!m.has(letter)) m.set(letter, []);
m.get(letter).push(c);
}
return Array.from(m.entries());
}, [filtered, sort]);
const renderCard = (c) => {
const org = c.organization || c.organization_name || '';
const type = c.contact_type || 'other';
const initial = (sortBasis(c).charAt(0) || displayName(c).charAt(0) || '?').toUpperCase();
return (
<button className="contact-card" key={c.id} onClick={() => setSelected(c)}>
<span className="mobile-avatar">{initial}</span>
<span className="contact-card-main">
<span className="contact-card-name">{displayName(c)}</span>
<span className="contact-card-sub">{org || '—'}</span>
</span>
<span className="contact-card-meta">
<span className={`badge ${contactTypeBadgeClass(type)}`}>{type}</span>
{c.last_contact_date && <span className="contact-card-date">{formatDate(c.last_contact_date)}</span>}
</span>
</button>
);
};
const sortShort = (SORT_OPTIONS.find((o) => o.id === sort) || SORT_OPTIONS[0]).short;
return (
<div className="mobile-screen">
<div className="mobile-caption">Read-only directory — people are added and edited from the Fundraising Grid.</div>
<div className="mobile-toolbar">
<input
className="mobile-search"
type="text"
placeholder="Search contacts…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="mobile-seg">
{['all', 'investors', 'prospects'].map((t) => (
<button key={t} className={`mobile-seg-tab ${tab === t ? 'active' : ''}`} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<div className="mobile-sortbar">
<span className="mobile-count">{filtered.length} {filtered.length === 1 ? 'contact' : 'contacts'}</span>
<button className="mobile-sort-btn" onClick={() => setSortOpen(true)}>⇅ {sortShort}</button>
</div>
</div>
{loading ? (
<SkeletonBlock lines={8} />
) : error ? (
<div className="empty-state">{error}</div>
) : filtered.length === 0 ? (
<div className="empty-state">No contacts found</div>
) : groups ? (
groups.map(([letter, items]) => (
<div key={letter}>
<div className="az-header">{letter}</div>
{items.map(renderCard)}
</div>
))
) : (
filtered.map(renderCard)
)}
<BottomSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort">
{SORT_OPTIONS.map((o) => (
<button
key={o.id}
className={`sheet-option ${sort === o.id ? 'active' : ''}`}
onClick={() => { setSort(o.id); setSortOpen(false); }}
>
<span>{o.label}</span>
{sort === o.id && <span className="sheet-option-check"></span>}
</button>
))}
</BottomSheet>
{selected && (
<MobileContactDetail
contact={selected}
token={token}
onClose={() => setSelected(null)}
onShowToast={onShowToast}
/>
)}
</div>
);
};
// Switch by viewport. Only useIsMobile() runs here, so the hook count is constant; the two
// surfaces mount/unmount on a breakpoint cross (rules-of-hooks-safe — each owns its hooks).
const ContactsPage = (props) => {
const isMobile = useIsMobile();
return isMobile ? <MobileContactsPage {...props} /> : <DesktopContactsPage {...props} />;
};
const ContactDetailPanel = ({ contact, onClose, onDelete, token, onShowToast, onRefresh }) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(true);