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:
@@ -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).
|
- **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`.
|
- **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.
|
- **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.
|
- **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
|
## Always
|
||||||
@@ -107,13 +107,14 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
|||||||
|
|
||||||
## Current state
|
## 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 0–1 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 0–2 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.
|
- **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 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 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 A–Z 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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 **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.
|
- **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
@@ -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 2–5); (c) the
|
has no base font-size, so it lands as each surface is re-authored (Phases 2–5); (c) the
|
||||||
`[data-theme="light"]` block → Phase 6 (dead without the toggle). Browser-interaction (the bar on a real
|
`[data-theme="light"]` block → Phase 6 (dead without the toggle). Browser-interaction (the bar on a real
|
||||||
phone) untested, like view-reorder.
|
phone) untested, like view-reorder.
|
||||||
- **Phase 2 — Contacts (pattern-validator spike, BEFORE the Grid).** ~17 inline styles; read-only A–Z
|
- **Phase 2 — Contacts (pattern-validator spike, BEFORE the Grid) — BUILT 2026-06-19 (deploy pending).**
|
||||||
list + segmented tabs + search → full-screen read-only detail. Proves the list→detail→sheet pattern
|
Read-only A–Z directory (sticky letter headers, sorted/sectioned by last name) + segmented
|
||||||
and the per-surface migration mechanics on the lowest-risk surface before the crux. *(Reorders the
|
All/Investors/Prospects tabs + pinned search → **full-screen read-only detail** (`.fs-detail`, promotes
|
||||||
earlier "Grid first" draft — de-risk the pattern cheaply, then attack the Grid.)* Also lands the
|
the slide-over: contact info w/ tap-to-copy email, opportunities, communication history) → **sort
|
||||||
**`<BottomSheet>` React component + `useIsMobile()` hook** (deferred from Phase 1, first consumed here,
|
BottomSheet** (the sheet primitive's first, read-only consumer: Name A–Z / Z–A / Recently-contacted —
|
||||||
built against the Phase 1 `.bottom-sheet` CSS) and this surface's **15px type bump**.
|
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
|
- **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,
|
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`
|
reminder, log note) + the `+`-create flow with client-side dedup typeahead. **Writes per `BRIEF.md`
|
||||||
|
|||||||
+525
-1
@@ -38,6 +38,8 @@
|
|||||||
--accent-soft: #3b82c422;
|
--accent-soft: #3b82c422;
|
||||||
--text-subtle: #70859b;
|
--text-subtle: #70859b;
|
||||||
--border-strong: #35506a;
|
--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
|
/* 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
|
by the bottom tab bar + bottom-sheet primitive; per-surface type bumps land with
|
||||||
each surface (Phases 2–5), not as a global body rule (components set own px). */
|
each surface (Phases 2–5), not as a global body rule (components set own px). */
|
||||||
@@ -45,9 +47,14 @@
|
|||||||
--mobile-touch-target: 44px;
|
--mobile-touch-target: 44px;
|
||||||
--mobile-input-h: 46px;
|
--mobile-input-h: 46px;
|
||||||
--mobile-sheet-radius: 20px;
|
--mobile-sheet-radius: 20px;
|
||||||
|
--mobile-card-radius: 10px;
|
||||||
|
--mobile-control-radius: 8px;
|
||||||
--mobile-screen-pad-x: 16px;
|
--mobile-screen-pad-x: 16px;
|
||||||
--mobile-card-gap: 10px;
|
--mobile-card-gap: 10px;
|
||||||
--mobile-font-body: 15px;
|
--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-sheet-title: 18px;
|
||||||
--mobile-font-tab-label: 10px;
|
--mobile-font-tab-label: 10px;
|
||||||
}
|
}
|
||||||
@@ -2005,6 +2012,130 @@
|
|||||||
}
|
}
|
||||||
.account-popover-logout:hover { background: var(--bg-hover); }
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A–Z 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. */
|
/* Visibility utilities — base = desktop; flipped under the breakpoint. */
|
||||||
.mobile-only { display: none; }
|
.mobile-only { display: none; }
|
||||||
|
|
||||||
@@ -3307,6 +3438,132 @@
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ─── Shared mobile primitives (landed in Phase 2, reused by Phases 3–5) ────────── */
|
||||||
|
|
||||||
|
// 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 LoginPage = () => {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = 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_PAGE_SIZE = 100;
|
||||||
const [contacts, setContacts] = useState([]);
|
const [contacts, setContacts] = useState([]);
|
||||||
const [contactsTotal, setContactsTotal] = useState(0);
|
const [contactsTotal, setContactsTotal] = useState(0);
|
||||||
@@ -4045,6 +4304,271 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mobile Contacts (<768px): read-only A–Z 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 (A–Z)', short: 'A–Z' },
|
||||||
|
{ id: 'name-desc', label: 'Name (Z–A)', short: 'Z–A' },
|
||||||
|
{ 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]);
|
||||||
|
|
||||||
|
// A–Z 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 ContactDetailPanel = ({ contact, onClose, onDelete, token, onShowToast, onRefresh }) => {
|
||||||
const [details, setDetails] = useState(null);
|
const [details, setDetails] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user