Mobile Phase 6: app-wide light theme + [data-theme] toggle
Ship the light palette behind a :root[data-theme="light"] switch; dark
stays the default and brand identity. A pre-paint boot script applies
localStorage.venture_crm_theme (no flash, no prefers-color-scheme), and an
app-wide toggle lives in the desktop sidebar footer + the mobile top bar,
both driven by one theme state in App.
Method keeps dark mode byte-identical: :root grew to 44 themed color slots
whose dark values equal the original literals, then 319 hex literals were
migrated to var() across the JSX inline region and the <style> block. The
StageChip is now className-based (.stage-chip--{stage}); PIPELINE_STAGE_CHIP
is removed. Every light tint (stage/recency/note/priority/reminder/money)
uses the designer's exact values from the full Claude Design export
(store.js + the four *App.dc.html DCLogic palettes), now committed as
provenance under design/_imports/2026-06-19_zip-file/ (zip + screenshots
gitignored).
Mobile surfaces + chrome are fully var-based, so mobile light is complete.
Desktop light has known rough edges (bespoke <style> shades, the legacy
off-palette .badge-* family, dark-tuned shadows) folded into a new Phase 7
design-conformance pass.
Verified: render-smoke green; a jsdom interaction harness on the authed
shell exercised the toggle (boot-dark -> light+persist+relabel -> dark);
dark-identity, theme-parity, and no-undefined-var checks all green. Not yet
checked on a real phone/browser.
This commit is contained in:
@@ -2,6 +2,12 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
|
|
||||||
|
# ── Design provenance: keep the text artifacts (*.dc.html, store.js, *.md, tokens.json)
|
||||||
|
# but not the heavy binaries (the raw .zip export + screenshot/thumbnail PNGs). ──
|
||||||
|
design/_imports/**/*.zip
|
||||||
|
design/_imports/**/*.png
|
||||||
|
design/_imports/**/*.thumbnail
|
||||||
|
|
||||||
# ── Python ──
|
# ── Python ──
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
@@ -107,11 +107,27 @@ 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` (pushed through `ee9db64`) ahead by mobile Phases 0–5 + drag-reorder views — **all deploy-pending** (no s9pk built yet). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign** — **all 4 mobile surfaces done (P0–P5)**, **P6 light theme next** (then P3b name/pill edit + deploy). Per-phase detail + backlog: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
|
_**Box live at v0.1.0:94**; `main` (pushed through `ee9db64`) ahead by mobile Phases 0–5 + drag-reorder views + **P6 light theme (uncommitted, this session)** — **all deploy-pending** (no s9pk built yet). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign** — **all 4 mobile surfaces + the light theme done (P0–P6)**; next is **P3b name/pill edit**, a **full design-conformance pass (new Phase 7)**, then **deploy**. Per-phase detail + backlog: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
|
||||||
|
|
||||||
- **Mobile redesign — all 4 core surfaces built + committed (Grid · Contacts · Pipeline · Reminders).** Each is a rules-of-hooks-safe `useIsMobile()` wrapper → `Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API on shared primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`/`MobileDetailRow`. Foundation: bottom-tab bar + `:root` mobile vars (P1); 4-stage enum + read-only derived grid signals (`existing_investor`/`last_activity_at`/`staleness`/`opportunity_id`) injected on GET, **stripped on write at both points** (P0/P3a `_computed_row_values` + `stripComputedRows`). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a). Per-phase detail in `ROADMAP.md`.
|
- **Mobile redesign — all 4 core surfaces built + committed (Grid · Contacts · Pipeline · Reminders).** Each is a rules-of-hooks-safe `useIsMobile()` wrapper → `Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API on shared primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`/`MobileDetailRow`. Foundation: bottom-tab bar + `:root` mobile vars (P1); 4-stage enum + read-only derived grid signals (`existing_investor`/`last_activity_at`/`staleness`/`opportunity_id`) injected on GET, **stripped on write at both points** (P0/P3a `_computed_row_values` + `stripComputedRows`). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a). Per-phase detail in `ROADMAP.md`.
|
||||||
- **This session (`ee9db64`, no backend change):** **P4 Pipeline** (`MobilePipeline` = CSS scroll-snap swipe-between-stages + segmented control + dots, per-card ‹/› move + tap→detail w/ stage sheet; shares `PATCH /api/opportunities/{id}/stage`; view+advance-only) + **P5 Reminders** (`MobileReminders` = urgency-grouped list over `/api/reminders` + Active/Done/All filter; `ReminderRow` pointer-drag **swipe-left=done / swipe-right=snooze +7d**; tap→create/edit sheet). Verified: render-smoke + jsdom-375px harness (12/12 each); reviewer-passed (notably **P5 `pointercancel` no longer fires a spurious mark-done**). Reusable swipe-test technique noted in memory.
|
- **This session — P6 light theme (uncommitted, frontend-only).** App-wide light theme behind
|
||||||
|
`:root[data-theme="light"]`; **dark stays default** (pre-paint boot script in `<head>` reads
|
||||||
|
`localStorage.venture_crm_theme`; no `prefers-color-scheme`). **App-wide toggle:** labeled control
|
||||||
|
in the desktop sidebar footer + sun/moon icon in the mobile top bar, both off one `theme` state in
|
||||||
|
`App`. Color pairs taken from the **full Claude Design export** (`design/_imports/2026-06-19_zip-file/`,
|
||||||
|
with the previously-missing `store.js` + `*App.dc.html` `DCLogic` palettes) — exact dark+light for
|
||||||
|
every stage/recency/note/priority/reminder/money tint. **Method = zero dark regression by
|
||||||
|
construction:** grew `:root` to 44 themed slots whose **dark values == the originals byte-for-byte**
|
||||||
|
(verified), then migrated **319 hex→`var()`** (script for structural; targeted edits for semantic/
|
||||||
|
chip helpers — `StageChip` now className-based, `PIPELINE_STAGE_CHIP` removed). Mobile surfaces +
|
||||||
|
chrome are fully var-based → **mobile light complete**. **Desktop light rough edges** (bespoke
|
||||||
|
`<style>` shades: login glow/scrollbar/table-hover/KPI green, the legacy off-palette `.badge-*`
|
||||||
|
family, dark-tuned shadows) deferred to the new **Phase 7 conformance pass** (per Grant: conform
|
||||||
|
*everything* — buttons/colors/functionality — to the Claude Design export). Verified: render-smoke
|
||||||
|
green; jsdom interaction harness on the **authed shell** (boot-dark → toggle→light+persist+relabel →
|
||||||
|
toggle→dark, 7/7); var parity + dark-identity + no-undefined-var checks green. **No real-phone check.**
|
||||||
|
- **Prior session (`ee9db64`, no backend change):** **P4 Pipeline** (`MobilePipeline` = CSS scroll-snap swipe-between-stages + segmented control + dots, per-card ‹/› move + tap→detail w/ stage sheet; shares `PATCH /api/opportunities/{id}/stage`; view+advance-only) + **P5 Reminders** (`MobileReminders` = urgency-grouped list over `/api/reminders` + Active/Done/All filter; `ReminderRow` pointer-drag **swipe-left=done / swipe-right=snooze +7d**; tap→create/edit sheet). Verified: render-smoke + jsdom-375px harness (12/12 each); reviewer-passed (notably **P5 `pointercancel` no longer fires a spurious mark-done**). Reusable swipe-test technique noted in memory.
|
||||||
- **Live (deployed):** 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 — all draft-only.
|
- **Live (deployed):** 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 — 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) **P6 light theme** (inline-hex→`var()` axis, 183 literals + ship `color.light` behind a `[data-theme]` toggle; dark stays default); 2) **P3b** name/pill edit (narrow per-row PATCH + pill editor); 3) **deploy** P0–P5 + view-reorder in one s9pk (**authorize first**) — verify on a real phone; 4) W2 web Ask box + smoke; 5) W3 bot grid-mutations; 6) W1b nurture-gap.
|
- **Next:** 1) **P3b** name/pill edit (narrow per-row PATCH + pill editor); 2) **Phase 7 design-conformance pass** against the full Claude Design export (`design/_imports/2026-06-19_zip-file/`) — buttons/colors/spacing/functionality across all surfaces, incl. finishing the P6 desktop-light rough edges (run `design-checker`); 3) **deploy** P0–P6 + view-reorder in one s9pk (**authorize first**) — verify light/dark on a real phone; 4) W2 web Ask box + smoke; 5) W3 bot grid-mutations; 6) W1b nurture-gap.
|
||||||
- **Open / risks:** all mobile work **built but never deployed or tested on a real phone** (render-smoke + jsdom-at-375px only — verify on a device); **P3b deferred**; 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:** all mobile work + **P6 light theme** **built but never deployed or tested on a real phone/browser** (render-smoke + jsdom only — verify on a device, both themes); **P6 desktop-light has known rough edges** (bespoke `<style>` shades, legacy `.badge-*` family, dark shadows → Phase 7); **P3b deferred**; 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.
|
||||||
|
|||||||
+38
-5
@@ -412,11 +412,44 @@ migration into each surface's build, behind one shared foundation step. No upfro
|
|||||||
green + a throwaway jsdom 375px harness (grouping/counts, swipe done + snooze PATCH, tap→edit prefilled,
|
green + a throwaway jsdom 375px harness (grouping/counts, swipe done + snooze PATCH, tap→edit prefilled,
|
||||||
create POST, Done-filter reload — 12/12). No real-phone check yet (same deferral as P1–P4). **Deploy:**
|
create POST, Done-filter reload — 12/12). No real-phone check yet (same deferral as P1–P4). **Deploy:**
|
||||||
folds into the next s9pk.
|
folds into the next s9pk.
|
||||||
- **Phase 6 — Light theme + toggle (adopted as a planned feature, 2026-06-19).** The inline-hex→`var()`
|
- **Phase 6 — Light theme + toggle — BUILT 2026-06-19 (deploy pending).** App-wide light theme
|
||||||
axis (183 literals) + ship the light palette (`tokens.tokens.json` `color.light`) behind a
|
behind a `:root[data-theme="light"]` switch; **dark stays the default** (a pre-paint boot script
|
||||||
`[data-theme]` switch + a top-bar toggle; dark stays the default. Mechanical; co-lands after the
|
in `<head>` reads `localStorage.venture_crm_theme`, no `prefers-color-scheme` auto-switch). Toggle
|
||||||
surfaces. Per-component light tints (stage/staleness/note badges) are in
|
is **app-wide**: a labeled control in the desktop sidebar footer + a sun/moon icon in the mobile
|
||||||
`_imports/2026-06-19/GridApp.dc.html`.
|
top bar, both driven by one `theme` state in `App` (single source of truth). Authoritative color
|
||||||
|
pairs came from the **full Claude Design export** (`design/_imports/2026-06-19_zip-file/`, incl. the
|
||||||
|
previously-missing `store.js` + the four `*App.dc.html` `DCLogic` palettes) — every stage/recency/
|
||||||
|
note/priority/reminder/money tint uses the designer's exact dark+light values, not guesses.
|
||||||
|
- **Method (zero dark-mode regression by construction):** grew `:root` to a full token set (44
|
||||||
|
themed color slots) whose **dark values equal the originals byte-for-byte**, so `var(--x)`
|
||||||
|
resolves identically in dark — verified (16 exact-match vars asserted == their original hex).
|
||||||
|
Migrated **319 hex literals → `var()`** across the JSX inline region *and* the `<style>` block
|
||||||
|
(a context-aware script for the unambiguous structural ones; targeted edits for the
|
||||||
|
context-dependent semantic/chip helpers — `StageChip` is now className-based off
|
||||||
|
`.stage-chip--{stage}`, `PIPELINE_STAGE_CHIP` deleted). All mobile surfaces + chrome are fully
|
||||||
|
var-based → **mobile light is complete**.
|
||||||
|
- **Known light rough edges (desktop only — for the conformance pass below, NOT mobile):** a
|
||||||
|
handful of bespoke `<style>`-block desktop shades (login glow/gradients `#101926`/`#4a9adf`,
|
||||||
|
scrollbar `#36506a`, desktop table row-hover `#172435`, dashboard KPI green `#10b981`) and the
|
||||||
|
legacy off-palette `.badge-*` family (`#ec407a`/`#ff9800`/etc., partly vestigial old-stage
|
||||||
|
badges) stay dark-tuned in light; **shadows** also stay dark-tuned (not yet tokenized). A few
|
||||||
|
desktop one-off shades were *consolidated* to the nearest token (small intentional dark deltas:
|
||||||
|
e.g. `#2a3a4d`→border, `#d9a15f`→due-soon, `#f3b2b2`→danger-text) — enumerate when polishing.
|
||||||
|
- **Verified:** render-smoke green (Babel transform + jsdom mount); a throwaway jsdom interaction
|
||||||
|
harness mounted the **authed shell** and exercised the toggle (boot-defaults-dark → click→light
|
||||||
|
+persist+relabel → click→dark, 7/7); theme parity + dark-identity + no-undefined-var checks all
|
||||||
|
green. **No real-phone / real-browser check yet** (same deferral as P1–P5 — verify on a device).
|
||||||
|
**Deploy:** folds into the next s9pk.
|
||||||
|
|
||||||
|
- **Phase 7 (NEW) — Full design-conformance pass against the complete Claude Design export
|
||||||
|
(`design/_imports/2026-06-19_zip-file/`).** Per Grant (2026-06-19): make sure *everything we
|
||||||
|
implement* matches what he built in Claude Design — **all buttons, colors, spacing, and
|
||||||
|
functionality**, across all four surfaces + the light theme. Concretely: (1) run `design-checker`
|
||||||
|
now that the surfaces exist; (2) reconcile remaining drift (the P6 desktop light rough edges
|
||||||
|
above — bespoke `<style>` shades, the legacy `.badge-*` family, themed shadows; plus any
|
||||||
|
button/interaction deltas vs the `*App.dc.html` comps + the ~25 `screenshots/`); (3) re-pull
|
||||||
|
anything still cloud-only. This is the "conform to the design" sweep that the per-surface builds
|
||||||
|
(P2–P6) deferred.
|
||||||
|
|
||||||
**Note on `design-checker`:** not run for this round-trip — it audits *existing* UI conformance,
|
**Note on `design-checker`:** not run for this round-trip — it audits *existing* UI conformance,
|
||||||
and the desktop UI still conforms to §1–7 (unchanged). The mobile gap is greenfield
|
and the desktop UI still conforms to §1–7 (unchanged). The mobile gap is greenfield
|
||||||
|
|||||||
+15
-9
@@ -9,7 +9,7 @@ not the visual language below.*
|
|||||||
|
|
||||||
## 1. Visual theme
|
## 1. Visual theme
|
||||||
|
|
||||||
A dense, professional, **dark** venture-CRM workspace (a **light** theme is planned as an
|
A dense, professional, **dark** venture-CRM workspace (a **light** theme ships as an
|
||||||
optional toggle — see §8 — but dark is the default and the brand's primary identity) — calm,
|
optional toggle — see §8 — but dark is the default and the brand's primary identity) — calm,
|
||||||
data-forward, slightly
|
data-forward, slightly
|
||||||
"terminal/financial." Cool blue-greys throughout, a single saturated blue as the only vivid
|
"terminal/financial." Cool blue-greys throughout, a single saturated blue as the only vivid
|
||||||
@@ -42,9 +42,9 @@ White (`#ffffff`) appears only as text on accent fills and in the brand mark.
|
|||||||
`#10b981`/`#6ee7b7`. **Staleness** (last-contact age) overlays the same ramp: fresh grey →
|
`#10b981`/`#6ee7b7`. **Staleness** (last-contact age) overlays the same ramp: fresh grey →
|
||||||
due-soon amber `#e0b341` → danger-soft red `#e06c6c`/`#f87171`. Both are tinted-fill + tinted-
|
due-soon amber `#e0b341` → danger-soft red `#e06c6c`/`#f87171`. Both are tinted-fill + tinted-
|
||||||
text badges, consistent with the badge rule above.
|
text badges, consistent with the badge rule above.
|
||||||
- **Light theme (planned)** mirrors every slot above in `tokens.tokens.json` `color.light` (e.g.
|
- **Light theme (implemented, P6)** mirrors every slot above in `tokens.tokens.json` `color.light`
|
||||||
base `#eaeef3`, panel `#ffffff`, text-primary `#16202c`); accent `#3b82c4` is unchanged. Dark is
|
(e.g. base `#eaeef3`, panel `#ffffff`, text-primary `#16202c`); accent `#3b82c4` is unchanged. Dark
|
||||||
the default — see §8.
|
is the default — see §8.
|
||||||
|
|
||||||
## 3. Typography
|
## 3. Typography
|
||||||
|
|
||||||
@@ -166,11 +166,17 @@ brief in `design/BRIEF.md`). The system:
|
|||||||
- **Touch + safe areas:** 44px minimum primary touch targets; body type bumped 13→15px (§3);
|
- **Touch + safe areas:** 44px minimum primary touch targets; body type bumped 13→15px (§3);
|
||||||
sticky bottom nav respects `env(safe-area-inset-bottom)` and content gets bottom padding so
|
sticky bottom nav respects `env(safe-area-inset-bottom)` and content gets bottom padding so
|
||||||
the nav never overlaps it.
|
the nav never overlaps it.
|
||||||
- **Light theme — planned (adopted 2026-06-19).** Ship the light palette (`tokens.tokens.json`
|
- **Light theme — implemented (P6, 2026-06-19).** The light palette (`tokens.tokens.json`
|
||||||
`color.light`) behind a `[data-theme]` switch + a top-bar toggle. **Dark stays the default and
|
`color.light` + the per-component tints from the Claude Design export) ships behind a
|
||||||
the brand's primary identity;** light is the optional alternative. It co-lands with the
|
`:root[data-theme="light"]` switch, applied by a pre-paint boot script from
|
||||||
inline-style→CSS migration (theming needs CSS custom properties, not per-element inline values).
|
`localStorage.venture_crm_theme` and toggled app-wide (desktop sidebar footer + mobile top-bar
|
||||||
Full per-component light tints (stage/staleness/note badges) are in `_imports/2026-06-19/`.
|
icon). **Dark stays the default and the brand's primary identity;** light is the optional
|
||||||
|
alternative (no `prefers-color-scheme` auto-switch). Colors are driven by ~44 themed `:root` CSS
|
||||||
|
vars whose dark values equal the original literals (so dark mode is unchanged). **Known gaps
|
||||||
|
(Phase 7 conformance pass):** some bespoke desktop `<style>` shades (login glow/scrollbar/
|
||||||
|
table-hover/KPI), the legacy off-palette `.badge-*` family, and shadows are not yet themed.
|
||||||
|
Full per-component light tints (stage/staleness/note badges) live in
|
||||||
|
`_imports/2026-06-19_zip-file/` (`store.js` + the four `*App.dc.html` `DCLogic` palettes).
|
||||||
|
|
||||||
The gap between this section and the current `index.html` is the implementation backlog in
|
The gap between this section and the current `index.html` is the implementation backlog in
|
||||||
`ROADMAP.md` (incl. the inline-style→CSS migration and the locked pipeline-stages/flags spec).
|
`ROADMAP.md` (incl. the inline-style→CSS migration and the locked pipeline-stages/flags spec).
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-width:100%; min-height:100vh; width:max-content; box-sizing:border-box; padding:48px 56px 64px; background:#e7e5df; display:flex; flex-direction:column;">
|
||||||
|
<div style="font:700 22px 'IBM Plex Sans'; color:#161b22; letter-spacing:-0.01em;">Ten31 CRM — Contacts, mobile</div>
|
||||||
|
<div style="font:400 14px 'IBM Plex Sans'; color:#5a5f66; margin-top:8px; max-width:800px; line-height:1.5;">People-first directory (distinct from the org-centric Grid). Search by name, email, or firm; tap a contact for their detail — email, organization (stage, committed, last-contact staleness), and a quick <strong style="color:#3b3f46;">Log communication</strong>. Existing-LP contacts carry a blue avatar ring + the corner earmark on their org. The top-bar pencil opens the same quick-log. Both phones are live; shown in dark and light.</div>
|
||||||
|
<div style="display:flex; gap:56px; align-items:flex-start; margin-top:40px;">
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Dark mode · default</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#0b1118;">
|
||||||
|
<dc-import name="ContactsApp" theme="dark" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Light mode</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#eaeef3;">
|
||||||
|
<dc-import name="ContactsApp" theme="light" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":980,"height":980}}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<script src="store.js"></script>
|
||||||
|
<style>
|
||||||
|
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
.cn-scroll::-webkit-scrollbar { width: 0; height: 0; }
|
||||||
|
.cn-root button, .cn-root input, .cn-root textarea { font-family: inherit; }
|
||||||
|
.cn-root {
|
||||||
|
--sans:'IBM Plex Sans','Segoe UI',sans-serif; --mono:'IBM Plex Mono',monospace;
|
||||||
|
--grad1:#1a3c5e44; --grad2:#27496b33;
|
||||||
|
--base:#0b1118; --panel:#111a27; --elev:#152233; --input:#0d1622; --hover:#1b2a3a;
|
||||||
|
--border:#263548; --bstrong:#35506a; --divider:#1c2735;
|
||||||
|
--t1:#e5edf5; --t2:#c7d3e0; --t3:#8ea2b7; --t4:#70859b;
|
||||||
|
--accent:#3b82c4; --accentlight:#93c5fd; --danger:#e06c6c; --money:#6ee7b7;
|
||||||
|
--shadow-card:0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
|
||||||
|
--nav-bg:#0d1622cc;
|
||||||
|
}
|
||||||
|
.cn-root[data-theme="light"] {
|
||||||
|
--grad1:#3b82c41c; --grad2:#27496b10;
|
||||||
|
--base:#eaeef3; --panel:#ffffff; --elev:#f4f7fb; --input:#eef2f7; --hover:#e6ecf4;
|
||||||
|
--border:#d6dde7; --bstrong:#b6c3d4; --divider:#e8edf3;
|
||||||
|
--t1:#16202c; --t2:#33414f; --t3:#5a6b7d; --t4:#84909e;
|
||||||
|
--accent:#3b82c4; --accentlight:#1f6fb8; --danger:#c0322f; --money:#057a55;
|
||||||
|
--shadow-card:0 8px 20px rgba(40,70,110,0.10), inset 0 1px 0 #ffffff;
|
||||||
|
--nav-bg:#ffffffd9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div class="cn-root" data-theme="{{ themeAttr }}" style="position:absolute; inset:0; background:radial-gradient(900px 460px at 12% -8%, var(--grad1), transparent 60%), radial-gradient(760px 380px at 92% -2%, var(--grad2), transparent 58%), var(--base); display:flex; flex-direction:column; font-family:var(--sans); color:var(--t1); letter-spacing:0.01em; overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- status bar -->
|
||||||
|
<div style="height:46px; flex:none; display:flex; align-items:flex-end; justify-content:space-between; padding:0 24px 6px; font-family:var(--mono); font-size:13px; color:var(--t2);">
|
||||||
|
<span>9:41</span>
|
||||||
|
<span style="display:flex; gap:6px; align-items:center; font-size:11px; letter-spacing:0.02em;">5G ▮▮▮▯ 84%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- top bar -->
|
||||||
|
<div style="flex:none; height:52px; display:flex; align-items:center; justify-content:space-between; padding:0 16px; border-bottom:1px solid var(--border);">
|
||||||
|
<span style="font-family:var(--mono); font-weight:600; font-size:15px; letter-spacing:0.04em; color:var(--t1);">·Ten31·</span>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<button onClick="{{ openQuickLog }}" aria-label="Log communication" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--border); background:var(--elev); color:var(--t3); cursor:pointer; display:flex; align-items:center; justify-content:center;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button onClick="{{ toggleTheme }}" aria-label="Toggle theme" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--border); background:var(--elev); color:var(--t3); font-size:15px; cursor:pointer; display:flex; align-items:center; justify-content:center; line-height:1;">{{ themeIcon }}</button>
|
||||||
|
<button onClick="{{ toggleAccount }}" aria-label="Account" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--bstrong); background:var(--elev); color:var(--accentlight); font-family:var(--mono); font-weight:600; font-size:13px; cursor:pointer; display:flex; align-items:center; justify-content:center;">GG</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- title + search -->
|
||||||
|
<div style="flex:none; padding:14px 16px 12px;">
|
||||||
|
<div style="display:flex; align-items:baseline; justify-content:space-between;">
|
||||||
|
<span style="font-size:21px; font-weight:600; letter-spacing:-0.01em;">Contacts</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">{{ countLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<input value="{{ search }}" onInput="{{ onSearch }}" placeholder="Search name, email, or firm…" style="width:100%; height:44px; margin-top:13px; background:var(--input); border:1px solid var(--border); border-radius:8px; color:var(--t1); font-family:var(--sans); font-size:15px; padding:0 14px; outline:none; box-sizing:border-box;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- directory list -->
|
||||||
|
<div class="cn-scroll" style="flex:1; min-height:0; overflow-y:auto; padding:2px 16px 20px; display:flex; flex-direction:column;">
|
||||||
|
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="6">
|
||||||
|
<div style="position:sticky; top:0; z-index:2; background:var(--base); padding:10px 4px 6px; font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.1em; color:var(--t4);">{{ g.letter }}</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px; padding-bottom:6px;">
|
||||||
|
<sc-for list="{{ g.people }}" as="c" hint-placeholder-count="2">
|
||||||
|
<button onClick="{{ c.open }}" style="text-align:left; cursor:pointer; width:100%; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:11px 13px; box-shadow:var(--shadow-card); display:flex; align-items:center; gap:12px; color:var(--t1);">
|
||||||
|
<span style="flex:none; width:40px; height:40px; border-radius:999px; background:var(--elev); border:{{ c.ring }}; display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:13px; font-weight:600; color:var(--accentlight);">{{ c.initials }}</span>
|
||||||
|
<span style="flex:1; min-width:0; display:flex; flex-direction:column; gap:3px;">
|
||||||
|
<span style="font-size:15px; font-weight:600; line-height:1.2; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ c.name }}</span>
|
||||||
|
<span style="display:flex; align-items:center; gap:8px; min-width:0;">
|
||||||
|
<span style="font-size:13px; color:var(--t3); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ c.org }}</span>
|
||||||
|
<span style="flex:none; font-family:var(--mono); font-size:9px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; padding:2px 7px; border-radius:999px; background:{{ c.stageBg }}; color:{{ c.stageText }}; border:1px solid {{ c.stageBorder }};">{{ c.stage }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span style="flex:none; font-family:var(--mono); font-size:11px; color:{{ c.lastColor }};">{{ c.last }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<sc-if value="{{ listEmpty }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:48px 20px; text-align:center; color:var(--t4); font-size:14px;">No contacts match.</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- bottom tab bar -->
|
||||||
|
<div style="flex:none; display:flex; border-top:1px solid var(--border); background:var(--nav-bg); backdrop-filter:blur(8px); padding-bottom:18px;">
|
||||||
|
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="4">
|
||||||
|
<button onClick="{{ t.go }}" style="flex:1; background:none; border:none; cursor:pointer; height:56px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px; color:{{ t.color }};">
|
||||||
|
<span style="width:20px; height:20px; display:flex; align-items:center; justify-content:center;">{{ t.icon }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.04em;">{{ t.label }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- account menu -->
|
||||||
|
<sc-if value="{{ accountMenu }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeAccount }}" style="position:absolute; inset:0; z-index:40; animation:fadeIn 120ms ease;">
|
||||||
|
<div style="position:absolute; top:96px; right:16px; width:208px; background:var(--elev); border:1px solid var(--bstrong); border-radius:10px; box-shadow:0 24px 56px rgba(1,8,17,0.5); overflow:hidden;">
|
||||||
|
<div style="padding:14px 16px; border-bottom:1px solid var(--border);">
|
||||||
|
<div style="font-size:14px; font-weight:600; color:var(--t1);">Grant Gilliam</div>
|
||||||
|
<div style="font-size:12px; color:var(--t3); margin-top:2px;">grant@ten31.xyz</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px;">
|
||||||
|
<div style="padding:11px 12px; border-radius:7px; font-size:14px; color:var(--t2);">Profile</div>
|
||||||
|
<div style="padding:11px 12px; border-radius:7px; font-size:14px; color:var(--danger);">Log out</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- contact detail sheet -->
|
||||||
|
<sc-if value="{{ detailOpen }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeDetail }}" style="position:absolute; inset:0; z-index:50; background:rgba(4,9,16,0.55); animation:fadeIn 150ms ease; display:flex; flex-direction:column; justify-content:flex-end;">
|
||||||
|
<div onClick="{{ stop }}" style="background:var(--panel); border-top:1px solid var(--bstrong); border-radius:20px 20px 0 0; box-shadow:0 -24px 56px rgba(1,8,17,0.4); animation:sheetUp 280ms cubic-bezier(.2,.8,.2,1); padding:0 20px 26px; max-height:90%; display:flex; flex-direction:column;">
|
||||||
|
<div style="padding:10px 0 4px; display:flex; justify-content:center; flex:none;"><div style="width:38px; height:4px; border-radius:999px; background:var(--bstrong);"></div></div>
|
||||||
|
<div class="cn-scroll" style="overflow-y:auto;">
|
||||||
|
<!-- header -->
|
||||||
|
<div style="display:flex; align-items:center; gap:13px; padding:6px 0 4px;">
|
||||||
|
<span style="flex:none; width:52px; height:52px; border-radius:999px; background:var(--elev); border:{{ d.ring }}; display:flex; align-items:center; justify-content:center; font-family:var(--mono); font-size:16px; font-weight:600; color:var(--accentlight);">{{ d.initials }}</span>
|
||||||
|
<div style="min-width:0;">
|
||||||
|
<div style="font-size:19px; font-weight:600; line-height:1.2;">{{ d.name }}</div>
|
||||||
|
<div style="font-size:13px; color:var(--t3); margin-top:2px;">{{ d.org }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- email -->
|
||||||
|
<button onClick="{{ copyEmail }}" style="width:100%; text-align:left; cursor:pointer; margin-top:14px; background:var(--input); border:1px solid var(--border); border-radius:10px; padding:12px 14px; display:flex; align-items:center; justify-content:space-between; gap:10px; color:var(--t1);">
|
||||||
|
<span style="display:flex; flex-direction:column; gap:3px; min-width:0;">
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.06em; text-transform:uppercase; color:var(--t4);">Email</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:14px; color:var(--accentlight); overflow:hidden; text-overflow:ellipsis;">{{ d.email }}</span>
|
||||||
|
</span>
|
||||||
|
<span style="flex:none; font-family:var(--mono); font-size:11px; color:var(--t4);">copy</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- actions -->
|
||||||
|
<div style="display:flex; gap:10px; margin-top:12px;">
|
||||||
|
<button onClick="{{ logForContact }}" style="flex:2; height:46px; border-radius:8px; border:none; background:linear-gradient(#3b82c4,#2f6ea9); color:#fff; font-size:14px; font-weight:600; cursor:pointer; box-shadow:0 6px 14px rgba(12,40,68,0.35);">Log communication</button>
|
||||||
|
<button onClick="{{ draftEmail }}" style="flex:1; height:46px; border-radius:8px; border:1px solid var(--bstrong); background:var(--elev); color:var(--t2); font-size:14px; font-weight:500; cursor:pointer;">Email</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- organization -->
|
||||||
|
<div style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3); margin:20px 0 9px;">Organization</div>
|
||||||
|
<div style="background:var(--input); border:1px solid var(--border); border-radius:10px; padding:13px 14px; display:flex; flex-direction:column; gap:11px;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px;">
|
||||||
|
<span style="display:flex; align-items:center; gap:8px; min-width:0;">
|
||||||
|
<sc-if value="{{ d.existing }}" hint-placeholder-val="{{ false }}"><span style="flex:none; width:0; height:0; border-top:13px solid var(--accent); border-right:13px solid transparent;"></span></sc-if>
|
||||||
|
<span style="font-size:15px; font-weight:600; overflow:hidden; text-overflow:ellipsis;">{{ d.org }}</span>
|
||||||
|
</span>
|
||||||
|
<span style="flex:none; font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; padding:3px 9px; border-radius:999px; background:{{ d.stageBg }}; color:{{ d.stageText }}; border:1px solid {{ d.stageBorder }};">{{ d.stage }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between;">
|
||||||
|
<span style="display:flex; flex-direction:column; gap:3px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.06em; text-transform:uppercase; color:var(--t4);">Committed</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:15px; font-weight:600; color:{{ d.amtColor }};">{{ d.amount }}</span>
|
||||||
|
</span>
|
||||||
|
<span style="display:flex; flex-direction:column; gap:3px; text-align:right;">
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.06em; text-transform:uppercase; color:var(--t4);">Last contact</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:14px; color:{{ d.lastColor }};">{{ d.last }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<sc-if value="{{ d.hasNote }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="border-top:1px solid var(--divider); padding-top:10px; display:flex; align-items:center; gap:8px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:2px 6px; border-radius:4px; background:{{ d.noteBg }}; color:{{ d.noteText }};">{{ d.noteType }}</span>
|
||||||
|
<span style="font-size:13px; color:var(--t2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ d.noteSummary }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
<button onClick="{{ openInGrid }}" style="align-self:flex-start; background:none; border:none; padding:0; cursor:pointer; color:var(--accentlight); font-size:13px;">Open investor in Grid ›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- log communication sheet (also pencil quick-log) -->
|
||||||
|
<sc-if value="{{ logOpen }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeLog }}" style="position:absolute; inset:0; z-index:65; background:rgba(4,9,16,0.6); animation:fadeIn 150ms ease; display:flex; flex-direction:column; justify-content:flex-end;">
|
||||||
|
<div onClick="{{ stop }}" style="background:var(--panel); border-top:1px solid var(--bstrong); border-radius:20px 20px 0 0; box-shadow:0 -24px 56px rgba(1,8,17,0.4); animation:sheetUp 280ms cubic-bezier(.2,.8,.2,1); padding:0 20px 26px; max-height:90%; display:flex; flex-direction:column;">
|
||||||
|
<div style="padding:10px 0 4px; display:flex; justify-content:center; flex:none;"><div style="width:38px; height:4px; border-radius:999px; background:var(--bstrong);"></div></div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; padding:8px 0 4px; flex:none;">
|
||||||
|
<span style="font-size:18px; font-weight:600; color:var(--t1);">Log communication</span>
|
||||||
|
<button onClick="{{ closeLog }}" style="background:none; border:none; color:var(--t3); font-size:22px; cursor:pointer; line-height:1; padding:0 4px;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="cn-scroll" style="overflow-y:auto; margin-top:10px;">
|
||||||
|
{{ logBody }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- toast -->
|
||||||
|
<sc-if value="{{ toast }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="position:absolute; left:16px; right:16px; bottom:92px; z-index:70; background:var(--elev); border:1px solid var(--bstrong); border-radius:10px; box-shadow:0 10px 24px rgba(4,12,22,0.35); padding:13px 16px; font-size:14px; color:var(--t1); display:flex; align-items:center; gap:10px; animation:fadeIn 150ms ease;">
|
||||||
|
<span style="color:var(--money);">✓</span>{{ toast }}
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":393,"height":812},"theme":{"editor":"enum","options":["dark","light"],"default":"dark","tsType":"'dark'|'light'"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
theme: props.theme === 'light' ? 'light' : 'dark',
|
||||||
|
search: '',
|
||||||
|
accountMenu: false,
|
||||||
|
detailId: null, // contactId
|
||||||
|
log: null, // { contactId|null, type, summary, details, q }
|
||||||
|
toast: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() { if (window.T31Store) this._unsub = window.T31Store.subscribe(() => this.forceUpdate()); }
|
||||||
|
componentWillUnmount() { if (this._unsub) this._unsub(); }
|
||||||
|
|
||||||
|
seed() {
|
||||||
|
const C = (name, email) => ({ name, email });
|
||||||
|
return [
|
||||||
|
{ id: 1, name: 'Northwall Capital', stage: 'commitment', daysAgo: 2, amt: 2500000,
|
||||||
|
contacts: [C('Dana Reyes', 'dana@northwall.com'), C('Per Holt', 'per@northwall.com')],
|
||||||
|
notes: [['Email', 'Confirmed $2.5M allocation across funds', '2026-06-17']] },
|
||||||
|
{ id: 2, name: 'Brightseed Partners', stage: 'engaged', daysAgo: 5, amt: 0,
|
||||||
|
contacts: [C('Omar Said', 'omar@brightseed.vc')], notes: [['Note', 'Intro from Polaris — warm', '2026-06-14']] },
|
||||||
|
{ id: 3, name: 'Cedarline Family Office', stage: 'commitment', daysAgo: 7, amt: 1200000,
|
||||||
|
contacts: [C('Lena Cho', 'lena@cedarline.com')], notes: [['Call', 'Wire received, fully funded', '2026-06-12']] },
|
||||||
|
{ id: 4, name: 'Vance & Co', stage: 'engaged', daysAgo: 3, amt: 0,
|
||||||
|
contacts: [C('Marcus Vance', 'mv@vanceco.com')], notes: [] },
|
||||||
|
{ id: 5, name: 'Polaris Endowment', stage: 'diligence', daysAgo: 1, amt: 5000000,
|
||||||
|
contacts: [C('Ruth Almeida', 'ralmeida@polaris.org')], notes: [['Meeting', 'IC presentation went well', '2026-06-18']] },
|
||||||
|
{ id: 7, name: 'Meridian Trust', stage: 'commitment', daysAgo: 4, amt: 800000,
|
||||||
|
contacts: [C('Sofia Marin', 'sofia@meridiantrust.com')], notes: [['Note', 'Signed side letter', '2026-06-14']] },
|
||||||
|
{ id: 8, name: 'Atlas Ventures Fund', stage: 'engaged', daysAgo: 6, amt: 0,
|
||||||
|
contacts: [C('Will Tanaka', 'will@atlasvf.com')], notes: [] },
|
||||||
|
{ id: 9, name: 'K. Whitfield', stage: null, daysAgo: 21, amt: 0,
|
||||||
|
contacts: [C('Kira Whitfield', 'kira@whitfield.io')], notes: [['Note', 'No allocation — parked', '2026-05-28']] },
|
||||||
|
{ id: 10, name: 'Granite Bay LP', stage: 'commitment', daysAgo: 30, amt: 3300000,
|
||||||
|
contacts: [C('Tom Becker', 'tom@granitebay.com')], notes: [] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
stageColors(s, theme) {
|
||||||
|
const light = theme === 'light';
|
||||||
|
const dark = {
|
||||||
|
'lead': { bg: '#70859b22', text: '#8ea2b7', border: '#2635488a' },
|
||||||
|
'engaged': { bg: '#3b82c422', text: '#93c5fd', border: '#3b82c44d' },
|
||||||
|
'diligence': { bg: '#e0b3411f', text: '#e0b341', border: '#e0b3413d' },
|
||||||
|
'commitment': { bg: '#10b9811f', text: '#6ee7b7', border: '#10b9813d' },
|
||||||
|
};
|
||||||
|
const lite = {
|
||||||
|
'lead': { bg: '#5a6b7d14', text: '#5a6b7d', border: '#d6dde7' },
|
||||||
|
'engaged': { bg: '#3b82c416', text: '#2266a0', border: '#bcd2ea' },
|
||||||
|
'diligence': { bg: '#e0b34122', text: '#8a6c12', border: '#e4d29a' },
|
||||||
|
'commitment': { bg: '#10b98118', text: '#057a55', border: '#a9ddca' },
|
||||||
|
};
|
||||||
|
const map = light ? lite : dark;
|
||||||
|
return map[s] || (light ? { bg: '#5a6b7d12', text: '#84909e', border: '#d6dde7' } : { bg: '#1b2a3a', text: '#70859b', border: '#263548' });
|
||||||
|
}
|
||||||
|
noteTag(t, theme) {
|
||||||
|
const light = theme === 'light';
|
||||||
|
const dark = { 'Email': { bg: '#3b82c422', text: '#93c5fd' }, 'Call': { bg: '#10b98122', text: '#6ee7b7' }, 'Meeting': { bg: '#f59e0b1f', text: '#fcd34d' }, 'Note': { bg: '#1b2a3a', text: '#8ea2b7' } };
|
||||||
|
const lite = { 'Email': { bg: '#3b82c41a', text: '#2266a0' }, 'Call': { bg: '#10b9811a', text: '#057a55' }, 'Meeting': { bg: '#f59e0b1a', text: '#a76a07' }, 'Note': { bg: '#5a6b7d14', text: '#5a6b7d' } };
|
||||||
|
const map = light ? lite : dark;
|
||||||
|
return map[t] || map['Note'];
|
||||||
|
}
|
||||||
|
recency(days, theme) {
|
||||||
|
const AMBER = 10, STALE = 30;
|
||||||
|
const light = theme === 'light';
|
||||||
|
if (days >= STALE) return { text: days + 'd stale', color: light ? '#c0322f' : '#f87171' };
|
||||||
|
if (days >= AMBER) return { text: days + 'd ago', color: light ? '#a76a07' : '#e0b341' };
|
||||||
|
return { text: days + 'd ago', color: light ? '#84909e' : '#70859b' };
|
||||||
|
}
|
||||||
|
money(n) {
|
||||||
|
if (!n) return '$0';
|
||||||
|
if (n >= 1e6) return '$' + (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M';
|
||||||
|
if (n >= 1e3) return '$' + Math.round(n / 1e3) + 'K';
|
||||||
|
return '$' + n;
|
||||||
|
}
|
||||||
|
initials(name) {
|
||||||
|
const parts = name.replace(/[^A-Za-z .]/g, '').trim().split(/\s+/);
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
stageLabel(s) { return s || 'no stage'; }
|
||||||
|
|
||||||
|
// flatten investors → contacts (from the shared store)
|
||||||
|
allContacts() {
|
||||||
|
const out = [];
|
||||||
|
const S = window.T31Store;
|
||||||
|
const list = S ? S.investors : [];
|
||||||
|
list.forEach(inv => (inv.contacts || []).forEach((c, idx) => {
|
||||||
|
out.push({ cid: inv.id * 100 + idx, name: c.name, email: c.email, orgId: inv.id, org: inv.name, stage: inv.stage, amt: S ? S.committed(inv) : 0, daysAgo: inv.daysAgo, notes: inv.notes });
|
||||||
|
}));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
contactById(cid) { return this.allContacts().find(c => c.cid === cid); }
|
||||||
|
|
||||||
|
toast(msg) { this.setState({ toast: msg }); clearTimeout(this._tt); this._tt = setTimeout(() => this.setState({ toast: null }), 2000); }
|
||||||
|
setLog(patch) { this.setState(s => ({ log: Object.assign({}, s.log, patch) })); }
|
||||||
|
saveLog() {
|
||||||
|
const lg = this.state.log; if (!lg || !lg.contactId || !lg.summary.trim()) return;
|
||||||
|
const c = this.contactById(lg.contactId);
|
||||||
|
const entry = [lg.type, lg.summary.trim(), '2026-06-19'];
|
||||||
|
if (window.T31Store) window.T31Store.logNote(c.orgId, entry);
|
||||||
|
this.setState({ log: null });
|
||||||
|
this.toast('Logged for ' + c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const s = this.state;
|
||||||
|
const theme = s.theme;
|
||||||
|
const q = s.search.trim().toLowerCase();
|
||||||
|
|
||||||
|
let people = this.allContacts();
|
||||||
|
if (q) people = people.filter(c => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q) || c.org.toLowerCase().includes(q));
|
||||||
|
people.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
const mk = (c) => {
|
||||||
|
const sc = this.stageColors(c.stage, theme);
|
||||||
|
const rec = this.recency(c.daysAgo, theme);
|
||||||
|
return {
|
||||||
|
cid: c.cid, name: c.name, org: c.org, initials: this.initials(c.name),
|
||||||
|
ring: c.amt > 0 ? '1.5px solid var(--accent)' : '1px solid var(--border)',
|
||||||
|
stage: this.stageLabel(c.stage), stageBg: sc.bg, stageText: sc.text, stageBorder: sc.border,
|
||||||
|
last: rec.text, lastColor: rec.color,
|
||||||
|
open: () => this.setState({ detailId: c.cid }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// group by first letter of name
|
||||||
|
const groups = [];
|
||||||
|
people.forEach(c => {
|
||||||
|
const letter = c.name[0].toUpperCase();
|
||||||
|
let g = groups[groups.length - 1];
|
||||||
|
if (!g || g.letter !== letter) { g = { letter, people: [] }; groups.push(g); }
|
||||||
|
g.people.push(mk(c));
|
||||||
|
});
|
||||||
|
|
||||||
|
// detail
|
||||||
|
const sel = s.detailId ? this.contactById(s.detailId) : null;
|
||||||
|
let d = null;
|
||||||
|
if (sel) {
|
||||||
|
const sc = this.stageColors(sel.stage, theme);
|
||||||
|
const rec = this.recency(sel.daysAgo, theme);
|
||||||
|
const note = sel.notes && sel.notes[0];
|
||||||
|
const nt = note ? this.noteTag(note[0], theme) : null;
|
||||||
|
d = {
|
||||||
|
name: sel.name, org: sel.org, email: sel.email, initials: this.initials(sel.name),
|
||||||
|
ring: sel.amt > 0 ? '1.5px solid var(--accent)' : '1px solid var(--border)',
|
||||||
|
existing: sel.amt > 0,
|
||||||
|
stage: this.stageLabel(sel.stage), stageBg: sc.bg, stageText: sc.text, stageBorder: sc.border,
|
||||||
|
amount: this.money(sel.amt), amtColor: sel.amt > 0 ? (theme === 'light' ? '#057a55' : '#6ee7b7') : 'var(--t4)',
|
||||||
|
last: rec.text, lastColor: rec.color,
|
||||||
|
hasNote: !!note, noteType: note ? note[0].toUpperCase() : '', noteSummary: note ? note[1] : '',
|
||||||
|
noteBg: nt ? nt.bg : '', noteText: nt ? nt.text : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// tabs
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'grid', label: 'Grid' }, { key: 'pipeline', label: 'Pipeline' },
|
||||||
|
{ key: 'reminders', label: 'Reminders' }, { key: 'contacts', label: 'Contacts' },
|
||||||
|
].map(t => ({
|
||||||
|
label: t.label, color: t.key === 'contacts' ? 'var(--accent)' : 'var(--t4)',
|
||||||
|
icon: this.tabIcon(t.key, t.key === 'contacts'),
|
||||||
|
go: () => { if (window.T31Store) window.T31Store.setTab(t.key); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const logBody = s.log ? this.buildLog(s.log) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
themeAttr: theme, themeIcon: theme === 'light' ? '☾' : '☀',
|
||||||
|
toggleTheme: () => { const t = theme === 'light' ? 'dark' : 'light'; if (window.T31Store) window.T31Store.setTheme(t); this.setState({ theme: t }); },
|
||||||
|
toggleAccount: () => this.setState(st => ({ accountMenu: !st.accountMenu })),
|
||||||
|
closeAccount: () => this.setState({ accountMenu: false }),
|
||||||
|
accountMenu: s.accountMenu,
|
||||||
|
countLabel: people.length + (people.length === 1 ? ' person' : ' people'),
|
||||||
|
search: s.search, onSearch: e => this.setState({ search: e.target.value }),
|
||||||
|
groups, listEmpty: people.length === 0, tabs,
|
||||||
|
// detail
|
||||||
|
detailOpen: !!sel, d,
|
||||||
|
closeDetail: () => this.setState({ detailId: null }),
|
||||||
|
stop: e => e.stopPropagation(),
|
||||||
|
copyEmail: () => this.toast('Email copied'),
|
||||||
|
draftEmail: () => this.toast('Drafting email to ' + (sel ? sel.email : '')),
|
||||||
|
openInGrid: () => { if (window.T31Store && sel) window.T31Store.openInvestor(sel.orgId); },
|
||||||
|
logForContact: () => this.setState({ log: { contactId: sel.cid, type: 'Note', summary: '', details: '', q: '' } }),
|
||||||
|
openInGridLabel: 'Open investor in Grid',
|
||||||
|
// log
|
||||||
|
openQuickLog: () => this.setState({ log: { contactId: null, type: 'Note', summary: '', details: '', q: '' } }),
|
||||||
|
closeLog: () => this.setState({ log: null }),
|
||||||
|
logOpen: !!s.log, logBody,
|
||||||
|
toast: s.toast,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildLog(lg) {
|
||||||
|
const h = React.createElement;
|
||||||
|
const theme = this.state.theme;
|
||||||
|
const dark = theme !== 'light';
|
||||||
|
const T = { input: dark ? '#0d1622' : '#eef2f7', border: dark ? '#263548' : '#d6dde7', bstrong: dark ? '#35506a' : '#b6c3d4',
|
||||||
|
t1: dark ? '#e5edf5' : '#16202c', t2: dark ? '#c7d3e0' : '#33414f', t3: dark ? '#8ea2b7' : '#5a6b7d', t4: dark ? '#70859b' : '#84909e',
|
||||||
|
elev: dark ? '#152233' : '#f4f7fb', accentlight: dark ? '#93c5fd' : '#1f6fb8', panel: dark ? '#111a27' : '#fff' };
|
||||||
|
const inputStyle = { width: '100%', height: 46, background: T.input, border: '1px solid ' + T.border, borderRadius: 8, color: T.t1, fontFamily: 'var(--sans)', fontSize: 15, padding: '0 14px', outline: 'none', boxSizing: 'border-box' };
|
||||||
|
const label = (t) => h('div', { style: { fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase', color: T.t3, margin: '16px 0 8px' } }, t);
|
||||||
|
|
||||||
|
// phase 1: pick contact
|
||||||
|
if (!lg.contactId) {
|
||||||
|
const qn = (lg.q || '').trim().toLowerCase();
|
||||||
|
let pool = this.allContacts();
|
||||||
|
if (qn) pool = pool.filter(c => c.name.toLowerCase().includes(qn) || c.email.toLowerCase().includes(qn) || c.org.toLowerCase().includes(qn));
|
||||||
|
else pool = pool.sort((a, b) => a.daysAgo - b.daysAgo);
|
||||||
|
pool = pool.slice(0, 8);
|
||||||
|
return h('div', null,
|
||||||
|
h('div', { style: { fontSize: 13, color: T.t3, lineHeight: 1.5, marginBottom: 12 } }, 'Pick a contact, then log the communication.'),
|
||||||
|
h('input', { value: lg.q, onChange: e => this.setLog({ q: e.target.value }), style: inputStyle, placeholder: 'Search contact or firm…', autoFocus: true }),
|
||||||
|
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 } }, pool.length ? pool.map(c => {
|
||||||
|
const sc = this.stageColors(c.stage, theme);
|
||||||
|
return h('button', { key: c.cid, onClick: () => this.setLog({ contactId: c.cid }), style: { width: '100%', textAlign: 'left', cursor: 'pointer', background: T.input, border: '1px solid ' + T.border, borderRadius: 10, padding: '11px 13px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, color: T.t1 } },
|
||||||
|
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 } },
|
||||||
|
h('span', { style: { fontSize: 15, fontWeight: 500 } }, c.name),
|
||||||
|
h('span', { style: { fontSize: 12, color: T.t3 } }, c.org)),
|
||||||
|
h('span', { style: { flex: 'none', fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', padding: '3px 8px', borderRadius: 999, background: sc.bg, color: sc.text, border: '1px solid ' + sc.border } }, this.stageLabel(c.stage)));
|
||||||
|
}) : h('div', { style: { fontSize: 13, color: T.t4, padding: '16px 4px' } }, 'No matches.'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase 2: form
|
||||||
|
const c = this.contactById(lg.contactId);
|
||||||
|
const types = ['Note', 'Email', 'Call', 'Meeting'];
|
||||||
|
const disabled = !lg.summary.trim();
|
||||||
|
return h('div', null,
|
||||||
|
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, background: T.input, border: '1px solid ' + T.border, borderRadius: 10, padding: '11px 13px' } },
|
||||||
|
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 } },
|
||||||
|
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '0.06em', textTransform: 'uppercase', color: T.t4 } }, 'Logging for'),
|
||||||
|
h('span', { style: { fontSize: 15, fontWeight: 600, color: T.t1 } }, c.name + ' · ' + c.org)),
|
||||||
|
h('button', { onClick: () => this.setLog({ contactId: null }), style: { flex: 'none', background: 'none', border: 'none', color: T.accentlight, fontSize: 13, cursor: 'pointer' } }, 'Change')),
|
||||||
|
label('Type'),
|
||||||
|
h('div', { style: { display: 'flex', gap: 8 } }, types.map(tp => {
|
||||||
|
const on = lg.type === tp; const tc = this.noteTag(tp, theme);
|
||||||
|
return h('button', { key: tp, onClick: () => this.setLog({ type: tp }), style: { flex: 1, height: 40, borderRadius: 7, cursor: 'pointer', fontFamily: 'var(--mono)', fontSize: 12, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', border: '1px solid ' + (on ? T.bstrong : T.border), background: on ? tc.bg : T.input, color: on ? tc.text : T.t3 } }, tp);
|
||||||
|
})),
|
||||||
|
label('Summary'),
|
||||||
|
h('input', { value: lg.summary, onChange: e => this.setLog({ summary: e.target.value }), style: inputStyle, placeholder: 'Short headline', autoFocus: true }),
|
||||||
|
label('Details'),
|
||||||
|
h('textarea', { value: lg.details, onChange: e => this.setLog({ details: e.target.value }), style: Object.assign({}, inputStyle, { height: 92, padding: '12px 14px', resize: 'none', lineHeight: 1.45 }), placeholder: 'Full context kept in communications history' }),
|
||||||
|
h('div', { style: { fontSize: 12, color: T.t4, marginTop: 8, lineHeight: 1.45 } }, 'Posts to ' + c.org + '\u2019s timeline via the one-row log path and bumps last contact to today.'),
|
||||||
|
h('button', { onClick: () => this.saveLog(), disabled: disabled, style: { width: '100%', height: 48, marginTop: 18, borderRadius: 8, border: 'none', background: disabled ? T.elev : 'linear-gradient(#3b82c4,#2f6ea9)', color: disabled ? T.t4 : '#fff', fontSize: 15, fontWeight: 600, cursor: disabled ? 'default' : 'pointer' } }, 'Log communication')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tabIcon(key, active) {
|
||||||
|
const c = active ? '#3b82c4' : (this.state.theme === 'light' ? '#84909e' : '#70859b');
|
||||||
|
const mk = (children) => React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 20 20', fill: 'none' }, children);
|
||||||
|
const r = (pp) => React.createElement('rect', pp);
|
||||||
|
const ln = (pp) => React.createElement('line', Object.assign({}, pp, { stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }));
|
||||||
|
if (key === 'grid') return mk([
|
||||||
|
r({ key: 1, x: 3, y: 3, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 2, x: 11, y: 3, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 3, x: 3, y: 11, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 4, x: 11, y: 11, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
]);
|
||||||
|
if (key === 'pipeline') return mk([
|
||||||
|
r({ key: 1, x: 3, y: 3, width: 4.5, height: 14, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 2, x: 9.25, y: 3, width: 4.5, height: 10, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 3, x: 15.5, y: 3, width: 1.5, height: 6, rx: 0.7, fill: c }),
|
||||||
|
]);
|
||||||
|
if (key === 'reminders') return mk([
|
||||||
|
React.createElement('circle', { key: 1, cx: 10, cy: 11, r: 6.2, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
ln({ key: 2, x1: 10, y1: 11, x2: 10, y2: 7.5 }),
|
||||||
|
ln({ key: 3, x1: 10, y1: 11, x2: 12.4, y2: 12 }),
|
||||||
|
ln({ key: 4, x1: 7, y1: 3.4, x2: 4.4, y2: 5.4 }),
|
||||||
|
ln({ key: 5, x1: 13, y1: 3.4, x2: 15.6, y2: 5.4 }),
|
||||||
|
]);
|
||||||
|
return mk([
|
||||||
|
React.createElement('circle', { key: 1, cx: 10, cy: 7, r: 3.2, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
React.createElement('path', { key: 2, d: 'M4 16.5c0-3 2.7-4.8 6-4.8s6 1.8 6 4.8', stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-width:100%; min-height:100vh; width:max-content; box-sizing:border-box; padding:48px 56px 64px; background:#e7e5df; display:flex; flex-direction:column;">
|
||||||
|
<div style="font:700 22px 'IBM Plex Sans'; color:#161b22; letter-spacing:-0.01em;">Existing-investor flag — three treatments</div>
|
||||||
|
<div style="font:400 14px 'IBM Plex Sans'; color:#5a5f66; margin-top:8px; max-width:820px; line-height:1.5;">All in the single blue accent, on the same Main Fundraising list (existing LPs: Northwall, Cedarline, Polaris, Meridian, Granite). Pick one and I'll lock it in across both themes; the others stay one prop-flip away.</div>
|
||||||
|
<div style="display:flex; gap:56px; align-items:flex-start; margin-top:40px;">
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">A · Star by name (current)</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#0b1118;">
|
||||||
|
<dc-import name="GridApp" variant="compact" theme="dark" lp-flag="star" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font:400 12px 'IBM Plex Sans'; color:#6a6f76; max-width:393px; line-height:1.5;">Inline ★ before the name. Lightest touch, zero extra height, but quietest.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">B · Corner earmark</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#0b1118;">
|
||||||
|
<dc-import name="GridApp" variant="compact" theme="dark" lp-flag="earmark" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font:400 12px 'IBM Plex Sans'; color:#6a6f76; max-width:393px; line-height:1.5;">Folded blue triangle in the top-left corner. Scannable down the left edge, no height cost.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">C · Thin top banner</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#0b1118;">
|
||||||
|
<dc-import name="GridApp" variant="compact" theme="dark" lp-flag="banner" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font:400 12px 'IBM Plex Sans'; color:#6a6f76; max-width:393px; line-height:1.5;">Slim accent bar across the card top. Most assertive — strongest "special to us," heaviest on a dense list.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1300,"height":920}}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Manrope:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Hanken+Grotesk:wght@400;500;600;700&family=Spline+Sans+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-width:100%; min-height:100vh; width:max-content; box-sizing:border-box; padding:48px 56px 64px; background:#e7e5df; display:flex; flex-direction:column;">
|
||||||
|
<div style="font:700 22px 'IBM Plex Sans'; color:#161b22; letter-spacing:-0.01em;">Type exploration — Fundraising Grid</div>
|
||||||
|
<div style="font:400 14px 'IBM Plex Sans'; color:#5a5f66; margin-top:8px; max-width:820px; line-height:1.5;">Same screen, three type pairings (UI sans + the mono that carries every number, date and badge). The current IBM Plex on the left; two alternatives that keep the serious, engineered, financial-instrument character. All three are live and identical apart from type — scan the names, the mono $ figures, and the uppercase badges. Nothing else changed.</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:56px; align-items:flex-start; margin-top:40px;">
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:2px;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Current · IBM Plex</div>
|
||||||
|
<div style="font:400 12px 'IBM Plex Mono'; color:#8a8f96;">IBM Plex Sans + IBM Plex Mono</div>
|
||||||
|
</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#0b1118;">
|
||||||
|
<dc-import name="GridApp" variant="compact" theme="dark" font="plex" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:2px;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Option B · Manrope</div>
|
||||||
|
<div style="font:400 12px 'IBM Plex Mono'; color:#8a8f96;">Manrope + JetBrains Mono</div>
|
||||||
|
</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#0b1118;">
|
||||||
|
<dc-import name="GridApp" variant="compact" theme="dark" font="manrope" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:2px;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Option C · Hanken Grotesk</div>
|
||||||
|
<div style="font:400 12px 'IBM Plex Mono'; color:#8a8f96;">Hanken Grotesk + Spline Sans Mono</div>
|
||||||
|
</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#0b1118;">
|
||||||
|
<dc-import name="GridApp" variant="compact" theme="dark" font="hanken" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-width:100%; min-height:100vh; width:max-content; box-sizing:border-box; padding:48px 56px 64px; background:#e7e5df; display:flex; flex-direction:column;">
|
||||||
|
<div style="font:700 22px 'IBM Plex Sans'; color:#161b22; letter-spacing:-0.01em;">Ten31 CRM — Fundraising Grid, mobile</div>
|
||||||
|
<div style="font:400 14px 'IBM Plex Sans'; color:#5a5f66; margin-top:8px; max-width:760px; line-height:1.5;">Mobile-preferred redesign of the system-of-record grid. Same Ten31 language, IBM Plex faces, tinted badges — re-laid-out as a compact card list → full-screen detail with drag-to-dismiss bottom sheets, behind a 4-tab bottom bar. Both phones are fully live (tap a card, open a view, log a note, add an investor) and carry a one-tap theme toggle (◑) in the top bar — shown here in dark and light.</div>
|
||||||
|
<div style="display:flex; gap:56px; align-items:flex-start; margin-top:40px;">
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Dark mode · default</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#0b1118;">
|
||||||
|
<dc-import name="GridApp" variant="compact" theme="dark" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Light mode</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#eaeef3;">
|
||||||
|
<dc-import name="GridApp" variant="compact" theme="light" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,827 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<script src="store.js"></script>
|
||||||
|
<style>
|
||||||
|
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes screenIn { from { transform: translateX(14px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||||
|
.ga-scroll::-webkit-scrollbar { width: 0; height: 0; }
|
||||||
|
.ga-root button, .ga-root input, .ga-root textarea, .ga-root select { font-family: inherit; }
|
||||||
|
.ga-root {
|
||||||
|
--sans:'IBM Plex Sans','Segoe UI',sans-serif; --mono:'IBM Plex Mono',monospace;
|
||||||
|
--grad1:#1a3c5e44; --grad2:#27496b33;
|
||||||
|
--base:#0b1118; --panel:#111a27; --elev:#152233; --input:#0d1622; --hover:#1b2a3a;
|
||||||
|
--border:#263548; --bstrong:#35506a; --divider:#1c2735;
|
||||||
|
--t1:#e5edf5; --t2:#c7d3e0; --t3:#8ea2b7; --t4:#70859b;
|
||||||
|
--accent:#3b82c4; --accentlight:#93c5fd; --danger:#e06c6c; --money:#6ee7b7;
|
||||||
|
--shadow-card:0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
|
||||||
|
--nav-bg:#0d1622cc;
|
||||||
|
}
|
||||||
|
.ga-root[data-theme="light"] {
|
||||||
|
--grad1:#3b82c41c; --grad2:#27496b10;
|
||||||
|
--base:#eaeef3; --panel:#ffffff; --elev:#f4f7fb; --input:#eef2f7; --hover:#e6ecf4;
|
||||||
|
--border:#d6dde7; --bstrong:#b6c3d4; --divider:#e8edf3;
|
||||||
|
--t1:#16202c; --t2:#33414f; --t3:#5a6b7d; --t4:#84909e;
|
||||||
|
--accent:#3b82c4; --accentlight:#1f6fb8; --danger:#c0322f; --money:#057a55;
|
||||||
|
--shadow-card:0 8px 20px rgba(40,70,110,0.10), inset 0 1px 0 #ffffff;
|
||||||
|
--nav-bg:#ffffffd9;
|
||||||
|
}
|
||||||
|
.ga-root[data-font="manrope"] { --sans:'Manrope','Segoe UI',sans-serif; --mono:'JetBrains Mono',monospace; }
|
||||||
|
.ga-root[data-font="hanken"] { --sans:'Hanken Grotesk','Segoe UI',sans-serif; --mono:'Spline Sans Mono',monospace; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div class="ga-root" data-theme="{{ themeAttr }}" data-font="{{ fontAttr }}" style="position:absolute; inset:0; background:radial-gradient(900px 460px at 12% -8%, var(--grad1), transparent 60%), radial-gradient(760px 380px at 92% -2%, var(--grad2), transparent 58%), var(--base); display:flex; flex-direction:column; font-family:var(--sans); color:var(--t1); letter-spacing:0.01em; overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- status bar -->
|
||||||
|
<div style="height:46px; flex:none; display:flex; align-items:flex-end; justify-content:space-between; padding:0 24px 6px; font-family:var(--mono); font-size:13px; color:var(--t2);">
|
||||||
|
<span>9:41</span>
|
||||||
|
<span style="display:flex; gap:6px; align-items:center; font-size:11px; letter-spacing:0.02em;">5G ▮▮▮▯ 84%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- top bar -->
|
||||||
|
<div style="flex:none; height:52px; display:flex; align-items:center; justify-content:space-between; padding:0 16px; border-bottom:1px solid var(--border);">
|
||||||
|
<span style="font-family:var(--mono); font-weight:600; font-size:15px; letter-spacing:0.04em; color:var(--t1);">·Ten31·</span>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<button onClick="{{ openQuickLog }}" aria-label="Log communication" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--border); background:var(--elev); color:var(--t3); cursor:pointer; display:flex; align-items:center; justify-content:center;">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<button onClick="{{ toggleTheme }}" aria-label="Toggle theme" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--border); background:var(--elev); color:var(--t3); font-size:15px; cursor:pointer; display:flex; align-items:center; justify-content:center; line-height:1;">{{ themeIcon }}</button>
|
||||||
|
<button onClick="{{ toggleAccount }}" aria-label="Account" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--bstrong); background:var(--elev); color:var(--accentlight); font-family:var(--mono); font-weight:600; font-size:13px; cursor:pointer; display:flex; align-items:center; justify-content:center;">GG</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- main scroll area -->
|
||||||
|
<div class="ga-scroll" style="flex:1; min-height:0; overflow-y:auto; overflow-x:hidden;">
|
||||||
|
|
||||||
|
<sc-if value="{{ tabGrid }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="padding:14px 16px 24px;">
|
||||||
|
<button onClick="{{ openViewSheet }}" style="width:100%; text-align:left; background:none; border:none; padding:0; cursor:pointer; display:flex; align-items:center; gap:8px; color:var(--t1);">
|
||||||
|
<span style="font-size:21px; font-weight:600; letter-spacing:-0.01em;">{{ view }}</span>
|
||||||
|
<span style="color:var(--t3); font-size:13px; transform:translateY(1px);">▾</span>
|
||||||
|
</button>
|
||||||
|
<div style="margin-top:5px; display:flex; align-items:center; justify-content:space-between; gap:10px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">{{ listCountLabel }}</span>
|
||||||
|
<button onClick="{{ openSortSheet }}" style="flex:none; display:flex; align-items:center; gap:6px; height:30px; padding:0 12px; border-radius:999px; border:1px solid var(--border); background:var(--input); color:var(--t2); font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; cursor:pointer;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h11M3 12h7M3 18h4"></path><path d="M18 8v9m0 0 3-3m-3 3-3-3"></path></svg>
|
||||||
|
{{ sortLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; margin-top:14px;">
|
||||||
|
<input value="{{ search }}" onInput="{{ onSearch }}" placeholder="Filter investors, contacts…" style="flex:1; min-width:0; height:44px; background:var(--input); border:1px solid var(--border); border-radius:8px; color:var(--t1); font-family:var(--sans); font-size:15px; padding:0 14px; outline:none;" />
|
||||||
|
<button onClick="{{ openCreate }}" aria-label="Add investor" style="width:44px; height:44px; flex:none; border-radius:8px; border:none; background:linear-gradient(#3b82c4,#2f6ea9); color:#fff; font-size:22px; font-weight:500; line-height:1; cursor:pointer; box-shadow:0 6px 14px rgba(12,40,68,0.35);">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px; margin-top:16px;">
|
||||||
|
<sc-for list="{{ cards }}" as="c" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ c.open }}" style="position:relative; overflow:hidden; text-align:left; cursor:pointer; width:100%; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:12px 14px; box-shadow:var(--shadow-card); display:flex; flex-direction:column; gap:8px; opacity:{{ c.opacity }};">
|
||||||
|
<sc-if value="{{ c.lpBanner }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="position:absolute; top:0; left:0; right:0; height:5px; background:var(--accent);"></span>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ c.lpEarmark }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="position:absolute; top:0; left:0; width:0; height:0; border-top:18px solid var(--accent); border-right:18px solid transparent;"></span>
|
||||||
|
</sc-if>
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:10px;">
|
||||||
|
<span style="display:flex; align-items:center; gap:7px; min-width:0;">
|
||||||
|
<sc-if value="{{ c.lpStar }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="flex:none; color:var(--accent); font-size:13px; line-height:1;" title="Existing LP">★</span>
|
||||||
|
</sc-if>
|
||||||
|
<span style="font-size:16px; font-weight:600; color:var(--t1); line-height:1.25; overflow:hidden; text-overflow:ellipsis;">{{ c.name }}</span>
|
||||||
|
</span>
|
||||||
|
<sc-if value="{{ c.priority }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="flex:none; font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.06em; text-transform:uppercase; padding:3px 7px; border-radius:4px; background:{{ priBg }}; color:{{ priText }};">Priority</span>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
|
||||||
|
<span style="font-family:var(--mono); font-size:15px; font-weight:600; color:{{ c.amtColor }};">{{ c.amount }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; padding:3px 8px; border-radius:999px; background:{{ c.stageBg }}; color:{{ c.stageText }}; border:1px solid {{ c.stageBorder }};">{{ c.stage }}</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:{{ c.lastColor }};">{{ c.last }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<sc-if value="{{ listEmpty }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:48px 20px; text-align:center; color:var(--t4); font-size:14px;">No investors match this view.</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<sc-if value="{{ tabOther }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:64px 28px; display:flex; flex-direction:column; align-items:center; text-align:center; gap:14px;">
|
||||||
|
<div style="width:54px; height:54px; border-radius:14px; border:1px solid var(--border); background:var(--panel); display:flex; align-items:center; justify-content:center; color:var(--accent); font-size:24px;">{{ otherIcon }}</div>
|
||||||
|
<div style="font-size:18px; font-weight:600; color:var(--t1);">{{ otherTitle }}</div>
|
||||||
|
<div style="font-size:14px; color:var(--t3); line-height:1.5; max-width:240px;">This surface is part of the mobile set — designed next, after the Grid is signed off.</div>
|
||||||
|
<button onClick="{{ goGrid }}" style="margin-top:6px; height:42px; padding:0 18px; border-radius:8px; border:1px solid var(--bstrong); background:var(--elev); color:var(--t2); font-size:14px; font-weight:500; cursor:pointer;">Back to Grid</button>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- bottom tab bar -->
|
||||||
|
<div style="flex:none; display:flex; border-top:1px solid var(--border); background:var(--nav-bg); backdrop-filter:blur(8px); padding-bottom:18px;">
|
||||||
|
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="4">
|
||||||
|
<button onClick="{{ t.go }}" style="flex:1; background:none; border:none; cursor:pointer; height:56px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px; color:{{ t.color }};">
|
||||||
|
<span style="width:20px; height:20px; display:flex; align-items:center; justify-content:center;">{{ t.icon }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.04em;">{{ t.label }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- account menu -->
|
||||||
|
<sc-if value="{{ accountMenu }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeAccount }}" style="position:absolute; inset:0; z-index:40; animation:fadeIn 120ms ease;">
|
||||||
|
<div style="position:absolute; top:96px; right:16px; width:208px; background:var(--elev); border:1px solid var(--bstrong); border-radius:10px; box-shadow:0 24px 56px rgba(1,8,17,0.5); overflow:hidden;">
|
||||||
|
<div style="padding:14px 16px; border-bottom:1px solid var(--border);">
|
||||||
|
<div style="font-size:14px; font-weight:600; color:var(--t1);">Grant Gilliam</div>
|
||||||
|
<div style="font-size:12px; color:var(--t3); margin-top:2px;">grant@ten31.xyz</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px;">
|
||||||
|
<div style="padding:11px 12px; border-radius:7px; font-size:14px; color:var(--t2);">Profile</div>
|
||||||
|
<div style="padding:11px 12px; border-radius:7px; font-size:14px; color:var(--danger);">Log out</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- view picker sheet -->
|
||||||
|
<sc-if value="{{ viewSheet }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeViewSheet }}" style="position:absolute; inset:0; z-index:50; background:rgba(4,9,16,0.55); animation:fadeIn 150ms ease; display:flex; flex-direction:column; justify-content:flex-end;">
|
||||||
|
<div onClick="{{ stop }}" style="background:var(--panel); border-top:1px solid var(--bstrong); border-radius:20px 20px 0 0; box-shadow:0 -24px 56px rgba(1,8,17,0.4); animation:sheetUp 280ms cubic-bezier(.2,.8,.2,1); padding-bottom:24px; max-height:80%; display:flex; flex-direction:column;">
|
||||||
|
<div style="padding:10px 0 4px; display:flex; justify-content:center;"><div style="width:38px; height:4px; border-radius:999px; background:var(--bstrong);"></div></div>
|
||||||
|
<div style="padding:6px 20px 12px; font-size:13px; color:var(--t3); font-weight:500;">Switch view</div>
|
||||||
|
<div style="overflow-y:auto;">
|
||||||
|
<sc-for list="{{ viewList }}" as="v" hint-placeholder-count="5">
|
||||||
|
<button onClick="{{ v.pick }}" style="width:100%; text-align:left; background:none; border:none; cursor:pointer; padding:15px 20px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-top:1px solid var(--divider); color:{{ v.color }};">
|
||||||
|
<span style="font-size:16px; font-weight:{{ v.weight }};">{{ v.name }}</span>
|
||||||
|
<span style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">{{ v.count }}</span>
|
||||||
|
<span style="color:var(--accent); font-size:14px; width:14px;">{{ v.check }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- investor detail -->
|
||||||
|
<sc-if value="{{ detailOpen }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="position:absolute; inset:0; z-index:30; background:radial-gradient(900px 460px at 12% -8%, var(--grad1), transparent 60%), var(--base); display:flex; flex-direction:column; animation:screenIn 200ms ease;">
|
||||||
|
<div style="flex:none; height:46px;"></div>
|
||||||
|
<div style="flex:none; display:flex; align-items:center; gap:6px; padding:6px 8px 10px; border-bottom:1px solid var(--border);">
|
||||||
|
<button onClick="{{ closeDetail }}" style="height:40px; padding:0 10px; background:none; border:none; color:var(--accentlight); font-size:15px; cursor:pointer; display:flex; align-items:center; gap:4px;">‹ Grid</button>
|
||||||
|
</div>
|
||||||
|
<div class="ga-scroll" style="flex:1; min-height:0; overflow-y:auto; padding:18px 16px 32px;">
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px;">
|
||||||
|
<div style="font-size:22px; font-weight:600; line-height:1.2; color:var(--t1); min-width:0;">{{ inv.name }}</div>
|
||||||
|
<button onClick="{{ editName }}" style="flex:none; height:34px; padding:0 12px; border-radius:7px; border:1px solid var(--bstrong); background:var(--elev); color:var(--t2); font-size:13px; cursor:pointer;">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:10px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
|
||||||
|
<sc-if value="{{ inv.priority }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:3px 8px; border-radius:4px; background:{{ priBg }}; color:{{ priText }};">Priority</span>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ inv.existing }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:3px 8px; border-radius:4px; background:#3b82c422; color:var(--accentlight);">Existing LP</span>
|
||||||
|
</sc-if>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:{{ inv.lastColor }};">Last contact {{ inv.lastText }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:22px;">
|
||||||
|
<div style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3); margin-bottom:10px;">Pipeline stage</div>
|
||||||
|
<button onClick="{{ editStage }}" style="width:100%; text-align:left; cursor:pointer; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px 16px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--t1);">
|
||||||
|
<span style="display:flex; align-items:center; gap:10px; min-width:0;">
|
||||||
|
<span style="flex:none; font-family:var(--mono); font-size:13px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; padding:4px 10px; border-radius:999px; background:{{ inv.stageBg }}; color:{{ inv.stageText }}; border:1px solid {{ inv.stageBorder }};">{{ inv.stage }}</span>
|
||||||
|
<sc-if value="{{ inv.notLinked }}" hint-placeholder-val="{{ false }}"><span style="font-size:12px; color:var(--t4);">not in pipeline yet</span></sc-if>
|
||||||
|
</span>
|
||||||
|
<span style="color:var(--t3); font-size:13px; flex:none;">Change ›</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:22px;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3);">Contacts</span>
|
||||||
|
<button onClick="{{ addContact }}" style="background:none; border:none; color:var(--accentlight); font-size:13px; cursor:pointer;">+ Add</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<sc-for list="{{ inv.contacts }}" as="ct" hint-placeholder-count="1">
|
||||||
|
<button onClick="{{ ct.edit }}" style="text-align:left; cursor:pointer; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:13px 16px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--t1);">
|
||||||
|
<span style="display:flex; flex-direction:column; gap:3px; min-width:0;">
|
||||||
|
<span style="font-size:15px; font-weight:500;">{{ ct.name }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:var(--t3); overflow:hidden; text-overflow:ellipsis;">{{ ct.email }}</span>
|
||||||
|
</span>
|
||||||
|
<span style="color:var(--t3); font-size:13px; flex:none;">›</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
<sc-if value="{{ inv.noContacts }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="font-size:13px; color:var(--t4); padding:2px 2px 4px;">No contacts yet — add one to enable pipeline linking.</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:22px;">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3);">Commitments</span>
|
||||||
|
<span style="font-size:10px; font-family:var(--mono); color:var(--t4); border:1px solid var(--border); border-radius:4px; padding:2px 6px;">read-only</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--panel); border:1px solid var(--border); border-radius:10px; overflow:hidden;">
|
||||||
|
<sc-for list="{{ inv.funds }}" as="f" hint-placeholder-count="3">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-top:1px solid var(--divider);">
|
||||||
|
<span style="font-size:13px; color:var(--t2);">{{ f.name }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:14px; font-weight:600; color:{{ f.color }};">{{ f.amt }}</span>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; padding:13px 16px; border-top:1px solid var(--border); background:var(--input);">
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; letter-spacing:0.06em; text-transform:uppercase; color:var(--t3);">Total invested</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:15px; font-weight:600; color:var(--money);">{{ inv.total }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:22px;">
|
||||||
|
<div style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3); margin-bottom:10px;">Reminder</div>
|
||||||
|
<button onClick="{{ editReminder }}" style="width:100%; text-align:left; cursor:pointer; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px 16px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:var(--t1);">
|
||||||
|
<sc-if value="{{ inv.hasReminder }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<span style="display:flex; flex-direction:column; gap:3px;">
|
||||||
|
<span style="font-size:14px;">{{ inv.reminderNote }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:{{ inv.reminderColor }};">Due {{ inv.reminderDate }}</span>
|
||||||
|
</span>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ inv.noReminder }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<span style="font-size:14px; color:var(--t3);">No reminder set</span>
|
||||||
|
</sc-if>
|
||||||
|
<span style="color:var(--t3); font-size:13px;">›</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:22px;">
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3);">Notes / communication</span>
|
||||||
|
<button onClick="{{ logNote }}" style="background:none; border:none; color:var(--accentlight); font-size:13px; cursor:pointer;">+ Log</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column;">
|
||||||
|
<sc-for list="{{ inv.notes }}" as="n" hint-placeholder-count="2">
|
||||||
|
<div style="display:flex; gap:12px; padding-bottom:16px;">
|
||||||
|
<div style="flex:none; display:flex; flex-direction:column; align-items:center; gap:4px;">
|
||||||
|
<span style="width:9px; height:9px; border-radius:999px; background:var(--accent); margin-top:4px;"></span>
|
||||||
|
<span style="flex:1; width:1px; background:var(--border);"></span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:2px 6px; border-radius:4px; background:{{ n.tagBg }}; color:{{ n.tagText }};">{{ n.type }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; color:var(--t4);">{{ n.date }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:14px; color:var(--t2); margin-top:6px; line-height:1.45;">{{ n.summary }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<sc-if value="{{ inv.noNotes }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="font-size:13px; color:var(--t4); padding:4px 0 8px;">No activity logged yet.</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- generic edit sheet -->
|
||||||
|
<sc-if value="{{ sheetOpen }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeSheet }}" style="position:absolute; inset:0; z-index:60; background:rgba(4,9,16,0.55); animation:fadeIn 150ms ease; display:flex; flex-direction:column; justify-content:flex-end;">
|
||||||
|
<div onClick="{{ stop }}" style="background:var(--panel); border-top:1px solid var(--bstrong); border-radius:20px 20px 0 0; box-shadow:0 -24px 56px rgba(1,8,17,0.4); animation:sheetUp 280ms cubic-bezier(.2,.8,.2,1); padding:0 20px 26px; max-height:88%; display:flex; flex-direction:column;">
|
||||||
|
<div style="padding:10px 0 4px; display:flex; justify-content:center; flex:none;"><div style="width:38px; height:4px; border-radius:999px; background:var(--bstrong);"></div></div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; padding:8px 0 16px; flex:none;">
|
||||||
|
<span style="font-size:18px; font-weight:600; color:var(--t1);">{{ sheetTitle }}</span>
|
||||||
|
<button onClick="{{ closeSheet }}" style="background:none; border:none; color:var(--t3); font-size:22px; cursor:pointer; line-height:1; padding:0 4px;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="ga-scroll" style="overflow-y:auto;">
|
||||||
|
{{ sheetBody }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- toast -->
|
||||||
|
<sc-if value="{{ toast }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="position:absolute; left:16px; right:16px; bottom:92px; z-index:70; background:var(--elev); border:1px solid var(--bstrong); border-radius:10px; box-shadow:0 10px 24px rgba(4,12,22,0.35); padding:13px 16px; font-size:14px; color:var(--t1); display:flex; align-items:center; gap:10px; animation:fadeIn 150ms ease;">
|
||||||
|
<span style="color:var(--money);">✓</span>{{ toast }}
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":393,"height":812},"variant":{"editor":"enum","options":["compact","roomy"],"default":"compact","tsType":"'compact'|'roomy'"},"theme":{"editor":"enum","options":["dark","light"],"default":"dark","tsType":"'dark'|'light'"},"font":{"editor":"enum","options":["plex","manrope","hanken"],"default":"plex","tsType":"'plex'|'manrope'|'hanken'"},"lpFlag":{"editor":"enum","options":["star","earmark","banner"],"default":"earmark","tsType":"'star'|'earmark'|'banner'"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const focus = window.T31Store ? window.T31Store.focusInvestorId : null;
|
||||||
|
if (window.T31Store) window.T31Store.focusInvestorId = null;
|
||||||
|
this.state = {
|
||||||
|
theme: props.theme === 'light' ? 'light' : 'dark',
|
||||||
|
tab: 'grid',
|
||||||
|
view: 'Main Fundraising',
|
||||||
|
search: '',
|
||||||
|
viewSheet: false,
|
||||||
|
accountMenu: false,
|
||||||
|
sortKey: 'name',
|
||||||
|
detailId: focus || null,
|
||||||
|
sheet: null,
|
||||||
|
toast: null,
|
||||||
|
investors: this.seed(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() { if (window.T31Store) this._unsub = window.T31Store.subscribe(() => this.forceUpdate()); }
|
||||||
|
componentWillUnmount() { if (this._unsub) this._unsub(); }
|
||||||
|
|
||||||
|
seed() {
|
||||||
|
const C = (name, email) => ({ name, email });
|
||||||
|
// daysAgo derives from server last_activity_at; priority is a disposition flag.
|
||||||
|
return [
|
||||||
|
{ id: 1, name: 'Northwall Capital', priority: true, stage: 'commitment', daysAgo: 2,
|
||||||
|
contacts: [C('Dana Reyes', 'dana@northwall.com'), C('Per Holt', 'per@northwall.com')],
|
||||||
|
funds: [['Ten31 Terahash', 1500000], ['Sats and Stats', 600000], ['Join the Fold', 400000]],
|
||||||
|
reminder: { date: 'Jun 24', note: 'Send Q2 update deck' }, views: ['Main Fundraising', 'All Investors'],
|
||||||
|
notes: [ ['Email', 'Confirmed $2.5M allocation across funds', '2026-06-17'], ['Meeting', 'DD call — covered redemption terms', '2026-06-10'] ] },
|
||||||
|
{ id: 2, name: 'Brightseed Partners', priority: true, stage: 'engaged', daysAgo: 5,
|
||||||
|
contacts: [C('Omar Said', 'omar@brightseed.vc')], funds: [['Ten31 Terahash', 0]],
|
||||||
|
reminder: { date: 'Jun 20', note: 'Follow up after intro call' }, views: ['Main Fundraising', 'Follow-up List'],
|
||||||
|
notes: [ ['Note', 'Intro from Polaris — warm', '2026-06-14'] ] },
|
||||||
|
{ id: 3, name: 'Cedarline Family Office', priority: false, stage: 'commitment', daysAgo: 7,
|
||||||
|
contacts: [C('Lena Cho', 'lena@cedarline.com')], funds: [['Ten31 Terahash', 800000], ['Pawn to F4', 400000]],
|
||||||
|
reminder: null, views: ['Main Fundraising', 'All Investors', 'Fund II investors'],
|
||||||
|
notes: [ ['Call', 'Wire received, fully funded', '2026-06-12'] ] },
|
||||||
|
{ id: 4, name: 'Vance & Co', priority: false, stage: 'engaged', daysAgo: 3,
|
||||||
|
contacts: [C('Marcus Vance', 'mv@vanceco.com')], funds: [['Ten31 Terahash', 0]],
|
||||||
|
reminder: { date: 'Jun 19', note: 'Resend deck — bounced' }, views: ['Main Fundraising', 'Follow-up List'],
|
||||||
|
notes: [] },
|
||||||
|
{ id: 5, name: 'Polaris Endowment', priority: true, stage: 'diligence', daysAgo: 1,
|
||||||
|
contacts: [C('Ruth Almeida', 'ralmeida@polaris.org')], funds: [['Ten31 Terahash', 3000000], ['Sats and Stats', 2000000]],
|
||||||
|
reminder: { date: 'Jun 21', note: 'IC memo due' }, views: ['Main Fundraising', 'All Investors', 'Follow-up List', 'Fund II investors'],
|
||||||
|
notes: [ ['Meeting', 'IC presentation went well', '2026-06-18'], ['Email', 'Sent data room access', '2026-06-15'] ] },
|
||||||
|
{ id: 6, name: 'Hartman Group', priority: false, stage: null, daysAgo: 14,
|
||||||
|
contacts: [], funds: [['Ten31 Terahash', 0]],
|
||||||
|
reminder: null, views: ['Main Fundraising'], notes: [] },
|
||||||
|
{ id: 7, name: 'Meridian Trust', priority: false, stage: 'commitment', daysAgo: 4,
|
||||||
|
contacts: [C('Sofia Marin', 'sofia@meridiantrust.com')], funds: [['Ten31 Terahash', 800000]],
|
||||||
|
reminder: null, views: ['Main Fundraising', 'All Investors'],
|
||||||
|
notes: [ ['Note', 'Signed side letter', '2026-06-14'] ] },
|
||||||
|
{ id: 8, name: 'Atlas Ventures Fund', priority: false, stage: 'engaged', daysAgo: 6,
|
||||||
|
contacts: [C('Will Tanaka', 'will@atlasvf.com')], funds: [['Ten31 Terahash', 0]],
|
||||||
|
reminder: null, views: ['Main Fundraising'], notes: [] },
|
||||||
|
{ id: 9, name: 'K. Whitfield', priority: false, stage: null, daysAgo: 21,
|
||||||
|
contacts: [C('Kira Whitfield', 'kira@whitfield.io')], funds: [],
|
||||||
|
reminder: null, views: ['Graveyard'], notes: [ ['Note', 'No allocation — parked', '2026-05-28'] ] },
|
||||||
|
{ id: 10, name: 'Granite Bay LP', priority: false, stage: 'commitment', daysAgo: 30,
|
||||||
|
contacts: [C('Tom Becker', 'tom@granitebay.com')], funds: [['Ten31 Terahash', 2000000], ['Sats and Stats', 1300000]],
|
||||||
|
reminder: null, views: ['Main Fundraising', 'All Investors', 'Fund II investors'], notes: [] },
|
||||||
|
{ id: 11, name: 'Forsythe Holdings', priority: false, stage: 'lead', daysAgo: 35,
|
||||||
|
contacts: [], funds: [], reminder: null, views: ['Graveyard'], notes: [] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
themePalette(theme) {
|
||||||
|
if (theme === 'light') return {
|
||||||
|
base: '#eaeef3', panel: '#ffffff', elev: '#f4f7fb', input: '#eef2f7', hover: '#e6ecf4',
|
||||||
|
border: '#d6dde7', bstrong: '#b6c3d4', divider: '#e8edf3',
|
||||||
|
t1: '#16202c', t2: '#33414f', t3: '#5a6b7d', t4: '#84909e', accentlight: '#1f6fb8', danger: '#c0322f', money: '#057a55' };
|
||||||
|
return { base: '#0b1118', panel: '#111a27', elev: '#152233', input: '#0d1622', hover: '#1b2a3a',
|
||||||
|
border: '#263548', bstrong: '#35506a', divider: '#1c2735',
|
||||||
|
t1: '#e5edf5', t2: '#c7d3e0', t3: '#8ea2b7', t4: '#70859b', accentlight: '#93c5fd', danger: '#e06c6c', money: '#6ee7b7' };
|
||||||
|
}
|
||||||
|
|
||||||
|
priColors(theme) {
|
||||||
|
return theme === 'light' ? { bg: '#e08e0922', text: '#a76a07' } : { bg: '#f59e0b22', text: '#fcd34d' };
|
||||||
|
}
|
||||||
|
stageColors(s, theme) {
|
||||||
|
const light = theme === 'light';
|
||||||
|
const dark = {
|
||||||
|
'lead': { bg: '#70859b22', text: '#8ea2b7', border: '#2635488a' },
|
||||||
|
'engaged': { bg: '#3b82c422', text: '#93c5fd', border: '#3b82c44d' },
|
||||||
|
'diligence': { bg: '#e0b3411f', text: '#e0b341', border: '#e0b3413d' },
|
||||||
|
'commitment': { bg: '#10b9811f', text: '#6ee7b7', border: '#10b9813d' },
|
||||||
|
};
|
||||||
|
const lite = {
|
||||||
|
'lead': { bg: '#5a6b7d14', text: '#5a6b7d', border: '#d6dde7' },
|
||||||
|
'engaged': { bg: '#3b82c416', text: '#2266a0', border: '#bcd2ea' },
|
||||||
|
'diligence': { bg: '#e0b34122', text: '#8a6c12', border: '#e4d29a' },
|
||||||
|
'commitment': { bg: '#10b98118', text: '#057a55', border: '#a9ddca' },
|
||||||
|
};
|
||||||
|
const map = light ? lite : dark;
|
||||||
|
return map[s] || (light ? { bg: '#5a6b7d12', text: '#84909e', border: '#d6dde7' } : { bg: '#1b2a3a', text: '#70859b', border: '#263548' });
|
||||||
|
}
|
||||||
|
// Staleness from one global threshold on days-since-last-activity. Thresholds TBD with team.
|
||||||
|
recency(days, theme) {
|
||||||
|
const AMBER = 10, STALE = 30;
|
||||||
|
const light = theme === 'light';
|
||||||
|
if (days >= STALE) return { text: days + 'd stale', color: light ? '#c0322f' : '#f87171' };
|
||||||
|
if (days >= AMBER) return { text: days + 'd ago', color: light ? '#a76a07' : '#e0b341' };
|
||||||
|
return { text: days + 'd ago', color: light ? '#84909e' : '#70859b' };
|
||||||
|
}
|
||||||
|
noteTag(t, theme) {
|
||||||
|
const light = theme === 'light';
|
||||||
|
const dark = { 'Email': { bg: '#3b82c422', text: '#93c5fd' }, 'Call': { bg: '#10b98122', text: '#6ee7b7' },
|
||||||
|
'Meeting': { bg: '#f59e0b1f', text: '#fcd34d' }, 'Note': { bg: '#1b2a3a', text: '#8ea2b7' } };
|
||||||
|
const lite = { 'Email': { bg: '#3b82c41a', text: '#2266a0' }, 'Call': { bg: '#10b9811a', text: '#057a55' },
|
||||||
|
'Meeting': { bg: '#f59e0b1a', text: '#a76a07' }, 'Note': { bg: '#5a6b7d14', text: '#5a6b7d' } };
|
||||||
|
const map = light ? lite : dark;
|
||||||
|
return map[t] || map['Note'];
|
||||||
|
}
|
||||||
|
dueColor(iso, theme) {
|
||||||
|
const S = window.T31Store; const days = S ? S.diffDays(iso) : 99;
|
||||||
|
if (days < 0) return theme === 'light' ? '#c0322f' : '#f87171';
|
||||||
|
if (days <= 1) return theme === 'light' ? '#8a6c12' : '#e0b341';
|
||||||
|
return theme === 'light' ? '#5a6b7d' : '#8ea2b7';
|
||||||
|
}
|
||||||
|
money(n) {
|
||||||
|
if (!n) return '$0';
|
||||||
|
if (n >= 1e6) return '$' + (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M';
|
||||||
|
if (n >= 1e3) return '$' + Math.round(n / 1e3) + 'K';
|
||||||
|
return '$' + n;
|
||||||
|
}
|
||||||
|
committed(inv) { return (inv.funds || []).reduce((a, f) => a + f[1], 0); }
|
||||||
|
stageLabel(s) { return s || 'no stage'; }
|
||||||
|
viewDefs() { return ['Main Fundraising', 'Follow-up List', 'Graveyard', 'All Investors', 'Fund II investors']; }
|
||||||
|
inView(inv, view) { return (inv.views || []).includes(view); }
|
||||||
|
|
||||||
|
toast(msg) { this.setState({ toast: msg }); clearTimeout(this._tt); this._tt = setTimeout(() => this.setState({ toast: null }), 2200); }
|
||||||
|
sortList(arr, key) {
|
||||||
|
const order = ['lead', 'engaged', 'diligence', 'commitment'];
|
||||||
|
const a = arr.slice();
|
||||||
|
if (key === 'stage') a.sort((x, y) => { const xi = x.stage ? order.indexOf(x.stage) : 99, yi = y.stage ? order.indexOf(y.stage) : 99; return xi - yi || x.name.localeCompare(y.name); });
|
||||||
|
else if (key === 'amount') a.sort((x, y) => this.committed(y) - this.committed(x) || x.name.localeCompare(y.name));
|
||||||
|
else if (key === 'staleness') a.sort((x, y) => y.daysAgo - x.daysAgo || x.name.localeCompare(y.name));
|
||||||
|
else if (key === 'priority') a.sort((x, y) => (y.priority ? 1 : 0) - (x.priority ? 1 : 0) || x.name.localeCompare(y.name));
|
||||||
|
else a.sort((x, y) => x.name.localeCompare(y.name));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
sortLabelFor(key) { return ({ name: 'Name', stage: 'Stage', amount: 'Amount', staleness: 'Staleness', priority: 'Priority' })[key] || 'Name'; }
|
||||||
|
|
||||||
|
updateInv(id, patch) { if (window.T31Store) window.T31Store.updateInvestor(id, patch); }
|
||||||
|
selectedInv() { return window.T31Store ? window.T31Store.investorById(this.state.detailId) : null; }
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const s = this.state;
|
||||||
|
const theme = s.theme;
|
||||||
|
|
||||||
|
const q = s.search.trim().toLowerCase();
|
||||||
|
const all = window.T31Store ? window.T31Store.investors : [];
|
||||||
|
const list = all.filter(i => this.inView(i, s.view)).filter(i => {
|
||||||
|
if (!q) return true;
|
||||||
|
return i.name.toLowerCase().includes(q) || (i.contacts || []).some(c => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
const moneyColor = theme === 'light' ? '#057a55' : '#6ee7b7';
|
||||||
|
const pri = this.priColors(theme);
|
||||||
|
const dimmed = s.view === 'Graveyard';
|
||||||
|
const lpFlag = this.props.lpFlag || 'earmark';
|
||||||
|
const cards = this.sortList(list, s.sortKey).map(i => {
|
||||||
|
const sc = this.stageColors(i.stage, theme);
|
||||||
|
const amt = this.committed(i);
|
||||||
|
const rec = this.recency(i.daysAgo, theme);
|
||||||
|
const existing = amt > 0;
|
||||||
|
return {
|
||||||
|
name: i.name,
|
||||||
|
existing: existing, priority: !!i.priority,
|
||||||
|
lpStar: existing && lpFlag === 'star',
|
||||||
|
lpEarmark: existing && lpFlag === 'earmark',
|
||||||
|
lpBanner: existing && lpFlag === 'banner',
|
||||||
|
amount: this.money(amt), amtColor: amt > 0 ? moneyColor : (theme === 'light' ? '#84909e' : '#70859b'),
|
||||||
|
stage: this.stageLabel(i.stage), stageBg: sc.bg, stageText: sc.text, stageBorder: sc.border,
|
||||||
|
last: rec.text, lastColor: rec.color,
|
||||||
|
opacity: dimmed ? '0.55' : '1',
|
||||||
|
open: () => this.setState({ detailId: i.id }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewList = this.viewDefs().map(name => ({
|
||||||
|
name, count: String(all.filter(i => this.inView(i, name)).length),
|
||||||
|
color: name === s.view ? 'var(--t1)' : 'var(--t2)',
|
||||||
|
weight: name === s.view ? 600 : 400,
|
||||||
|
check: name === s.view ? '✓' : '',
|
||||||
|
pick: () => this.setState({ view: name, viewSheet: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'grid', label: 'Grid' }, { key: 'pipeline', label: 'Pipeline' },
|
||||||
|
{ key: 'reminders', label: 'Reminders' }, { key: 'contacts', label: 'Contacts' },
|
||||||
|
].map(t => ({
|
||||||
|
label: t.label, color: t.key === 'grid' ? 'var(--accent)' : 'var(--t4)',
|
||||||
|
icon: this.tabIcon(t.key, t.key === 'grid'),
|
||||||
|
go: () => { if (window.T31Store) window.T31Store.setTab(t.key); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sel = this.selectedInv();
|
||||||
|
let inv = null;
|
||||||
|
if (sel) {
|
||||||
|
const sc = this.stageColors(sel.stage, theme);
|
||||||
|
const selAmt = this.committed(sel);
|
||||||
|
const selRec = this.recency(sel.daysAgo, theme);
|
||||||
|
const rem = window.T31Store ? window.T31Store.reminderFor(sel.id) : null;
|
||||||
|
inv = {
|
||||||
|
name: sel.name, existing: selAmt > 0, priority: !!sel.priority,
|
||||||
|
lastText: selRec.text, lastColor: selRec.color,
|
||||||
|
stage: this.stageLabel(sel.stage), stageBg: sc.bg, stageText: sc.text, stageBorder: sc.border,
|
||||||
|
notLinked: !sel.stage,
|
||||||
|
contacts: sel.contacts.map((c, idx) => ({ name: c.name, email: c.email || 'no email', edit: () => this.openSheet('contact', { idx, name: c.name, email: c.email }) })),
|
||||||
|
noContacts: sel.contacts.length === 0,
|
||||||
|
funds: (sel.funds.length ? sel.funds : [['Ten31 Terahash', 0]]).map(f => ({ name: f[0], amt: this.money(f[1]), color: f[1] > 0 ? moneyColor : (theme === 'light' ? '#84909e' : '#70859b') })),
|
||||||
|
total: this.money(this.committed(sel)),
|
||||||
|
hasReminder: !!rem, noReminder: !rem,
|
||||||
|
reminderNote: rem ? rem.note : '', reminderDate: rem ? window.T31Store.monthDay(rem.due) : '',
|
||||||
|
reminderColor: rem ? this.dueColor(rem.due, theme) : 'var(--t3)',
|
||||||
|
notes: sel.notes.map(n => { const nt = this.noteTag(n[0], theme); return { type: n[0].toUpperCase(), tagBg: nt.bg, tagText: nt.text, date: n[2], summary: n[1] }; }),
|
||||||
|
noNotes: sel.notes.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sheetBody = s.sheet ? this.buildSheet(s.sheet) : null;
|
||||||
|
const tabOther = s.tab !== 'grid';
|
||||||
|
const otherMeta = { pipeline: ['◧', 'Pipeline'], reminders: ['◷', 'Reminders'], contacts: ['◓', 'Contacts'] }[s.tab] || ['', ''];
|
||||||
|
|
||||||
|
return {
|
||||||
|
themeAttr: theme, themeIcon: theme === 'light' ? '☾' : '☀',
|
||||||
|
fontAttr: this.props.font || 'plex',
|
||||||
|
priBg: pri.bg, priText: pri.text,
|
||||||
|
toggleTheme: () => { const t = s.theme === 'light' ? 'dark' : 'light'; if (window.T31Store) window.T31Store.setTheme(t); this.setState({ theme: t }); },
|
||||||
|
view: s.view,
|
||||||
|
listCountLabel: `${list.length} ${list.length === 1 ? 'investor' : 'investors'}`,
|
||||||
|
search: s.search,
|
||||||
|
onSearch: e => this.setState({ search: e.target.value }),
|
||||||
|
openViewSheet: () => this.setState({ viewSheet: true }),
|
||||||
|
closeViewSheet: () => this.setState({ viewSheet: false }),
|
||||||
|
viewSheet: s.viewSheet, viewList,
|
||||||
|
toggleAccount: () => this.setState({ accountMenu: !s.accountMenu }),
|
||||||
|
closeAccount: () => this.setState({ accountMenu: false }),
|
||||||
|
accountMenu: s.accountMenu,
|
||||||
|
openCreate: () => this.openSheet('create', { name: '', cname: '', cemail: '', priority: false, stage: 'lead' }),
|
||||||
|
openSortSheet: () => this.openSheet('sort', {}),
|
||||||
|
sortLabel: this.sortLabelFor(s.sortKey),
|
||||||
|
openQuickLog: () => this.openSheet('quicklog', { q: '', targetId: null, type: 'Note', summary: '', details: '' }),
|
||||||
|
tabs, tabGrid: true, tabOther: false,
|
||||||
|
otherIcon: otherMeta[0], otherTitle: otherMeta[1],
|
||||||
|
goGrid: () => { if (window.T31Store) window.T31Store.setTab('grid'); },
|
||||||
|
cards, listEmpty: cards.length === 0,
|
||||||
|
detailOpen: !!sel, inv,
|
||||||
|
closeDetail: () => this.setState({ detailId: null }),
|
||||||
|
editName: () => this.openSheet('name', { name: sel.name }),
|
||||||
|
editStage: () => this.openSheet('stage', { stage: sel.stage, linked: !!sel.stage }),
|
||||||
|
addContact: () => this.openSheet('contact', { idx: -1, name: '', email: '' }),
|
||||||
|
editReminder: () => { const rm = window.T31Store ? window.T31Store.reminderFor(sel.id) : null; this.openSheet('reminder', { rid: rm ? rm.id : null, date: rm ? rm.due : '', note: rm ? rm.note : '' }); },
|
||||||
|
logNote: () => this.openSheet('note', { type: 'Note', summary: '', details: '' }),
|
||||||
|
sheetOpen: !!s.sheet, sheetTitle: s.sheet ? s.sheet._title : '', sheetBody,
|
||||||
|
closeSheet: () => this.setState({ sheet: null }),
|
||||||
|
stop: e => e.stopPropagation(),
|
||||||
|
toast: s.toast,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tabIcon(key, active) {
|
||||||
|
const c = active ? '#3b82c4' : (this.state.theme === 'light' ? '#84909e' : '#70859b');
|
||||||
|
const mk = (children) => React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 20 20', fill: 'none' }, children);
|
||||||
|
const r = (p) => React.createElement('rect', p);
|
||||||
|
const ln = (p) => React.createElement('line', Object.assign({}, p, { stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }));
|
||||||
|
if (key === 'grid') return mk([
|
||||||
|
r({ key: 1, x: 3, y: 3, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 2, x: 11, y: 3, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 3, x: 3, y: 11, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 4, x: 11, y: 11, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
]);
|
||||||
|
if (key === 'pipeline') return mk([
|
||||||
|
r({ key: 1, x: 3, y: 3, width: 4.5, height: 14, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 2, x: 9.25, y: 3, width: 4.5, height: 10, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 3, x: 15.5, y: 3, width: 1.5, height: 6, rx: 0.7, fill: c }),
|
||||||
|
]);
|
||||||
|
if (key === 'reminders') return mk([
|
||||||
|
React.createElement('circle', { key: 1, cx: 10, cy: 11, r: 6.2, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
ln({ key: 2, x1: 10, y1: 11, x2: 10, y2: 7.5 }),
|
||||||
|
ln({ key: 3, x1: 10, y1: 11, x2: 12.4, y2: 12 }),
|
||||||
|
ln({ key: 4, x1: 7, y1: 3.4, x2: 4.4, y2: 5.4 }),
|
||||||
|
ln({ key: 5, x1: 13, y1: 3.4, x2: 15.6, y2: 5.4 }),
|
||||||
|
]);
|
||||||
|
return mk([
|
||||||
|
React.createElement('circle', { key: 1, cx: 10, cy: 7, r: 3.2, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
React.createElement('path', { key: 2, d: 'M4 16.5c0-3 2.7-4.8 6-4.8s6 1.8 6 4.8', stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
openSheet(kind, draft) {
|
||||||
|
const titles = { name: 'Edit investor name', contact: draft.idx === -1 ? 'Add contact' : 'Edit contact',
|
||||||
|
note: 'Log communication', stage: 'Pipeline stage', reminder: 'Set reminder', create: 'New investor', quicklog: 'Log communication', sort: 'Sort investors' };
|
||||||
|
this.setState({ sheet: Object.assign({ kind: kind, _title: titles[kind] }, draft) });
|
||||||
|
}
|
||||||
|
setDraft(patch) { this.setState(s => ({ sheet: Object.assign({}, s.sheet, patch) })); }
|
||||||
|
|
||||||
|
buildSheet(sh) {
|
||||||
|
const h = React.createElement;
|
||||||
|
const sel = this.selectedInv();
|
||||||
|
const p = this.themePalette(this.state.theme);
|
||||||
|
const theme = this.state.theme;
|
||||||
|
const label = (t) => h('div', { style: { fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase', color: p.t3, margin: '14px 0 8px' } }, t);
|
||||||
|
const inputStyle = { width: '100%', height: 46, background: p.input, border: '1px solid ' + p.border, borderRadius: 8, color: p.t1, fontFamily: 'var(--sans)', fontSize: 15, padding: '0 14px', outline: 'none', boxSizing: 'border-box' };
|
||||||
|
const areaStyle = Object.assign({}, inputStyle, { height: 96, padding: '12px 14px', resize: 'none', lineHeight: 1.45 });
|
||||||
|
const help = (t) => h('div', { style: { fontSize: 12, color: p.t4, marginTop: 7, lineHeight: 1.45 } }, t);
|
||||||
|
const primaryBtn = (txt, onClick, disabled) => h('button', { onClick, disabled, style: { width: '100%', height: 48, marginTop: 22, borderRadius: 8, border: 'none', background: disabled ? p.elev : 'linear-gradient(#3b82c4,#2f6ea9)', color: disabled ? p.t4 : '#fff', fontSize: 15, fontWeight: 600, cursor: disabled ? 'default' : 'pointer', fontFamily: 'var(--sans)', boxShadow: disabled ? 'none' : '0 6px 14px rgba(12,40,68,0.35)' } }, txt);
|
||||||
|
|
||||||
|
if (sh.kind === 'sort') {
|
||||||
|
const opts = [['name', 'Name', 'A → Z'], ['stage', 'Pipeline stage', 'Lead → Commitment'], ['amount', 'Committed', 'Most first'], ['staleness', 'Last contact', 'Most stale first'], ['priority', 'Priority', 'Flagged first']];
|
||||||
|
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } }, opts.map(o => {
|
||||||
|
const on = this.state.sortKey === o[0];
|
||||||
|
return h('button', { key: o[0], onClick: () => this.setState({ sortKey: o[0], sheet: null }), style: { width: '100%', textAlign: 'left', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, minHeight: 52, padding: '0 15px', borderRadius: 10, border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? p.elev : p.input } },
|
||||||
|
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 2 } },
|
||||||
|
h('span', { style: { fontSize: 15, fontWeight: 500, color: p.t1 } }, o[1]),
|
||||||
|
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 11, color: p.t4 } }, o[2])),
|
||||||
|
on ? h('span', { style: { color: 'var(--accent)', fontSize: 15 } }, '\u2713') : null);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sh.kind === 'name') {
|
||||||
|
return h('div', null,
|
||||||
|
label('Investor name'),
|
||||||
|
h('input', { value: sh.name, onChange: e => this.setDraft({ name: e.target.value }), style: inputStyle, autoFocus: true }),
|
||||||
|
help('Writes a single-row update — no full-grid save, no version race.'),
|
||||||
|
primaryBtn('Save name', () => { this.updateInv(sel.id, { name: sh.name }); this.setState({ sheet: null }); this.toast('Investor name updated'); }, !sh.name.trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sh.kind === 'contact') {
|
||||||
|
const isNew = sh.idx === -1;
|
||||||
|
return h('div', null,
|
||||||
|
label('Name'),
|
||||||
|
h('input', { value: sh.name, onChange: e => this.setDraft({ name: e.target.value }), style: inputStyle, placeholder: 'Contact name', autoFocus: true }),
|
||||||
|
label('Email'),
|
||||||
|
h('input', { value: sh.email, onChange: e => this.setDraft({ email: e.target.value }), style: Object.assign({}, inputStyle, { fontFamily: 'var(--mono)', fontSize: 14 }), placeholder: 'name@firm.com', inputMode: 'email' }),
|
||||||
|
help(isNew ? 'Adds a contact pill to this investor row.' : 'Editing the contact pill. Removing a pill has no undo — the grid blob is canonical.'),
|
||||||
|
h('div', { style: { display: 'flex', gap: 10, marginTop: 22 } },
|
||||||
|
!isNew ? h('button', { onClick: () => { const cs = sel.contacts.filter((_, i) => i !== sh.idx); this.updateInv(sel.id, { contacts: cs }); this.setState({ sheet: null }); this.toast('Contact removed'); }, style: { height: 48, padding: '0 16px', borderRadius: 8, border: '1px solid ' + p.danger, background: 'transparent', color: p.danger, fontSize: 14, fontWeight: 500, cursor: 'pointer', flex: 'none' } }, 'Remove') : null,
|
||||||
|
h('button', { onClick: () => {
|
||||||
|
let cs = sel.contacts.slice();
|
||||||
|
if (isNew) cs.push({ name: sh.name, email: sh.email });
|
||||||
|
else cs[sh.idx] = { name: sh.name, email: sh.email };
|
||||||
|
this.updateInv(sel.id, { contacts: cs }); this.setState({ sheet: null }); this.toast(isNew ? 'Contact added' : 'Contact updated');
|
||||||
|
}, disabled: !sh.name.trim(), style: { flex: 1, height: 48, borderRadius: 8, border: 'none', background: !sh.name.trim() ? p.elev : 'linear-gradient(#3b82c4,#2f6ea9)', color: !sh.name.trim() ? p.t4 : '#fff', fontSize: 15, fontWeight: 600, cursor: 'pointer' } }, isNew ? 'Add contact' : 'Save')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sh.kind === 'note') {
|
||||||
|
const types = ['Note', 'Email', 'Call', 'Meeting'];
|
||||||
|
return h('div', null,
|
||||||
|
label('Type'),
|
||||||
|
h('div', { style: { display: 'flex', gap: 8 } }, types.map(t => {
|
||||||
|
const on = sh.type === t; const tc = this.noteTag(t, theme);
|
||||||
|
return h('button', { key: t, onClick: () => this.setDraft({ type: t }), style: { flex: 1, height: 40, borderRadius: 7, cursor: 'pointer', fontFamily: 'var(--mono)', fontSize: 12, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? tc.bg : p.input, color: on ? tc.text : p.t3 } }, t);
|
||||||
|
})),
|
||||||
|
label('Summary'),
|
||||||
|
h('input', { value: sh.summary, onChange: e => this.setDraft({ summary: e.target.value }), style: inputStyle, placeholder: 'Short headline', autoFocus: true }),
|
||||||
|
label('Details'),
|
||||||
|
h('textarea', { value: sh.details, onChange: e => this.setDraft({ details: e.target.value }), style: areaStyle, placeholder: 'Full context kept in communications history' }),
|
||||||
|
help('Posts immediately to the shared timeline via the one-row log path.'),
|
||||||
|
primaryBtn('Log communication', () => {
|
||||||
|
const today = '2026-06-19';
|
||||||
|
if (window.T31Store) window.T31Store.logNote(sel.id, [sh.type, sh.summary, today]);
|
||||||
|
this.setState({ sheet: null }); this.toast('Communication logged');
|
||||||
|
}, !sh.summary.trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sh.kind === 'stage') {
|
||||||
|
const stages = ['lead', 'engaged', 'diligence', 'commitment'];
|
||||||
|
const noContacts = sel.contacts.length === 0;
|
||||||
|
if (!sh.linked) {
|
||||||
|
return h('div', null,
|
||||||
|
h('div', { style: { fontSize: 14, color: p.t2, lineHeight: 1.5, marginTop: 6 } }, 'This investor isn\u2019t in the pipeline yet. Add them to create a pipeline opportunity, then set a stage.'),
|
||||||
|
noContacts ? h('div', { style: { marginTop: 14, padding: '12px 14px', borderRadius: 8, border: '1px solid ' + (theme === 'light' ? '#e4d29a' : '#e0b3413d'), background: theme === 'light' ? '#f59e0b14' : '#e0b3411a', fontSize: 13, color: theme === 'light' ? '#8a6c12' : '#e0b341', lineHeight: 1.45 } }, 'Needs at least one contact before it can be linked to the pipeline.') : null,
|
||||||
|
primaryBtn('Add to pipeline', () => { this.setDraft({ linked: true, stage: 'lead' }); }, noContacts)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return h('div', null,
|
||||||
|
label('Stage'),
|
||||||
|
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } }, stages.map(st => {
|
||||||
|
const on = sh.stage === st; const sc = this.stageColors(st, theme);
|
||||||
|
return h('button', { key: st, onClick: () => this.setDraft({ stage: st }), style: { width: '100%', height: 48, borderRadius: 8, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? p.elev : p.input } },
|
||||||
|
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 13, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', padding: '4px 10px', borderRadius: 999, background: sc.bg, color: sc.text, border: '1px solid ' + sc.border } }, st),
|
||||||
|
on ? h('span', { style: { color: '#3b82c4', fontSize: 15 } }, '\u2713') : null
|
||||||
|
);
|
||||||
|
})),
|
||||||
|
help('Shares the opportunities endpoint with the Pipeline tab.'),
|
||||||
|
primaryBtn('Update stage', () => { this.updateInv(sel.id, { stage: sh.stage }); this.setState({ sheet: null }); this.toast('Pipeline stage updated'); })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sh.kind === 'reminder') {
|
||||||
|
const S = window.T31Store;
|
||||||
|
const presets = [['Tomorrow', '2026-06-20'], ['In 3 days', '2026-06-22'], ['Next week', '2026-06-26'], ['In 2 weeks', '2026-07-03']];
|
||||||
|
return h('div', null,
|
||||||
|
label('Due date'),
|
||||||
|
h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, presets.map(d => {
|
||||||
|
const on = sh.date === d[1];
|
||||||
|
return h('button', { key: d[1], onClick: () => this.setDraft({ date: d[1] }), style: { flex: '1 0 40%', height: 42, borderRadius: 7, cursor: 'pointer', fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 500, border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? p.elev : p.input, color: on ? p.t1 : p.t3 } }, d[0] + ' · ' + (S ? S.monthDay(d[1]) : ''));
|
||||||
|
})),
|
||||||
|
label('Note'),
|
||||||
|
h('input', { value: sh.note, onChange: e => this.setDraft({ note: e.target.value }), style: inputStyle, placeholder: 'What needs doing?', autoFocus: true }),
|
||||||
|
help('Saved to Reminders and shown on the investor row.'),
|
||||||
|
h('div', { style: { display: 'flex', gap: 10, marginTop: 22 } },
|
||||||
|
sh.rid ? h('button', { onClick: () => { if (S) S.deleteReminder(sh.rid); this.setState({ sheet: null }); this.toast('Reminder cleared'); }, style: { height: 48, padding: '0 16px', borderRadius: 8, border: '1px solid ' + p.bstrong, background: p.elev, color: p.t2, fontSize: 14, cursor: 'pointer', flex: 'none' } }, 'Clear') : null,
|
||||||
|
h('button', { onClick: () => { const due = sh.date || '2026-06-22'; if (S) { if (sh.rid) S.updateReminder(sh.rid, { note: sh.note, due: due, done: false }); else S.addReminder(sel.id, sh.note || 'Follow up', due); } this.setState({ sheet: null }); this.toast('Reminder set'); }, disabled: !sh.note.trim(), style: { flex: 1, height: 48, borderRadius: 8, border: 'none', background: !sh.note.trim() ? p.elev : 'linear-gradient(#3b82c4,#2f6ea9)', color: !sh.note.trim() ? p.t4 : '#fff', fontSize: 15, fontWeight: 600, cursor: 'pointer' } }, 'Save reminder')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sh.kind === 'create') {
|
||||||
|
const qn = sh.name.trim().toLowerCase();
|
||||||
|
const matches = qn.length >= 2 ? (window.T31Store ? window.T31Store.investors : []).filter(i => i.name.toLowerCase().includes(qn)).slice(0, 3) : [];
|
||||||
|
const stages = ['lead', 'engaged', 'diligence', 'commitment'];
|
||||||
|
const warnBorder = theme === 'light' ? '#e4d29a' : '#e0b3413d';
|
||||||
|
const warnBg = theme === 'light' ? '#f59e0b12' : '#e0b3411a';
|
||||||
|
const warnText = theme === 'light' ? '#8a6c12' : '#e0b341';
|
||||||
|
const prc = this.priColors(theme);
|
||||||
|
return h('div', null,
|
||||||
|
label('Investor name'),
|
||||||
|
h('input', { value: sh.name, onChange: e => this.setDraft({ name: e.target.value }), style: inputStyle, placeholder: 'Search or create…', autoFocus: true }),
|
||||||
|
matches.length ? h('div', { style: { marginTop: 10, border: '1px solid ' + warnBorder, background: warnBg, borderRadius: 8, overflow: 'hidden' } }, [
|
||||||
|
h('div', { key: 'h', style: { padding: '9px 13px', fontSize: 12, color: warnText, borderBottom: '1px solid ' + warnBorder } }, 'Possible existing match — open instead of creating a duplicate?')
|
||||||
|
].concat(matches.map(m => { const ms = this.stageColors(m.stage, theme); return h('button', { key: m.id, onClick: () => { this.setState({ sheet: null, detailId: m.id, tab: 'grid' }); }, style: { width: '100%', textAlign: 'left', padding: '11px 13px', background: 'none', border: 'none', borderTop: '1px solid ' + warnBorder, cursor: 'pointer', color: p.t1, fontSize: 14, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 } }, h('span', null, m.name), h('span', { style: { flex: 'none', fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', padding: '3px 8px', borderRadius: 999, background: ms.bg, color: ms.text, border: '1px solid ' + ms.border } }, this.stageLabel(m.stage))); }))) : null,
|
||||||
|
label('First contact'),
|
||||||
|
h('input', { value: sh.cname, onChange: e => this.setDraft({ cname: e.target.value }), style: inputStyle, placeholder: 'Contact name' }),
|
||||||
|
h('input', { value: sh.cemail, onChange: e => this.setDraft({ cemail: e.target.value }), style: Object.assign({}, inputStyle, { marginTop: 8, fontFamily: 'var(--mono)', fontSize: 14 }), placeholder: 'name@firm.com', inputMode: 'email' }),
|
||||||
|
label('Initial stage'),
|
||||||
|
h('div', { style: { display: 'flex', gap: 8 } }, stages.map(t => {
|
||||||
|
const on = sh.stage === t; const sc = this.stageColors(t, theme);
|
||||||
|
return h('button', { key: t, onClick: () => this.setDraft({ stage: t }), style: { flex: 1, height: 44, borderRadius: 7, cursor: 'pointer', fontFamily: 'var(--mono)', fontSize: 11, fontWeight: 600, letterSpacing: '0.03em', textTransform: 'uppercase', border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? sc.bg : p.input, color: on ? sc.text : p.t3, lineHeight: 1.1, textAlign: 'center', padding: '0 4px' } }, t);
|
||||||
|
})),
|
||||||
|
label('Disposition'),
|
||||||
|
h('button', { onClick: () => this.setDraft({ priority: !sh.priority }), style: { width: '100%', height: 48, borderRadius: 8, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 14px', border: '1px solid ' + (sh.priority ? p.bstrong : p.border), background: sh.priority ? prc.bg : p.input } },
|
||||||
|
h('span', { style: { fontSize: 14, color: sh.priority ? prc.text : p.t2, fontWeight: 500 } }, 'Flag as Priority'),
|
||||||
|
h('span', { style: { width: 40, height: 24, borderRadius: 999, background: sh.priority ? '#3b82c4' : p.bstrong, position: 'relative', transition: 'background 150ms', flex: 'none' } },
|
||||||
|
h('span', { style: { position: 'absolute', top: 3, left: sh.priority ? 19 : 3, width: 18, height: 18, borderRadius: 999, background: '#fff', transition: 'left 150ms' } }))
|
||||||
|
),
|
||||||
|
help('Creates the row + first contact in one call (create_investor_if_missing). Commitments and the full column set are filled later on desktop.'),
|
||||||
|
primaryBtn('Create investor', () => {
|
||||||
|
const contacts = sh.cname.trim() ? [{ name: sh.cname, email: sh.cemail }] : [];
|
||||||
|
const ni = { name: sh.name.trim(), priority: !!sh.priority, stage: sh.stage, daysAgo: 0, contacts: contacts, funds: [['Ten31 Terahash', 0]], views: ['Main Fundraising'], notes: [] };
|
||||||
|
const id = window.T31Store ? window.T31Store.addInvestor(ni) : 0;
|
||||||
|
this.setState({ sheet: null, view: 'Main Fundraising', detailId: id });
|
||||||
|
this.toast('Investor created');
|
||||||
|
}, !sh.name.trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sh.kind === 'quicklog') {
|
||||||
|
const qn = (sh.q || '').trim().toLowerCase();
|
||||||
|
if (!sh.targetId) {
|
||||||
|
let pool = (window.T31Store ? window.T31Store.investors : []).slice();
|
||||||
|
if (qn) pool = pool.filter(i => i.name.toLowerCase().includes(qn) || (i.contacts || []).some(c => c.name.toLowerCase().includes(qn) || (c.email || '').toLowerCase().includes(qn)));
|
||||||
|
else pool = pool.sort((a, b) => a.daysAgo - b.daysAgo);
|
||||||
|
pool = pool.slice(0, 8);
|
||||||
|
return h('div', null,
|
||||||
|
h('div', { style: { fontSize: 13, color: p.t3, lineHeight: 1.5, margin: '0 0 12px' } }, 'Pick an investor or contact, then log the communication.'),
|
||||||
|
h('input', { value: sh.q, onChange: e => this.setDraft({ q: e.target.value }), style: inputStyle, placeholder: 'Search investor or contact…', autoFocus: true }),
|
||||||
|
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 } }, pool.length ? pool.map(i => {
|
||||||
|
const sc = this.stageColors(i.stage, theme); const amt = this.committed(i);
|
||||||
|
const sub = i.contacts[0] ? i.contacts[0].name + (i.contacts.length > 1 ? ' +' + (i.contacts.length - 1) : '') : 'No contacts';
|
||||||
|
return h('button', { key: i.id, onClick: () => this.setDraft({ targetId: i.id }), style: { width: '100%', textAlign: 'left', cursor: 'pointer', background: p.input, border: '1px solid ' + p.border, borderRadius: 10, padding: '11px 13px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, color: p.t1 } },
|
||||||
|
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 3, minWidth: 0 } },
|
||||||
|
h('span', { style: { fontSize: 15, fontWeight: 500 } }, (amt > 0 ? '★ ' : '') + i.name),
|
||||||
|
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 12, color: p.t3 } }, sub)),
|
||||||
|
h('span', { style: { flex: 'none', fontFamily: 'var(--mono)', fontSize: 10, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', padding: '3px 8px', borderRadius: 999, background: sc.bg, color: sc.text, border: '1px solid ' + sc.border } }, this.stageLabel(i.stage)));
|
||||||
|
}) : h('div', { style: { fontSize: 13, color: p.t4, padding: '16px 4px' } }, 'No matches.'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const t = (window.T31Store ? window.T31Store.investors : []).find(i => i.id === sh.targetId);
|
||||||
|
const types = ['Note', 'Email', 'Call', 'Meeting'];
|
||||||
|
return h('div', null,
|
||||||
|
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, background: p.input, border: '1px solid ' + p.border, borderRadius: 10, padding: '11px 13px' } },
|
||||||
|
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 } },
|
||||||
|
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '0.06em', textTransform: 'uppercase', color: p.t4 } }, 'Logging for'),
|
||||||
|
h('span', { style: { fontSize: 15, fontWeight: 600, color: p.t1 } }, t.name)),
|
||||||
|
h('button', { onClick: () => this.setDraft({ targetId: null }), style: { flex: 'none', background: 'none', border: 'none', color: p.accentlight, fontSize: 13, cursor: 'pointer' } }, 'Change')),
|
||||||
|
label('Type'),
|
||||||
|
h('div', { style: { display: 'flex', gap: 8 } }, types.map(tp => {
|
||||||
|
const on = sh.type === tp; const tc = this.noteTag(tp, theme);
|
||||||
|
return h('button', { key: tp, onClick: () => this.setDraft({ type: tp }), style: { flex: 1, height: 40, borderRadius: 7, cursor: 'pointer', fontFamily: 'var(--mono)', fontSize: 12, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', border: '1px solid ' + (on ? p.bstrong : p.border), background: on ? tc.bg : p.input, color: on ? tc.text : p.t3 } }, tp);
|
||||||
|
})),
|
||||||
|
label('Summary'),
|
||||||
|
h('input', { value: sh.summary, onChange: e => this.setDraft({ summary: e.target.value }), style: inputStyle, placeholder: 'Short headline', autoFocus: true }),
|
||||||
|
label('Details'),
|
||||||
|
h('textarea', { value: sh.details, onChange: e => this.setDraft({ details: e.target.value }), style: areaStyle, placeholder: 'Full context kept in communications history' }),
|
||||||
|
help('Posts to ' + t.name + '\u2019s timeline via the one-row log path and bumps last contact to today.'),
|
||||||
|
primaryBtn('Log communication', () => {
|
||||||
|
const entry = [sh.type, sh.summary.trim(), '2026-06-19'];
|
||||||
|
if (window.T31Store) window.T31Store.logNote(t.id, entry);
|
||||||
|
this.setState({ sheet: null }); this.toast('Logged for ' + t.name);
|
||||||
|
}, !sh.summary.trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-width:100%; min-height:100vh; width:max-content; box-sizing:border-box; padding:48px 56px 64px; background:#e7e5df; display:flex; flex-direction:column;">
|
||||||
|
<div style="font:700 22px 'IBM Plex Sans'; color:#161b22; letter-spacing:-0.01em;">Ten31 CRM — Pipeline, mobile</div>
|
||||||
|
<div style="font:400 14px 'IBM Plex Sans'; color:#5a5f66; margin-top:8px; max-width:780px; line-height:1.5;">Canonical Pipeline — swipe between stages. Same shell, data, and theme system as the Grid. Both phones are live: tap a stage chip (or swipe) to move between stages, advance a deal with the inline ‹ / ›, or tap a card for its detail — quick stage move, full activity timeline, and <strong style="color:#3b3f46;">+ Log</strong> to record a communication right here. Shown in dark and light.</div>
|
||||||
|
<div style="display:flex; gap:56px; align-items:flex-start; margin-top:40px;">
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Swipe stages · dark (default)</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#0b1118;">
|
||||||
|
<dc-import name="PipelineApp" mode="swipe" theme="dark" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font:400 12px 'IBM Plex Sans'; color:#6a6f76; max-width:393px; line-height:1.5;">One stage fills the screen; horizontal snap + a sticky segmented control. Tap a card to view its activity and log a new communication.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Swipe stages · light</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#eaeef3;">
|
||||||
|
<dc-import name="PipelineApp" mode="swipe" theme="light" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font:400 12px 'IBM Plex Sans'; color:#6a6f76; max-width:393px; line-height:1.5;">Same surface, light theme — toggle in-app with the ☀/☾ control in the top bar.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<script src="store.js"></script>
|
||||||
|
<style>
|
||||||
|
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@keyframes accOpen { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.pp-scroll::-webkit-scrollbar { width: 0; height: 0; }
|
||||||
|
.pp-snap::-webkit-scrollbar { width: 0; height: 0; }
|
||||||
|
.pp-scroll, .pp-snap { scrollbar-width: none; -ms-overflow-style: none; }
|
||||||
|
.pp-root button { font-family: inherit; }
|
||||||
|
.pp-root {
|
||||||
|
--sans:'IBM Plex Sans','Segoe UI',sans-serif; --mono:'IBM Plex Mono',monospace;
|
||||||
|
--grad1:#1a3c5e44; --grad2:#27496b33;
|
||||||
|
--base:#0b1118; --panel:#111a27; --elev:#152233; --input:#0d1622; --hover:#1b2a3a;
|
||||||
|
--border:#263548; --bstrong:#35506a; --divider:#1c2735;
|
||||||
|
--t1:#e5edf5; --t2:#c7d3e0; --t3:#8ea2b7; --t4:#70859b;
|
||||||
|
--accent:#3b82c4; --accentlight:#93c5fd; --danger:#e06c6c; --money:#6ee7b7;
|
||||||
|
--shadow-card:0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
|
||||||
|
--nav-bg:#0d1622cc;
|
||||||
|
}
|
||||||
|
.pp-root[data-theme="light"] {
|
||||||
|
--grad1:#3b82c41c; --grad2:#27496b10;
|
||||||
|
--base:#eaeef3; --panel:#ffffff; --elev:#f4f7fb; --input:#eef2f7; --hover:#e6ecf4;
|
||||||
|
--border:#d6dde7; --bstrong:#b6c3d4; --divider:#e8edf3;
|
||||||
|
--t1:#16202c; --t2:#33414f; --t3:#5a6b7d; --t4:#84909e;
|
||||||
|
--accent:#3b82c4; --accentlight:#1f6fb8; --danger:#c0322f; --money:#057a55;
|
||||||
|
--shadow-card:0 8px 20px rgba(40,70,110,0.10), inset 0 1px 0 #ffffff;
|
||||||
|
--nav-bg:#ffffffd9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div class="pp-root" data-theme="{{ themeAttr }}" style="position:absolute; inset:0; background:radial-gradient(900px 460px at 12% -8%, var(--grad1), transparent 60%), radial-gradient(760px 380px at 92% -2%, var(--grad2), transparent 58%), var(--base); display:flex; flex-direction:column; font-family:var(--sans); color:var(--t1); letter-spacing:0.01em; overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- status bar -->
|
||||||
|
<div style="height:46px; flex:none; display:flex; align-items:flex-end; justify-content:space-between; padding:0 24px 6px; font-family:var(--mono); font-size:13px; color:var(--t2);">
|
||||||
|
<span>9:41</span>
|
||||||
|
<span style="display:flex; gap:6px; align-items:center; font-size:11px; letter-spacing:0.02em;">5G ▮▮▮▯ 84%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- top bar -->
|
||||||
|
<div style="flex:none; height:52px; display:flex; align-items:center; justify-content:space-between; padding:0 16px; border-bottom:1px solid var(--border);">
|
||||||
|
<span style="font-family:var(--mono); font-weight:600; font-size:15px; letter-spacing:0.04em; color:var(--t1);">·Ten31·</span>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<button onClick="{{ toggleTheme }}" aria-label="Toggle theme" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--border); background:var(--elev); color:var(--t3); font-size:15px; cursor:pointer; display:flex; align-items:center; justify-content:center; line-height:1;">{{ themeIcon }}</button>
|
||||||
|
<button onClick="{{ toggleAccount }}" aria-label="Account" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--bstrong); background:var(--elev); color:var(--accentlight); font-family:var(--mono); font-weight:600; font-size:13px; cursor:pointer; display:flex; align-items:center; justify-content:center;">GG</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- title row -->
|
||||||
|
<div style="flex:none; padding:14px 16px 12px; display:flex; align-items:baseline; justify-content:space-between; gap:10px;">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:3px;">
|
||||||
|
<span style="font-size:21px; font-weight:600; letter-spacing:-0.01em;">Pipeline</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">{{ totalLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick="{{ openSortSheet }}" style="flex:none; display:flex; align-items:center; gap:6px; height:30px; padding:0 12px; border-radius:999px; border:1px solid var(--border); background:var(--input); color:var(--t2); font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; cursor:pointer;">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h11M3 12h7M3 18h4"></path><path d="M18 8v9m0 0 3-3m-3 3-3-3"></path></svg>
|
||||||
|
{{ sortLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== SWIPE MODE ===== -->
|
||||||
|
<sc-if value="{{ isSwipe }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<!-- segmented stage control -->
|
||||||
|
<div class="pp-scroll" style="flex:none; overflow-x:auto; padding:0 16px 12px;">
|
||||||
|
<div style="display:inline-flex; gap:8px;">
|
||||||
|
<sc-for list="{{ segments }}" as="sg" hint-placeholder-count="6">
|
||||||
|
<button onClick="{{ sg.go }}" style="flex:none; cursor:pointer; display:flex; align-items:center; gap:8px; height:36px; padding:0 14px; border-radius:999px; border:1px solid {{ sg.border }}; background:{{ sg.bg }};">
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; color:{{ sg.text }};">{{ sg.label }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; font-weight:600; color:{{ sg.countText }}; background:{{ sg.countBg }}; min-width:18px; height:18px; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; padding:0 5px;">{{ sg.count }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- snap columns -->
|
||||||
|
<div class="pp-snap" ref="{{ snapRef }}" onScroll="{{ onSnapScroll }}" style="flex:1; min-height:0; display:flex; overflow-x:auto; overflow-y:hidden; scroll-snap-type:x mandatory; -webkit-overflow-scrolling:touch;">
|
||||||
|
<sc-for list="{{ columns }}" as="col" hint-placeholder-count="6">
|
||||||
|
<div style="flex:none; width:100%; height:100%; scroll-snap-align:start; display:flex; flex-direction:column;">
|
||||||
|
<div style="flex:none; display:flex; align-items:center; justify-content:space-between; padding:4px 18px 12px;">
|
||||||
|
<span style="display:flex; align-items:center; gap:9px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:13px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:4px 11px; border-radius:999px; background:{{ col.bg }}; color:{{ col.text }}; border:1px solid {{ col.border }};">{{ col.label }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">{{ col.count }}</span>
|
||||||
|
</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:13px; font-weight:600; color:{{ col.sumColor }};">{{ col.sum }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pp-scroll" style="flex:1; min-height:0; overflow-y:auto; padding:0 16px 18px; display:flex; flex-direction:column; gap:10px;">
|
||||||
|
<sc-for list="{{ col.cards }}" as="c" hint-placeholder-count="3">
|
||||||
|
<div style="background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:var(--shadow-card); overflow:hidden;">
|
||||||
|
<button onClick="{{ c.open }}" style="width:100%; text-align:left; cursor:pointer; background:none; border:none; padding:13px 14px 11px; display:flex; flex-direction:column; gap:9px; color:var(--t1);">
|
||||||
|
<span style="display:flex; align-items:flex-start; justify-content:space-between; gap:10px;">
|
||||||
|
<span style="display:flex; align-items:center; gap:6px; min-width:0;">
|
||||||
|
<sc-if value="{{ c.existing }}" hint-placeholder-val="{{ false }}"><span style="flex:none; color:var(--accent); font-size:12px; line-height:1;">★</span></sc-if>
|
||||||
|
<span style="font-size:16px; font-weight:600; line-height:1.25; overflow:hidden; text-overflow:ellipsis;">{{ c.name }}</span>
|
||||||
|
</span>
|
||||||
|
<sc-if value="{{ c.priority }}" hint-placeholder-val="{{ false }}"><span style="flex:none; font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:3px 7px; border-radius:4px; background:{{ priBg }}; color:{{ priText }};">Priority</span></sc-if>
|
||||||
|
</span>
|
||||||
|
<span style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:15px; font-weight:600; color:{{ c.amtColor }};">{{ c.amount }}</span>
|
||||||
|
<span style="width:3px; height:3px; border-radius:999px; background:var(--bstrong);"></span>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">{{ c.last }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div style="display:flex; border-top:1px solid var(--divider);">
|
||||||
|
<button onClick="{{ c.moveBack }}" disabled="{{ c.atStart }}" style="flex:1; cursor:pointer; background:none; border:none; border-right:1px solid var(--divider); height:40px; color:{{ c.backColor }}; font-family:var(--mono); font-size:11px; letter-spacing:0.04em; text-transform:uppercase; display:flex; align-items:center; justify-content:center; gap:5px;">‹ {{ c.backLabel }}</button>
|
||||||
|
<button onClick="{{ c.moveFwd }}" disabled="{{ c.atEnd }}" style="flex:1; cursor:pointer; background:none; border:none; height:40px; color:{{ c.fwdColor }}; font-family:var(--mono); font-size:11px; letter-spacing:0.04em; text-transform:uppercase; display:flex; align-items:center; justify-content:center; gap:5px;">{{ c.fwdLabel }} ›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<sc-if value="{{ col.empty }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:40px 16px; text-align:center; color:var(--t4); font-size:13px; border:1px dashed var(--border); border-radius:10px;">No investors in this stage.</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="flex:none; display:flex; align-items:center; justify-content:center; gap:9px; padding:8px 0 12px;">
|
||||||
|
<sc-for list="{{ dots }}" as="dt" hint-placeholder-count="4">
|
||||||
|
<button onClick="{{ dt.go }}" aria-label="Go to stage" style="background:none; border:none; cursor:pointer; padding:7px 3px; display:flex; align-items:center; justify-content:center;">
|
||||||
|
<sc-if value="{{ dt.active }}" hint-placeholder-val="{{ true }}"><span style="display:block; width:22px; height:6px; border-radius:999px; background:var(--accent);"></span></sc-if>
|
||||||
|
<sc-if value="{{ dt.inactive }}" hint-placeholder-val="{{ false }}"><span style="display:block; width:6px; height:6px; border-radius:999px; background:var(--bstrong);"></span></sc-if>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- ===== ACCORDION MODE ===== -->
|
||||||
|
<sc-if value="{{ isAccordion }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div class="pp-scroll" style="flex:1; min-height:0; overflow-y:auto; padding:0 16px 20px; display:flex; flex-direction:column; gap:10px;">
|
||||||
|
<sc-for list="{{ sections }}" as="sec" hint-placeholder-count="6">
|
||||||
|
<div style="border:1px solid var(--border); border-radius:12px; background:var(--panel); overflow:hidden;">
|
||||||
|
<button onClick="{{ sec.toggle }}" style="width:100%; cursor:pointer; background:none; border:none; padding:14px 15px; display:flex; align-items:center; justify-content:space-between; gap:10px; color:var(--t1);">
|
||||||
|
<span style="display:flex; align-items:center; gap:10px; min-width:0;">
|
||||||
|
<span style="flex:none; color:var(--t3); font-size:12px; width:12px; transition:transform 150ms; transform:rotate({{ sec.rot }}deg);">▸</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:13px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:4px 11px; border-radius:999px; background:{{ sec.bg }}; color:{{ sec.text }}; border:1px solid {{ sec.border }};">{{ sec.label }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">{{ sec.count }}</span>
|
||||||
|
</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:13px; font-weight:600; color:{{ sec.sumColor }};">{{ sec.sum }}</span>
|
||||||
|
</button>
|
||||||
|
<sc-if value="{{ sec.open }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:0 12px 12px; display:flex; flex-direction:column; gap:9px; animation:accOpen 180ms ease;">
|
||||||
|
<sc-for list="{{ sec.cards }}" as="c" hint-placeholder-count="2">
|
||||||
|
<div style="background:var(--elev); border:1px solid var(--border); border-radius:10px; overflow:hidden;">
|
||||||
|
<button onClick="{{ c.open }}" style="width:100%; text-align:left; cursor:pointer; background:none; border:none; padding:12px 13px; display:flex; align-items:center; justify-content:space-between; gap:10px; color:var(--t1);">
|
||||||
|
<span style="display:flex; flex-direction:column; gap:5px; min-width:0;">
|
||||||
|
<span style="display:flex; align-items:center; gap:6px; min-width:0;"><sc-if value="{{ c.existing }}" hint-placeholder-val="{{ false }}"><span style="flex:none; color:var(--accent); font-size:11px; line-height:1;">★</span></sc-if><span style="font-size:15px; font-weight:600; line-height:1.2; overflow:hidden; text-overflow:ellipsis;">{{ c.name }}</span></span>
|
||||||
|
<span style="display:flex; align-items:center; gap:9px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:13px; font-weight:600; color:{{ c.amtColor }};">{{ c.amount }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; color:var(--t4);">{{ c.last }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<sc-if value="{{ c.priority }}" hint-placeholder-val="{{ false }}"><span style="flex:none; font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:3px 7px; border-radius:4px; background:{{ priBg }}; color:{{ priText }};">Priority</span></sc-if>
|
||||||
|
</button>
|
||||||
|
<div style="display:flex; border-top:1px solid var(--divider);">
|
||||||
|
<button onClick="{{ c.moveBack }}" disabled="{{ c.atStart }}" style="flex:1; cursor:pointer; background:none; border:none; border-right:1px solid var(--divider); height:38px; color:{{ c.backColor }}; font-family:var(--mono); font-size:11px; letter-spacing:0.04em; text-transform:uppercase; display:flex; align-items:center; justify-content:center;">‹ {{ c.backLabel }}</button>
|
||||||
|
<button onClick="{{ c.moveFwd }}" disabled="{{ c.atEnd }}" style="flex:1; cursor:pointer; background:none; border:none; height:38px; color:{{ c.fwdColor }}; font-family:var(--mono); font-size:11px; letter-spacing:0.04em; text-transform:uppercase; display:flex; align-items:center; justify-content:center;">{{ c.fwdLabel }} ›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<sc-if value="{{ sec.empty }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:18px 14px; text-align:center; color:var(--t4); font-size:13px;">No investors in this stage.</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- bottom tab bar -->
|
||||||
|
<div style="flex:none; display:flex; border-top:1px solid var(--border); background:var(--nav-bg); backdrop-filter:blur(8px); padding-bottom:18px;">
|
||||||
|
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="4">
|
||||||
|
<button onClick="{{ t.go }}" style="flex:1; background:none; border:none; cursor:pointer; height:56px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px; color:{{ t.color }};">
|
||||||
|
<span style="width:20px; height:20px; display:flex; align-items:center; justify-content:center;">{{ t.icon }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.04em;">{{ t.label }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- account menu -->
|
||||||
|
<sc-if value="{{ accountMenu }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeAccount }}" style="position:absolute; inset:0; z-index:40; animation:fadeIn 120ms ease;">
|
||||||
|
<div style="position:absolute; top:96px; right:16px; width:208px; background:var(--elev); border:1px solid var(--bstrong); border-radius:10px; box-shadow:0 24px 56px rgba(1,8,17,0.5); overflow:hidden;">
|
||||||
|
<div style="padding:14px 16px; border-bottom:1px solid var(--border);">
|
||||||
|
<div style="font-size:14px; font-weight:600; color:var(--t1);">Grant Gilliam</div>
|
||||||
|
<div style="font-size:12px; color:var(--t3); margin-top:2px;">grant@ten31.xyz</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px;">
|
||||||
|
<div style="padding:11px 12px; border-radius:7px; font-size:14px; color:var(--t2);">Profile</div>
|
||||||
|
<div style="padding:11px 12px; border-radius:7px; font-size:14px; color:var(--danger);">Log out</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- detail / quick-move sheet -->
|
||||||
|
<sc-if value="{{ sheetOpen }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeSheet }}" style="position:absolute; inset:0; z-index:60; background:rgba(4,9,16,0.55); animation:fadeIn 150ms ease; display:flex; flex-direction:column; justify-content:flex-end;">
|
||||||
|
<div onClick="{{ stop }}" style="background:var(--panel); border-top:1px solid var(--bstrong); border-radius:20px 20px 0 0; box-shadow:0 -24px 56px rgba(1,8,17,0.4); animation:sheetUp 280ms cubic-bezier(.2,.8,.2,1); padding:0 20px 26px; max-height:88%; display:flex; flex-direction:column;">
|
||||||
|
<div style="padding:10px 0 4px; display:flex; justify-content:center; flex:none;"><div style="width:38px; height:4px; border-radius:999px; background:var(--bstrong);"></div></div>
|
||||||
|
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:8px 0 4px; flex:none;">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:7px; min-width:0;">
|
||||||
|
<span style="font-size:19px; font-weight:600; color:var(--t1);">{{ d.name }}</span>
|
||||||
|
<span style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<sc-if value="{{ d.priority }}" hint-placeholder-val="{{ false }}"><span style="font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:3px 7px; border-radius:4px; background:{{ priBg }}; color:{{ priText }};">Priority</span></sc-if>
|
||||||
|
<sc-if value="{{ d.existing }}" hint-placeholder-val="{{ false }}"><span style="font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:3px 7px; border-radius:4px; background:#3b82c422; color:var(--accentlight);">Existing LP</span></sc-if>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:var(--t4);">Last contact {{ d.last }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button onClick="{{ closeSheet }}" style="flex:none; background:none; border:none; color:var(--t3); font-size:22px; cursor:pointer; line-height:1; padding:0 4px;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pp-scroll" style="overflow-y:auto; margin-top:8px;">
|
||||||
|
<div style="display:flex; gap:10px; margin:6px 0 4px;">
|
||||||
|
<div style="flex:1; background:var(--input); border:1px solid var(--border); border-radius:10px; padding:11px 13px; display:flex; flex-direction:column; gap:4px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.06em; text-transform:uppercase; color:var(--t4);">Committed</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:15px; font-weight:600; color:{{ d.amtColor }};">{{ d.amount }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; background:var(--input); border:1px solid var(--border); border-radius:10px; padding:11px 13px; display:flex; flex-direction:column; gap:4px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.06em; text-transform:uppercase; color:var(--t4);">Contacts</span>
|
||||||
|
<span style="font-size:14px; color:var(--t2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ d.contactLine }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3); margin:18px 0 9px;">Move stage</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<sc-for list="{{ d.stageOptions }}" as="so" hint-placeholder-count="6">
|
||||||
|
<button onClick="{{ so.pick }}" style="width:100%; cursor:pointer; height:46px; border-radius:8px; display:flex; align-items:center; justify-content:space-between; padding:0 14px; border:1px solid {{ so.rowBorder }}; background:{{ so.rowBg }};">
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; padding:4px 10px; border-radius:999px; background:{{ so.bg }}; color:{{ so.text }}; border:1px solid {{ so.border }};">{{ so.label }}</span>
|
||||||
|
<span style="color:var(--accent); font-size:15px; width:16px;">{{ so.check }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; margin:20px 0 10px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3);">Notes / communication</span>
|
||||||
|
<button onClick="{{ openLog }}" style="background:var(--elev); border:1px solid var(--border); border-radius:6px; padding:7px 12px; cursor:pointer; color:var(--accentlight); font-size:13px; min-height:36px;">+ Log</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column;">
|
||||||
|
<sc-for list="{{ d.notes }}" as="n" hint-placeholder-count="2">
|
||||||
|
<div style="display:flex; gap:11px; padding-bottom:14px;">
|
||||||
|
<div style="flex:none; display:flex; flex-direction:column; align-items:center; gap:4px;">
|
||||||
|
<span style="width:9px; height:9px; border-radius:999px; background:var(--accent); margin-top:4px;"></span>
|
||||||
|
<span style="flex:1; width:1px; background:var(--border);"></span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.05em; text-transform:uppercase; padding:2px 6px; border-radius:4px; background:{{ n.tagBg }}; color:{{ n.tagText }};">{{ n.type }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; color:var(--t4);">{{ n.date }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:14px; color:var(--t2); margin-top:6px; line-height:1.45;">{{ n.summary }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
<sc-if value="{{ d.noNotes }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<div style="font-size:13px; color:var(--t4); padding-bottom:6px;">No activity logged yet.</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size:12px; color:var(--t4); margin-top:14px; line-height:1.45;">Stage moves and logged communications both write to the shared opportunities row — the same data the Grid edits.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- log activity sheet (over detail) -->
|
||||||
|
<sc-if value="{{ logOpen }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeLog }}" style="position:absolute; inset:0; z-index:65; background:rgba(4,9,16,0.55); animation:fadeIn 150ms ease; display:flex; flex-direction:column; justify-content:flex-end;">
|
||||||
|
<div onClick="{{ stop }}" style="background:var(--panel); border-top:1px solid var(--bstrong); border-radius:20px 20px 0 0; box-shadow:0 -24px 56px rgba(1,8,17,0.4); animation:sheetUp 280ms cubic-bezier(.2,.8,.2,1); padding:0 20px 26px; max-height:90%; display:flex; flex-direction:column;">
|
||||||
|
<div style="padding:10px 0 4px; display:flex; justify-content:center; flex:none;"><div style="width:38px; height:4px; border-radius:999px; background:var(--bstrong);"></div></div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; padding:8px 0 14px; flex:none;">
|
||||||
|
<span style="font-size:18px; font-weight:600; color:var(--t1);">Log communication</span>
|
||||||
|
<button onClick="{{ closeLog }}" style="background:none; border:none; color:var(--t3); font-size:22px; cursor:pointer; line-height:1; padding:0 4px;">×</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:var(--mono); font-size:12px; color:var(--t4); margin:-4px 0 14px;">{{ logFor }}</div>
|
||||||
|
<div class="pp-scroll" style="overflow-y:auto;">
|
||||||
|
<div style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3); margin:0 0 8px;">Type</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<sc-for list="{{ logTypes }}" as="lt" hint-placeholder-count="4">
|
||||||
|
<button onClick="{{ lt.pick }}" style="flex:1; height:42px; border-radius:7px; cursor:pointer; font-family:var(--mono); font-size:12px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; border:1px solid {{ lt.border }}; background:{{ lt.bg }}; color:{{ lt.text }};">{{ lt.label }}</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3); margin:16px 0 8px;">Summary</div>
|
||||||
|
<input value="{{ logSummary }}" onInput="{{ onLogSummary }}" placeholder="Short headline" style="width:100%; height:46px; background:var(--input); border:1px solid var(--border); border-radius:8px; color:var(--t1); font-family:var(--sans); font-size:15px; padding:0 14px; outline:none; box-sizing:border-box;" />
|
||||||
|
<div style="font-family:var(--mono); font-size:11px; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3); margin:16px 0 8px;">Details</div>
|
||||||
|
<textarea value="{{ logDetails }}" onInput="{{ onLogDetails }}" rows="3" placeholder="Full context kept in communications history" style="width:100%; background:var(--input); border:1px solid var(--border); border-radius:8px; color:var(--t1); font-family:var(--sans); font-size:15px; padding:12px 14px; outline:none; resize:none; line-height:1.45; box-sizing:border-box;"></textarea>
|
||||||
|
<div style="display:flex; gap:10px; margin-top:20px;">
|
||||||
|
<button onClick="{{ closeLog }}" style="flex:1; height:48px; background:var(--elev); border:1px solid var(--border); border-radius:8px; color:var(--t2); font-size:15px; font-weight:500; cursor:pointer;">Cancel</button>
|
||||||
|
<button onClick="{{ saveLog }}" disabled="{{ logDisabled }}" style="flex:2; height:48px; border:none; border-radius:8px; color:{{ logBtnText }}; background:{{ logBtnBg }}; font-size:15px; font-weight:600; cursor:pointer;">Log it</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- sort sheet -->
|
||||||
|
<sc-if value="{{ sortSheet }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeSortSheet }}" style="position:absolute; inset:0; z-index:60; background:rgba(4,9,16,0.55); animation:fadeIn 150ms ease; display:flex; flex-direction:column; justify-content:flex-end;">
|
||||||
|
<div onClick="{{ stop }}" style="background:var(--panel); border-top:1px solid var(--bstrong); border-radius:20px 20px 0 0; box-shadow:0 -24px 56px rgba(1,8,17,0.4); animation:sheetUp 280ms cubic-bezier(.2,.8,.2,1); padding:0 20px 26px; display:flex; flex-direction:column;">
|
||||||
|
<div style="padding:10px 0 4px; display:flex; justify-content:center; flex:none;"><div style="width:38px; height:4px; border-radius:999px; background:var(--bstrong);"></div></div>
|
||||||
|
<div style="padding:8px 0 14px; font-size:18px; font-weight:600; color:var(--t1);">Sort within stage</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<sc-for list="{{ sortOptions }}" as="o" hint-placeholder-count="4">
|
||||||
|
<button onClick="{{ o.pick }}" style="width:100%; text-align:left; cursor:pointer; display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:52px; padding:0 15px; border-radius:10px; border:1px solid {{ o.border }}; background:{{ o.bg }};">
|
||||||
|
<span style="display:flex; flex-direction:column; gap:2px;">
|
||||||
|
<span style="font-size:15px; font-weight:500; color:var(--t1);">{{ o.label }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; color:var(--t4);">{{ o.hint }}</span>
|
||||||
|
</span>
|
||||||
|
<sc-if value="{{ o.on }}" hint-placeholder-val="{{ false }}"><span style="color:var(--accent); font-size:15px;">✓</span></sc-if>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- toast -->
|
||||||
|
<sc-if value="{{ toast }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="position:absolute; left:16px; right:16px; bottom:92px; z-index:70; background:var(--elev); border:1px solid var(--bstrong); border-radius:10px; box-shadow:0 10px 24px rgba(4,12,22,0.35); padding:13px 16px; font-size:14px; color:var(--t1); display:flex; align-items:center; gap:10px; animation:fadeIn 150ms ease;">
|
||||||
|
<span style="color:var(--money);">✓</span>{{ toast }}
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":393,"height":812},"mode":{"editor":"enum","options":["swipe","accordion"],"default":"swipe","tsType":"'swipe'|'accordion'"},"theme":{"editor":"enum","options":["dark","light"],"default":"dark","tsType":"'dark'|'light'"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this._snap = null;
|
||||||
|
this.state = {
|
||||||
|
theme: props.theme === 'light' ? 'light' : 'dark',
|
||||||
|
active: 0,
|
||||||
|
sortKey: 'name',
|
||||||
|
sortSheet: false,
|
||||||
|
open: { 'diligence': true, 'commitment': true },
|
||||||
|
sheetId: null,
|
||||||
|
log: null,
|
||||||
|
toast: null,
|
||||||
|
investors: this.seed(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() { if (window.T31Store) this._unsub = window.T31Store.subscribe(() => this.forceUpdate()); }
|
||||||
|
componentWillUnmount() { if (this._unsub) this._unsub(); }
|
||||||
|
|
||||||
|
seed() {
|
||||||
|
return [
|
||||||
|
{ id: 1, name: 'Northwall Capital', type: 'Investor', stage: 'committed', last: '2d ago', contacts: ['Dana Reyes', 'Per Holt'], amt: 2500000, notes: [['Email', 'Confirmed $2.5M allocation across funds', '2026-06-17'], ['Meeting', 'DD call — covered redemption terms', '2026-06-10']] },
|
||||||
|
{ id: 2, name: 'Brightseed Partners', type: 'Prospect', stage: 'meeting', last: '5d ago', contacts: ['Omar Said'], amt: 0, notes: [['Note', 'Intro from Polaris — warm', '2026-06-14']] },
|
||||||
|
{ id: 3, name: 'Cedarline Family Office', type: 'Investor', stage: 'funded', last: '1w ago', contacts: ['Lena Cho'], amt: 1200000, notes: [['Call', 'Wire received, fully funded', '2026-06-12']] },
|
||||||
|
{ id: 4, name: 'Vance & Co', type: 'Prospect', stage: 'outreach', last: '3d ago', contacts: ['Marcus Vance'], amt: 0, notes: [] },
|
||||||
|
{ id: 5, name: 'Polaris Endowment', type: 'Investor', stage: 'due diligence', last: 'yesterday', contacts: ['Ruth Almeida'], amt: 5000000, notes: [['Meeting', 'IC presentation went well', '2026-06-18'], ['Email', 'Sent data room access', '2026-06-15']] },
|
||||||
|
{ id: 7, name: 'Meridian Trust', type: 'Investor', stage: 'committed', last: '4d ago', contacts: ['Sofia Marin'], amt: 800000, notes: [['Note', 'Signed side letter', '2026-06-14']] },
|
||||||
|
{ id: 8, name: 'Atlas Ventures Fund', type: 'Prospect', stage: 'meeting', last: '6d ago', contacts: ['Will Tanaka'], amt: 0, notes: [] },
|
||||||
|
{ id: 10, name: 'Granite Bay LP', type: 'Investor', stage: 'funded', last: '1mo ago', contacts: ['Tom Becker'], amt: 3300000, notes: [] },
|
||||||
|
{ id: 11, name: 'Forsythe Holdings', type: 'Priority Target', stage: 'lead', last: '5w ago', contacts: [], amt: 0, notes: [] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
stages() { return ['lead', 'engaged', 'diligence', 'commitment']; }
|
||||||
|
shortStage(s) { return ({ 'lead': 'Lead', 'engaged': 'Engaged', 'diligence': 'Diligence', 'commitment': 'Commitment' })[s] || s; }
|
||||||
|
|
||||||
|
themePalette(theme) {
|
||||||
|
if (theme === 'light') return { t4: '#84909e', money: '#057a55' };
|
||||||
|
return { t4: '#70859b', money: '#6ee7b7' };
|
||||||
|
}
|
||||||
|
typeColors(t, theme) {
|
||||||
|
const light = theme === 'light';
|
||||||
|
if (t === 'Investor') return light ? { bg: '#10b9811f', text: '#057a55' } : { bg: '#10b98122', text: '#6ee7b7' };
|
||||||
|
if (t === 'Priority Target') return light ? { bg: '#e08e0922', text: '#a76a07' } : { bg: '#f59e0b22', text: '#fcd34d' };
|
||||||
|
return light ? { bg: '#3b82c41f', text: '#2266a0' } : { bg: '#3b82c422', text: '#93c5fd' };
|
||||||
|
}
|
||||||
|
stageColors(s, theme) {
|
||||||
|
const light = theme === 'light';
|
||||||
|
const dark = {
|
||||||
|
'lead': { bg: '#70859b22', text: '#8ea2b7', border: '#2635488a' },
|
||||||
|
'engaged': { bg: '#3b82c422', text: '#93c5fd', border: '#3b82c44d' },
|
||||||
|
'diligence': { bg: '#e0b3411f', text: '#e0b341', border: '#e0b3413d' },
|
||||||
|
'commitment': { bg: '#10b9811f', text: '#6ee7b7', border: '#10b9813d' },
|
||||||
|
};
|
||||||
|
const lite = {
|
||||||
|
'lead': { bg: '#5a6b7d14', text: '#5a6b7d', border: '#d6dde7' },
|
||||||
|
'engaged': { bg: '#3b82c416', text: '#2266a0', border: '#bcd2ea' },
|
||||||
|
'diligence': { bg: '#e0b34122', text: '#8a6c12', border: '#e4d29a' },
|
||||||
|
'commitment': { bg: '#10b98118', text: '#057a55', border: '#a9ddca' },
|
||||||
|
};
|
||||||
|
const map = light ? lite : dark;
|
||||||
|
return map[s] || (light ? { bg: '#5a6b7d12', text: '#84909e', border: '#d6dde7' } : { bg: '#1b2a3a', text: '#70859b', border: '#263548' });
|
||||||
|
}
|
||||||
|
noteTag(t, theme) {
|
||||||
|
const light = theme === 'light';
|
||||||
|
const dark = { 'Email': { bg: '#3b82c422', text: '#93c5fd' }, 'Call': { bg: '#10b98122', text: '#6ee7b7' }, 'Meeting': { bg: '#f59e0b1f', text: '#fcd34d' }, 'Note': { bg: '#1b2a3a', text: '#8ea2b7' } };
|
||||||
|
const lite = { 'Email': { bg: '#3b82c41a', text: '#2266a0' }, 'Call': { bg: '#10b9811a', text: '#057a55' }, 'Meeting': { bg: '#f59e0b1a', text: '#a76a07' }, 'Note': { bg: '#5a6b7d14', text: '#5a6b7d' } };
|
||||||
|
const map = light ? lite : dark;
|
||||||
|
return map[t] || map['Note'];
|
||||||
|
}
|
||||||
|
money(n) {
|
||||||
|
if (!n) return '$0';
|
||||||
|
if (n >= 1e6) return '$' + (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M';
|
||||||
|
if (n >= 1e3) return '$' + Math.round(n / 1e3) + 'K';
|
||||||
|
return '$' + n;
|
||||||
|
}
|
||||||
|
|
||||||
|
amt(i) { return window.T31Store ? window.T31Store.committed(i) : (i.amt || 0); }
|
||||||
|
sortCards(arr, key) {
|
||||||
|
const a = arr.slice();
|
||||||
|
if (key === 'amount') a.sort((x, y) => this.amt(y) - this.amt(x) || x.name.localeCompare(y.name));
|
||||||
|
else if (key === 'staleness') a.sort((x, y) => y.daysAgo - x.daysAgo || x.name.localeCompare(y.name));
|
||||||
|
else if (key === 'priority') a.sort((x, y) => (y.priority ? 1 : 0) - (x.priority ? 1 : 0) || x.name.localeCompare(y.name));
|
||||||
|
else a.sort((x, y) => x.name.localeCompare(y.name));
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
sortLabelFor(key) { return ({ name: 'Name', amount: 'Amount', staleness: 'Staleness', priority: 'Priority' })[key] || 'Name'; }
|
||||||
|
toast(msg) { this.setState({ toast: msg }); clearTimeout(this._tt); this._tt = setTimeout(() => this.setState({ toast: null }), 2000); }
|
||||||
|
moveStage(id, dir) {
|
||||||
|
const order = this.stages();
|
||||||
|
const inv = (window.T31Store ? window.T31Store.investors : []).find(i => i.id === id);
|
||||||
|
if (!inv) return;
|
||||||
|
const idx = order.indexOf(inv.stage);
|
||||||
|
const ni = Math.max(0, Math.min(order.length - 1, idx + dir));
|
||||||
|
if (window.T31Store) window.T31Store.updateInvestor(id, { stage: order[ni] });
|
||||||
|
this.toast('Moved to ' + this.shortStage(order[ni]));
|
||||||
|
}
|
||||||
|
setStage(id, stage) {
|
||||||
|
if (window.T31Store) window.T31Store.updateInvestor(id, { stage: stage });
|
||||||
|
this.toast('Moved to ' + this.shortStage(stage));
|
||||||
|
}
|
||||||
|
setLog(patch) { this.setState(s => ({ log: Object.assign({}, s.log, patch) })); }
|
||||||
|
saveLog() {
|
||||||
|
const lg = this.state.log; const id = this.state.sheetId;
|
||||||
|
if (!lg || !lg.summary.trim()) return;
|
||||||
|
const entry = [lg.type, lg.summary.trim(), '2026-06-19'];
|
||||||
|
if (window.T31Store) window.T31Store.logNote(id, entry);
|
||||||
|
this.setState({ log: null });
|
||||||
|
this.toast('Communication logged');
|
||||||
|
}
|
||||||
|
|
||||||
|
onSnapScroll(e) {
|
||||||
|
const el = e.target;
|
||||||
|
const w = el.clientWidth || 1;
|
||||||
|
const idx = Math.round(el.scrollLeft / w);
|
||||||
|
if (idx !== this.state.active) this.setState({ active: idx });
|
||||||
|
}
|
||||||
|
goSegment(idx, e) {
|
||||||
|
const root = e && e.currentTarget ? e.currentTarget.closest('.pp-root') : null;
|
||||||
|
const el = root ? root.querySelector('.pp-snap') : this._snap;
|
||||||
|
if (!el) return;
|
||||||
|
// Synchronous, snap-aligned jump — the only scroll path this runtime keeps.
|
||||||
|
// (smooth + rAF writes get clobbered by the reconciler here.) CSS scroll-behavior
|
||||||
|
// on the element gives a native glide where the engine supports it.
|
||||||
|
el.scrollLeft = idx * el.clientWidth;
|
||||||
|
this.setState({ active: idx });
|
||||||
|
}
|
||||||
|
|
||||||
|
cardModel(i, theme, p) {
|
||||||
|
const order = this.stages();
|
||||||
|
const idx = order.indexOf(i.stage);
|
||||||
|
const amt = this.amt(i);
|
||||||
|
const accentDim = theme === 'light' ? '#84909e' : '#46586c';
|
||||||
|
return {
|
||||||
|
id: i.id, name: i.name, priority: !!i.priority, existing: amt > 0,
|
||||||
|
amount: this.money(amt), amtColor: amt > 0 ? p.money : p.t4, last: i.daysAgo + 'd ago',
|
||||||
|
open: () => this.setState({ sheetId: i.id }),
|
||||||
|
atStart: idx <= 0, atEnd: idx >= order.length - 1,
|
||||||
|
backLabel: idx > 0 ? this.shortStage(order[idx - 1]) : 'Start',
|
||||||
|
fwdLabel: idx < order.length - 1 ? this.shortStage(order[idx + 1]) : 'End',
|
||||||
|
backColor: idx > 0 ? 'var(--t3)' : accentDim,
|
||||||
|
fwdColor: idx < order.length - 1 ? 'var(--accentlight)' : accentDim,
|
||||||
|
moveBack: () => this.moveStage(i.id, -1),
|
||||||
|
moveFwd: () => this.moveStage(i.id, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const s = this.state;
|
||||||
|
const theme = s.theme;
|
||||||
|
const p = this.themePalette(theme);
|
||||||
|
const order = this.stages();
|
||||||
|
const mode = this.props.mode === 'accordion' ? 'accordion' : 'swipe';
|
||||||
|
|
||||||
|
const byStage = {};
|
||||||
|
order.forEach(st => byStage[st] = []);
|
||||||
|
const list = window.T31Store ? window.T31Store.investors : [];
|
||||||
|
list.forEach(i => { if (byStage[i.stage]) byStage[i.stage].push(i); });
|
||||||
|
order.forEach(st => { byStage[st] = this.sortCards(byStage[st], s.sortKey); });
|
||||||
|
|
||||||
|
const grandTotal = list.reduce((a, i) => a + this.amt(i), 0);
|
||||||
|
const activeCount = list.filter(i => byStage[i.stage]).length;
|
||||||
|
const priC = theme === 'light' ? { bg: '#e08e0922', text: '#a76a07' } : { bg: '#f59e0b22', text: '#fcd34d' };
|
||||||
|
|
||||||
|
// segments (swipe)
|
||||||
|
const segments = order.map((st, idx) => {
|
||||||
|
const sc = this.stageColors(st, theme);
|
||||||
|
const on = idx === s.active;
|
||||||
|
return {
|
||||||
|
label: this.shortStage(st), count: String(byStage[st].length),
|
||||||
|
go: (e) => this.goSegment(idx, e),
|
||||||
|
bg: on ? sc.bg : 'var(--input)', border: on ? sc.border : 'var(--border)', text: on ? sc.text : 'var(--t3)',
|
||||||
|
countBg: on ? sc.border : 'var(--border)', countText: on ? sc.text : 'var(--t4)',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// columns (swipe)
|
||||||
|
const columns = order.map(st => {
|
||||||
|
const sc = this.stageColors(st, theme);
|
||||||
|
const sum = byStage[st].reduce((a, i) => a + this.amt(i), 0);
|
||||||
|
return {
|
||||||
|
label: this.shortStage(st), count: byStage[st].length + (byStage[st].length === 1 ? ' investor' : ' investors'),
|
||||||
|
bg: sc.bg, text: sc.text, border: sc.border,
|
||||||
|
sum: this.money(sum), sumColor: sum > 0 ? p.money : 'var(--t4)',
|
||||||
|
cards: byStage[st].map(i => this.cardModel(i, theme, p)),
|
||||||
|
empty: byStage[st].length === 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// sections (accordion)
|
||||||
|
const sections = order.map(st => {
|
||||||
|
const sc = this.stageColors(st, theme);
|
||||||
|
const sum = byStage[st].reduce((a, i) => a + this.amt(i), 0);
|
||||||
|
const isOpen = !!s.open[st];
|
||||||
|
return {
|
||||||
|
label: this.shortStage(st), count: byStage[st].length + (byStage[st].length === 1 ? ' investor' : ' investors'),
|
||||||
|
bg: sc.bg, text: sc.text, border: sc.border,
|
||||||
|
sum: this.money(sum), sumColor: sum > 0 ? p.money : 'var(--t4)',
|
||||||
|
open: isOpen, rot: isOpen ? 90 : 0,
|
||||||
|
toggle: () => this.setState(prev => ({ open: Object.assign({}, prev.open, { [st]: !prev.open[st] }) })),
|
||||||
|
cards: byStage[st].map(i => this.cardModel(i, theme, p)),
|
||||||
|
empty: byStage[st].length === 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// detail sheet
|
||||||
|
const sel = list.find(i => i.id === s.sheetId);
|
||||||
|
let d = null;
|
||||||
|
if (sel) {
|
||||||
|
const selAmt = this.amt(sel);
|
||||||
|
d = {
|
||||||
|
name: sel.name, priority: !!sel.priority, existing: selAmt > 0, last: sel.daysAgo + 'd ago',
|
||||||
|
amount: this.money(selAmt), amtColor: selAmt > 0 ? p.money : 'var(--t4)',
|
||||||
|
contactLine: sel.contacts.length ? (sel.contacts[0].name + (sel.contacts.length > 1 ? ' +' + (sel.contacts.length - 1) : '')) : 'None',
|
||||||
|
stageOptions: order.map(st => {
|
||||||
|
const sc = this.stageColors(st, theme);
|
||||||
|
const on = sel.stage === st;
|
||||||
|
return {
|
||||||
|
label: this.shortStage(st), bg: sc.bg, text: sc.text, border: sc.border,
|
||||||
|
rowBg: on ? 'var(--elev)' : 'var(--input)', rowBorder: on ? 'var(--bstrong)' : 'var(--border)',
|
||||||
|
check: on ? '✓' : '',
|
||||||
|
pick: () => this.setStage(sel.id, st),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
hasNote: sel.notes.length > 0, noNotes: sel.notes.length === 0,
|
||||||
|
notes: sel.notes.map(n => { const nt = this.noteTag(n[0], theme); return { type: n[0].toUpperCase(), tagBg: nt.bg, tagText: nt.text, date: n[2], summary: n[1] }; }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// log-activity sheet (layered over detail)
|
||||||
|
const lg = s.log;
|
||||||
|
const logTypes = ['Note', 'Email', 'Call', 'Meeting'].map(t => {
|
||||||
|
const on = lg && lg.type === t; const tc = this.noteTag(t, theme);
|
||||||
|
return { label: t, pick: () => this.setLog({ type: t }),
|
||||||
|
bg: on ? tc.bg : 'var(--input)', border: on ? 'var(--bstrong)' : 'var(--border)', text: on ? tc.text : 'var(--t3)' };
|
||||||
|
});
|
||||||
|
const logDisabled = !lg || !lg.summary.trim();
|
||||||
|
|
||||||
|
const dots = order.map((st, idx) => ({
|
||||||
|
go: (e) => this.goSegment(idx, e),
|
||||||
|
active: idx === s.active,
|
||||||
|
inactive: idx !== s.active,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sortOptions = [['name', 'Name', 'A → Z'], ['amount', 'Committed', 'Most first'], ['staleness', 'Last contact', 'Most stale first'], ['priority', 'Priority', 'Flagged first']].map(o => ({
|
||||||
|
label: o[1], hint: o[2], on: s.sortKey === o[0],
|
||||||
|
bg: s.sortKey === o[0] ? 'var(--elev)' : 'var(--input)', border: s.sortKey === o[0] ? 'var(--bstrong)' : 'var(--border)',
|
||||||
|
pick: () => this.setState({ sortKey: o[0], sortSheet: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// bottom tabs
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'grid', label: 'Grid' }, { key: 'pipeline', label: 'Pipeline' },
|
||||||
|
{ key: 'reminders', label: 'Reminders' }, { key: 'contacts', label: 'Contacts' },
|
||||||
|
].map(t => ({
|
||||||
|
label: t.label, color: t.key === 'pipeline' ? 'var(--accent)' : 'var(--t4)',
|
||||||
|
icon: this.tabIcon(t.key, t.key === 'pipeline'),
|
||||||
|
go: () => { if (window.T31Store) window.T31Store.setTab(t.key); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
themeAttr: theme, themeIcon: theme === 'light' ? '☾' : '☀',
|
||||||
|
priBg: priC.bg, priText: priC.text,
|
||||||
|
toggleTheme: () => { const t = theme === 'light' ? 'dark' : 'light'; if (window.T31Store) window.T31Store.setTheme(t); this.setState({ theme: t }); },
|
||||||
|
toggleAccount: () => this.setState(st => ({ accountMenu: !st.accountMenu })),
|
||||||
|
closeAccount: () => this.setState({ accountMenu: false }),
|
||||||
|
accountMenu: s.accountMenu,
|
||||||
|
totalLabel: activeCount + ' active · ' + this.money(grandTotal) + ' committed',
|
||||||
|
modeLabel: mode === 'swipe' ? 'Swipe stages' : 'Accordion',
|
||||||
|
isSwipe: mode === 'swipe', isAccordion: mode === 'accordion',
|
||||||
|
segments, columns, sections, tabs, dots,
|
||||||
|
sortLabel: this.sortLabelFor(s.sortKey), sortOptions,
|
||||||
|
openSortSheet: () => this.setState({ sortSheet: true }),
|
||||||
|
closeSortSheet: () => this.setState({ sortSheet: false }),
|
||||||
|
sortSheet: s.sortSheet,
|
||||||
|
snapRef: el => { this._snap = el; }, onSnapScroll: e => this.onSnapScroll(e),
|
||||||
|
sheetOpen: !!sel, d,
|
||||||
|
closeSheet: () => this.setState({ sheetId: null }),
|
||||||
|
openLog: () => this.setState({ log: { type: 'Note', summary: '', details: '' } }),
|
||||||
|
closeLog: () => this.setState({ log: null }),
|
||||||
|
logOpen: !!lg && !!sel,
|
||||||
|
logFor: sel ? 'For ' + sel.name : '',
|
||||||
|
logTypes,
|
||||||
|
logSummary: lg ? lg.summary : '', onLogSummary: e => this.setLog({ summary: e.target.value }),
|
||||||
|
logDetails: lg ? lg.details : '', onLogDetails: e => this.setLog({ details: e.target.value }),
|
||||||
|
logDisabled,
|
||||||
|
logBtnBg: logDisabled ? 'var(--elev)' : 'linear-gradient(#3b82c4,#2f6ea9)',
|
||||||
|
logBtnText: logDisabled ? 'var(--t4)' : '#fff',
|
||||||
|
saveLog: () => this.saveLog(),
|
||||||
|
stop: e => e.stopPropagation(),
|
||||||
|
toast: s.toast,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
tabIcon(key, active) {
|
||||||
|
const c = active ? '#3b82c4' : (this.state.theme === 'light' ? '#84909e' : '#70859b');
|
||||||
|
const mk = (children) => React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 20 20', fill: 'none' }, children);
|
||||||
|
const r = (pp) => React.createElement('rect', pp);
|
||||||
|
const ln = (pp) => React.createElement('line', Object.assign({}, pp, { stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }));
|
||||||
|
if (key === 'grid') return mk([
|
||||||
|
r({ key: 1, x: 3, y: 3, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 2, x: 11, y: 3, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 3, x: 3, y: 11, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 4, x: 11, y: 11, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
]);
|
||||||
|
if (key === 'pipeline') return mk([
|
||||||
|
r({ key: 1, x: 3, y: 3, width: 4.5, height: 14, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 2, x: 9.25, y: 3, width: 4.5, height: 10, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 3, x: 15.5, y: 3, width: 1.5, height: 6, rx: 0.7, fill: c }),
|
||||||
|
]);
|
||||||
|
if (key === 'reminders') return mk([
|
||||||
|
React.createElement('circle', { key: 1, cx: 10, cy: 11, r: 6.2, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
ln({ key: 2, x1: 10, y1: 11, x2: 10, y2: 7.5 }),
|
||||||
|
ln({ key: 3, x1: 10, y1: 11, x2: 12.4, y2: 12 }),
|
||||||
|
ln({ key: 4, x1: 7, y1: 3.4, x2: 4.4, y2: 5.4 }),
|
||||||
|
ln({ key: 5, x1: 13, y1: 3.4, x2: 15.6, y2: 5.4 }),
|
||||||
|
]);
|
||||||
|
return mk([
|
||||||
|
React.createElement('circle', { key: 1, cx: 10, cy: 7, r: 3.2, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
React.createElement('path', { key: 2, d: 'M4 16.5c0-3 2.7-4.8 6-4.8s6 1.8 6 4.8', stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-width:100%; min-height:100vh; width:max-content; box-sizing:border-box; padding:48px 56px 64px; background:#e7e5df; display:flex; flex-direction:column;">
|
||||||
|
<div style="font:700 22px 'IBM Plex Sans'; color:#161b22; letter-spacing:-0.01em;">Ten31 CRM — Reminders, mobile</div>
|
||||||
|
<div style="font:400 14px 'IBM Plex Sans'; color:#5a5f66; margin-top:8px; max-width:800px; line-height:1.5;"><strong style="color:#3b3f46;">Swipe a card left to complete, right to snooze</strong> (a checkmark or clock arms as you drag; release past the line to fire) — snooze opens a duration picker. Tap a card to edit (note, due, delete, jump to the investor). Follow-ups are grouped by urgency — <strong style="color:#3b3f46;">Overdue</strong>, Today, This week, Later — in the Grid's staleness colors; <strong style="color:#3b3f46;">+</strong> adds one against any investor. Both phones live; dark and light.</div>
|
||||||
|
<div style="display:flex; gap:56px; align-items:flex-start; margin-top:40px;">
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Dark mode · default</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#0b1118;">
|
||||||
|
<dc-import name="RemindersApp" theme="dark" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Light mode</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:#eaeef3;">
|
||||||
|
<dc-import name="RemindersApp" theme="light" hint-size="100%,100%"></dc-import>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":980,"height":980}}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<script src="store.js"></script>
|
||||||
|
<style>
|
||||||
|
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
.rm-scroll::-webkit-scrollbar { width: 0; height: 0; }
|
||||||
|
.rm-root button, .rm-root input, .rm-root textarea { font-family: inherit; }
|
||||||
|
.rm-root {
|
||||||
|
--sans:'IBM Plex Sans','Segoe UI',sans-serif; --mono:'IBM Plex Mono',monospace;
|
||||||
|
--grad1:#1a3c5e44; --grad2:#27496b33;
|
||||||
|
--base:#0b1118; --panel:#111a27; --elev:#152233; --input:#0d1622; --hover:#1b2a3a;
|
||||||
|
--border:#263548; --bstrong:#35506a; --divider:#1c2735;
|
||||||
|
--t1:#e5edf5; --t2:#c7d3e0; --t3:#8ea2b7; --t4:#70859b;
|
||||||
|
--accent:#3b82c4; --accentlight:#93c5fd; --danger:#e06c6c; --money:#6ee7b7;
|
||||||
|
--shadow-card:0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
|
||||||
|
--nav-bg:#0d1622cc;
|
||||||
|
}
|
||||||
|
.rm-root[data-theme="light"] {
|
||||||
|
--grad1:#3b82c41c; --grad2:#27496b10;
|
||||||
|
--base:#eaeef3; --panel:#ffffff; --elev:#f4f7fb; --input:#eef2f7; --hover:#e6ecf4;
|
||||||
|
--border:#d6dde7; --bstrong:#b6c3d4; --divider:#e8edf3;
|
||||||
|
--t1:#16202c; --t2:#33414f; --t3:#5a6b7d; --t4:#84909e;
|
||||||
|
--accent:#3b82c4; --accentlight:#1f6fb8; --danger:#c0322f; --money:#057a55;
|
||||||
|
--shadow-card:0 8px 20px rgba(40,70,110,0.10), inset 0 1px 0 #ffffff;
|
||||||
|
--nav-bg:#ffffffd9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div class="rm-root" data-theme="{{ themeAttr }}" style="position:absolute; inset:0; background:radial-gradient(900px 460px at 12% -8%, var(--grad1), transparent 60%), radial-gradient(760px 380px at 92% -2%, var(--grad2), transparent 58%), var(--base); display:flex; flex-direction:column; font-family:var(--sans); color:var(--t1); letter-spacing:0.01em; overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- status bar -->
|
||||||
|
<div style="height:46px; flex:none; display:flex; align-items:flex-end; justify-content:space-between; padding:0 24px 6px; font-family:var(--mono); font-size:13px; color:var(--t2);">
|
||||||
|
<span>9:41</span>
|
||||||
|
<span style="display:flex; gap:6px; align-items:center; font-size:11px; letter-spacing:0.02em;">5G ▮▮▮▯ 84%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- top bar -->
|
||||||
|
<div style="flex:none; height:52px; display:flex; align-items:center; justify-content:space-between; padding:0 16px; border-bottom:1px solid var(--border);">
|
||||||
|
<span style="font-family:var(--mono); font-weight:600; font-size:15px; letter-spacing:0.04em; color:var(--t1);">·Ten31·</span>
|
||||||
|
<div style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<button onClick="{{ toggleTheme }}" aria-label="Toggle theme" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--border); background:var(--elev); color:var(--t3); font-size:15px; cursor:pointer; display:flex; align-items:center; justify-content:center; line-height:1;">{{ themeIcon }}</button>
|
||||||
|
<button onClick="{{ toggleAccount }}" aria-label="Account" style="width:36px; height:36px; border-radius:999px; border:1px solid var(--bstrong); background:var(--elev); color:var(--accentlight); font-family:var(--mono); font-weight:600; font-size:13px; cursor:pointer; display:flex; align-items:center; justify-content:center;">GG</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- title + add -->
|
||||||
|
<div style="flex:none; padding:14px 16px 12px; display:flex; align-items:flex-start; justify-content:space-between; gap:10px;">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:3px;">
|
||||||
|
<span style="font-size:21px; font-weight:600; letter-spacing:-0.01em;">Reminders</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:12px; color:{{ summaryColor }};">{{ summary }}</span>
|
||||||
|
</div>
|
||||||
|
<button onClick="{{ openAdd }}" aria-label="Add reminder" style="flex:none; width:44px; height:44px; border-radius:10px; border:none; background:linear-gradient(#3b82c4,#2f6ea9); color:#fff; font-size:22px; font-weight:500; line-height:1; cursor:pointer; box-shadow:0 6px 14px rgba(12,40,68,0.35);">+</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- list -->
|
||||||
|
<div class="rm-scroll" style="flex:1; min-height:0; overflow-y:auto; padding:2px 16px 20px; display:flex; flex-direction:column; gap:18px;">
|
||||||
|
<sc-for list="{{ sections }}" as="sec" hint-placeholder-count="3">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; padding:0 2px 9px;">
|
||||||
|
<span style="width:7px; height:7px; border-radius:999px; background:{{ sec.dot }};"></span>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.08em; text-transform:uppercase; color:var(--t3);">{{ sec.label }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; color:var(--t4);">{{ sec.count }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:9px;">
|
||||||
|
<sc-for list="{{ sec.items }}" as="r" hint-placeholder-count="2">
|
||||||
|
<div style="position:relative; overflow:hidden; border-radius:10px;">
|
||||||
|
<!-- snooze reveal (swipe right) -->
|
||||||
|
<div data-act="snooze" style="position:absolute; inset:0; display:flex; align-items:center; gap:9px; padding-left:20px; border-radius:10px; background:{{ snoozeBg }}; color:{{ snoozeFg }}; opacity:0; pointer-events:none;">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.07em; text-transform:uppercase;">Snooze</span>
|
||||||
|
</div>
|
||||||
|
<!-- complete reveal (swipe left) -->
|
||||||
|
<div data-act="done" style="position:absolute; inset:0; display:flex; align-items:center; justify-content:flex-end; gap:9px; padding-right:20px; border-radius:10px; background:{{ doneBg }}; color:{{ doneFg }}; opacity:0; pointer-events:none;">
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.07em; text-transform:uppercase;">Complete</span>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>
|
||||||
|
</div>
|
||||||
|
<!-- draggable card -->
|
||||||
|
<div onPointerDown="{{ r.dragStart }}" onPointerMove="{{ r.dragMove }}" onPointerUp="{{ r.dragEnd }}" onPointerCancel="{{ r.dragEnd }}" style="position:relative; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:13px 14px; box-shadow:var(--shadow-card); display:flex; flex-direction:column; gap:5px; color:var(--t1); cursor:grab; touch-action:pan-y; user-select:none; transform:translateX(0);">
|
||||||
|
<span style="font-size:15px; font-weight:500; line-height:1.3;">{{ r.note }}</span>
|
||||||
|
<span style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
|
||||||
|
<span style="font-size:12px; color:var(--t3);">{{ r.org }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; font-weight:600; letter-spacing:0.04em; text-transform:uppercase; padding:2px 8px; border-radius:999px; background:{{ r.chipBg }}; color:{{ r.chipText }}; border:1px solid {{ r.chipBorder }};">{{ r.dueText }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
|
||||||
|
<sc-if value="{{ allClear }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="padding:40px 20px; text-align:center; display:flex; flex-direction:column; align-items:center; gap:10px;">
|
||||||
|
<span style="font-size:26px; color:var(--money);">✓</span>
|
||||||
|
<span style="font-size:15px; color:var(--t2); font-weight:500;">Inbox zero</span>
|
||||||
|
<span style="font-size:13px; color:var(--t4);">No open reminders. Nice.</span>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- completed -->
|
||||||
|
<sc-if value="{{ hasDone }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div>
|
||||||
|
<button onClick="{{ toggleCompleted }}" style="width:100%; background:none; border:none; cursor:pointer; display:flex; align-items:center; gap:8px; padding:0 2px 9px; color:var(--t3);">
|
||||||
|
<span style="font-size:12px; width:12px; transform:rotate({{ completedRot }}deg); transition:transform 150ms;">▸</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:0.08em; text-transform:uppercase;">Completed</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:11px; color:var(--t4);">{{ doneCount }}</span>
|
||||||
|
</button>
|
||||||
|
<sc-if value="{{ completedOpen }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:9px;">
|
||||||
|
<sc-for list="{{ doneItems }}" as="r" hint-placeholder-count="1">
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:12px 13px; opacity:0.6;">
|
||||||
|
<button onClick="{{ r.toggle }}" aria-label="Reopen" style="flex:none; width:24px; height:24px; border-radius:999px; border:2px solid var(--accent); background:var(--accent); cursor:pointer; display:flex; align-items:center; justify-content:center; color:#fff; font-size:12px; line-height:1;">✓</button>
|
||||||
|
<button onClick="{{ r.open }}" style="flex:1; min-width:0; text-align:left; background:none; border:none; cursor:pointer; display:flex; flex-direction:column; gap:5px; color:var(--t1);">
|
||||||
|
<span style="font-size:15px; font-weight:500; line-height:1.3; text-decoration:line-through; color:var(--t3);">{{ r.note }}</span>
|
||||||
|
<span style="font-size:12px; color:var(--t4);">{{ r.org }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- bottom tab bar -->
|
||||||
|
<div style="flex:none; display:flex; border-top:1px solid var(--border); background:var(--nav-bg); backdrop-filter:blur(8px); padding-bottom:18px;">
|
||||||
|
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="4">
|
||||||
|
<button onClick="{{ t.go }}" style="flex:1; background:none; border:none; cursor:pointer; height:56px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px; color:{{ t.color }};">
|
||||||
|
<span style="width:20px; height:20px; display:flex; align-items:center; justify-content:center;">{{ t.icon }}</span>
|
||||||
|
<span style="font-family:var(--mono); font-size:10px; letter-spacing:0.04em;">{{ t.label }}</span>
|
||||||
|
</button>
|
||||||
|
</sc-for>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- account menu -->
|
||||||
|
<sc-if value="{{ accountMenu }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeAccount }}" style="position:absolute; inset:0; z-index:40; animation:fadeIn 120ms ease;">
|
||||||
|
<div style="position:absolute; top:96px; right:16px; width:208px; background:var(--elev); border:1px solid var(--bstrong); border-radius:10px; box-shadow:0 24px 56px rgba(1,8,17,0.5); overflow:hidden;">
|
||||||
|
<div style="padding:14px 16px; border-bottom:1px solid var(--border);">
|
||||||
|
<div style="font-size:14px; font-weight:600; color:var(--t1);">Grant Gilliam</div>
|
||||||
|
<div style="font-size:12px; color:var(--t3); margin-top:2px;">grant@ten31.xyz</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:6px;">
|
||||||
|
<div style="padding:11px 12px; border-radius:7px; font-size:14px; color:var(--t2);">Profile</div>
|
||||||
|
<div style="padding:11px 12px; border-radius:7px; font-size:14px; color:var(--danger);">Log out</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- generic sheet -->
|
||||||
|
<sc-if value="{{ sheetOpen }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div onClick="{{ closeSheet }}" style="position:absolute; inset:0; z-index:60; background:rgba(4,9,16,0.55); animation:fadeIn 150ms ease; display:flex; flex-direction:column; justify-content:flex-end;">
|
||||||
|
<div onClick="{{ stop }}" style="background:var(--panel); border-top:1px solid var(--bstrong); border-radius:20px 20px 0 0; box-shadow:0 -24px 56px rgba(1,8,17,0.4); animation:sheetUp 280ms cubic-bezier(.2,.8,.2,1); padding:0 20px 26px; max-height:90%; display:flex; flex-direction:column;">
|
||||||
|
<div style="padding:10px 0 4px; display:flex; justify-content:center; flex:none;"><div style="width:38px; height:4px; border-radius:999px; background:var(--bstrong);"></div></div>
|
||||||
|
<div style="display:flex; align-items:center; justify-content:space-between; padding:8px 0 4px; flex:none;">
|
||||||
|
<span style="font-size:18px; font-weight:600; color:var(--t1);">{{ sheetTitle }}</span>
|
||||||
|
<button onClick="{{ closeSheet }}" style="background:none; border:none; color:var(--t3); font-size:22px; cursor:pointer; line-height:1; padding:0 4px;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="rm-scroll" style="overflow-y:auto; margin-top:8px;">
|
||||||
|
{{ sheetBody }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
<!-- toast -->
|
||||||
|
<sc-if value="{{ toast }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<div style="position:absolute; left:16px; right:16px; bottom:92px; z-index:70; background:var(--elev); border:1px solid var(--bstrong); border-radius:10px; box-shadow:0 10px 24px rgba(4,12,22,0.35); padding:13px 16px; font-size:14px; color:var(--t1); display:flex; align-items:center; gap:10px; animation:fadeIn 150ms ease;">
|
||||||
|
<span style="color:var(--money);">✓</span>{{ toast }}
|
||||||
|
</div>
|
||||||
|
</sc-if>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":393,"height":812},"theme":{"editor":"enum","options":["dark","light"],"default":"dark","tsType":"'dark'|'light'"}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.today = new Date(2026, 5, 19); // Jun 19 2026
|
||||||
|
this.state = {
|
||||||
|
theme: props.theme === 'light' ? 'light' : 'dark',
|
||||||
|
accountMenu: false,
|
||||||
|
completedOpen: false,
|
||||||
|
sheet: null,
|
||||||
|
toast: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() { if (window.T31Store) this._unsub = window.T31Store.subscribe(() => this.forceUpdate()); }
|
||||||
|
componentWillUnmount() { if (this._unsub) this._unsub(); }
|
||||||
|
|
||||||
|
seed() {
|
||||||
|
return [
|
||||||
|
{ id: 1, note: 'Resend deck — bounced', org: 'Vance & Co', orgId: 4, due: '2026-06-18', done: false },
|
||||||
|
{ id: 2, note: 'Re-engage — cold 2 weeks', org: 'Hartman Group', orgId: 6, due: '2026-06-16', done: false },
|
||||||
|
{ id: 3, note: 'IC memo due', org: 'Polaris Endowment', orgId: 5, due: '2026-06-19', done: false },
|
||||||
|
{ id: 4, note: 'Follow up after intro call', org: 'Brightseed Partners', orgId: 2, due: '2026-06-19', done: false },
|
||||||
|
{ id: 5, note: 'Share data room link', org: 'Atlas Ventures Fund', orgId: 8, due: '2026-06-20', done: false },
|
||||||
|
{ id: 6, note: 'Countersign side letter', org: 'Meridian Trust', orgId: 7, due: '2026-06-21', done: false },
|
||||||
|
{ id: 7, note: 'Send Q2 update deck', org: 'Northwall Capital', orgId: 1, due: '2026-06-24', done: false },
|
||||||
|
{ id: 8, note: 'Quarterly check-in call', org: 'Cedarline Family Office', orgId: 3, due: '2026-07-08', done: false },
|
||||||
|
{ id: 9, note: 'Thank-you note post-wire', org: 'Granite Bay LP', orgId: 10, due: '2026-06-13', done: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
investorList() {
|
||||||
|
if (window.T31Store) return window.T31Store.investors.map(i => ({ id: i.id, name: i.name }));
|
||||||
|
return [
|
||||||
|
{ id: 1, name: 'Northwall Capital' }, { id: 2, name: 'Brightseed Partners' }, { id: 3, name: 'Cedarline Family Office' },
|
||||||
|
{ id: 4, name: 'Vance & Co' }, { id: 5, name: 'Polaris Endowment' }, { id: 6, name: 'Hartman Group' },
|
||||||
|
{ id: 7, name: 'Meridian Trust' }, { id: 8, name: 'Atlas Ventures Fund' }, { id: 10, name: 'Granite Bay LP' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(iso) { const p = iso.split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); }
|
||||||
|
diffDays(iso) { return Math.round((this.parse(iso) - this.today) / 86400000); }
|
||||||
|
monthDay(iso) { const m = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const d = this.parse(iso); return m[d.getMonth()] + ' ' + d.getDate(); }
|
||||||
|
|
||||||
|
urgency(iso, theme) {
|
||||||
|
const light = theme === 'light';
|
||||||
|
const d = this.diffDays(iso);
|
||||||
|
const red = light ? { t: '#c0322f', bg: '#c0322f14', bd: '#e3b4b2' } : { t: '#f87171', bg: '#f8717118', bd: '#f8717140' };
|
||||||
|
const amber = light ? { t: '#8a6c12', bg: '#e0b34122', bd: '#e4d29a' } : { t: '#e0b341', bg: '#e0b3411f', bd: '#e0b3413d' };
|
||||||
|
const blue = light ? { t: '#1f6fb8', bg: '#3b82c416', bd: '#bcd2ea' } : { t: '#93c5fd', bg: '#3b82c422', bd: '#3b82c44d' };
|
||||||
|
const grey = light ? { t: '#5a6b7d', bg: '#5a6b7d12', bd: '#d6dde7' } : { t: '#8ea2b7', bg: '#1b2a3a', bd: '#263548' };
|
||||||
|
if (d < 0) return { bucket: 0, text: (-d) + 'd overdue', c: red };
|
||||||
|
if (d === 0) return { bucket: 1, text: 'Today', c: amber };
|
||||||
|
if (d === 1) return { bucket: 2, text: 'Tomorrow', c: blue };
|
||||||
|
if (d <= 7) return { bucket: 2, text: 'in ' + d + 'd', c: blue };
|
||||||
|
return { bucket: 3, text: this.monthDay(iso), c: grey };
|
||||||
|
}
|
||||||
|
|
||||||
|
toast(msg) { this.setState({ toast: msg }); clearTimeout(this._tt); this._tt = setTimeout(() => this.setState({ toast: null }), 2000); }
|
||||||
|
|
||||||
|
dragStart(e, id) {
|
||||||
|
const card = e.currentTarget;
|
||||||
|
card.style.transition = 'none';
|
||||||
|
this._drag = { id, x0: e.clientX, card, wrap: card.parentNode, dx: 0, moved: false, cap: false };
|
||||||
|
}
|
||||||
|
dragMove(e) {
|
||||||
|
const d = this._drag; if (!d) return;
|
||||||
|
let dx = e.clientX - d.x0;
|
||||||
|
if (Math.abs(dx) > 5) d.moved = true;
|
||||||
|
if (!d.cap && Math.abs(dx) > 6) { try { d.card.setPointerCapture(e.pointerId); } catch (_) {} d.cap = true; }
|
||||||
|
dx = Math.max(-160, Math.min(160, dx));
|
||||||
|
d.dx = dx;
|
||||||
|
d.card.style.transform = 'translateX(' + dx + 'px)';
|
||||||
|
const TH = 70;
|
||||||
|
const done = d.wrap.querySelector('[data-act="done"]');
|
||||||
|
const snooze = d.wrap.querySelector('[data-act="snooze"]');
|
||||||
|
if (dx < 0) { done.style.opacity = Math.min(1, -dx / TH); snooze.style.opacity = 0; }
|
||||||
|
else if (dx > 0) { snooze.style.opacity = Math.min(1, dx / TH); done.style.opacity = 0; }
|
||||||
|
else { done.style.opacity = 0; snooze.style.opacity = 0; }
|
||||||
|
}
|
||||||
|
dragEnd(e) {
|
||||||
|
const d = this._drag; if (!d) return; this._drag = null;
|
||||||
|
const dx = d.dx, TH = 70;
|
||||||
|
const done = d.wrap.querySelector('[data-act="done"]');
|
||||||
|
const snooze = d.wrap.querySelector('[data-act="snooze"]');
|
||||||
|
const clearBg = () => { done.style.opacity = 0; snooze.style.opacity = 0; };
|
||||||
|
d.card.style.transition = 'transform 200ms ease';
|
||||||
|
const S = window.T31Store;
|
||||||
|
const r = S ? S.reminders.find(x => x.id === d.id) : null;
|
||||||
|
const inv = (r && S) ? S.investorById(r.orgId) : null;
|
||||||
|
const orgName = inv ? inv.name : '';
|
||||||
|
if (dx <= -TH) {
|
||||||
|
d.card.style.transform = 'translateX(-110%)';
|
||||||
|
const card = d.card, id = d.id;
|
||||||
|
setTimeout(() => {
|
||||||
|
card.style.transition = 'none';
|
||||||
|
card.style.transform = 'translateX(0)';
|
||||||
|
clearBg();
|
||||||
|
this.toggleDone(id);
|
||||||
|
}, 190);
|
||||||
|
} else if (dx >= TH) {
|
||||||
|
d.card.style.transform = 'translateX(0)'; clearBg();
|
||||||
|
this.setState({ sheet: { kind: 'snooze', id: d.id, org: orgName } });
|
||||||
|
} else {
|
||||||
|
d.card.style.transform = 'translateX(0)'; clearBg();
|
||||||
|
if (r) this.setState({ sheet: { kind: 'edit', id: r.id, orgId: r.orgId, note: r.note, due: r.due, org: orgName } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toggleDone(id) {
|
||||||
|
const S = window.T31Store; const r = S && S.reminders.find(x => x.id === id);
|
||||||
|
const nowDone = r ? !r.done : true;
|
||||||
|
if (S) S.toggleReminder(id);
|
||||||
|
this.toast(nowDone ? 'Marked done' : 'Reopened');
|
||||||
|
}
|
||||||
|
setSheet(patch) { this.setState(s => ({ sheet: Object.assign({}, s.sheet, patch) })); }
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const s = this.state;
|
||||||
|
const theme = s.theme;
|
||||||
|
const accent = '#3b82c4';
|
||||||
|
|
||||||
|
const S = window.T31Store;
|
||||||
|
const orgName = (id) => { const inv = S && S.investorById(id); return inv ? inv.name : ''; };
|
||||||
|
const allR = (S ? S.reminders : []).map(r => Object.assign({}, r, { org: orgName(r.orgId) }));
|
||||||
|
const open = allR.filter(r => !r.done);
|
||||||
|
const done = allR.filter(r => r.done);
|
||||||
|
|
||||||
|
const overdue = open.filter(r => this.diffDays(r.due) < 0).length;
|
||||||
|
const todayN = open.filter(r => this.diffDays(r.due) === 0).length;
|
||||||
|
let summary, summaryColor;
|
||||||
|
if (open.length === 0) { summary = 'All clear'; summaryColor = 'var(--money)'; }
|
||||||
|
else { summary = overdue + ' overdue · ' + todayN + ' today · ' + open.length + ' open'; summaryColor = overdue ? (theme === 'light' ? '#c0322f' : '#f87171') : 'var(--t4)'; }
|
||||||
|
|
||||||
|
const dots = ['#f87171', '#e0b341', accent, theme === 'light' ? '#84909e' : '#70859b'];
|
||||||
|
const labels = ['Overdue', 'Today', 'This week', 'Later'];
|
||||||
|
const buckets = [[], [], [], []];
|
||||||
|
open.forEach(r => { const u = this.urgency(r.due, theme); buckets[u.bucket].push({ r, u }); });
|
||||||
|
buckets.forEach(b => b.sort((a, z) => this.diffDays(a.r.due) - this.diffDays(z.r.due)));
|
||||||
|
|
||||||
|
const mkItem = ({ r, u }) => ({
|
||||||
|
note: r.note, org: r.org,
|
||||||
|
dueText: u.text, chipBg: u.c.bg, chipText: u.c.t, chipBorder: u.c.bd,
|
||||||
|
dragStart: (e) => this.dragStart(e, r.id),
|
||||||
|
dragMove: (e) => this.dragMove(e),
|
||||||
|
dragEnd: (e) => this.dragEnd(e),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sections = [];
|
||||||
|
buckets.forEach((b, i) => { if (b.length) sections.push({ label: labels[i], count: String(b.length), dot: dots[i], items: b.map(mkItem) }); });
|
||||||
|
|
||||||
|
const doneItems = done.map(r => ({ note: r.note, org: r.org, toggle: () => this.toggleDone(r.id), open: () => this.setState({ sheet: { kind: 'edit', id: r.id, orgId: r.orgId, note: r.note, due: r.due, org: r.org } }) }));
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'grid', label: 'Grid' }, { key: 'pipeline', label: 'Pipeline' },
|
||||||
|
{ key: 'reminders', label: 'Reminders' }, { key: 'contacts', label: 'Contacts' },
|
||||||
|
].map(t => ({
|
||||||
|
label: t.label, color: t.key === 'reminders' ? 'var(--accent)' : 'var(--t4)',
|
||||||
|
icon: this.tabIcon(t.key, t.key === 'reminders'),
|
||||||
|
go: () => { if (window.T31Store) window.T31Store.setTab(t.key); },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sheetBody = s.sheet ? this.buildSheet(s.sheet) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
themeAttr: theme, themeIcon: theme === 'light' ? '☾' : '☀',
|
||||||
|
toggleTheme: () => { const t = theme === 'light' ? 'dark' : 'light'; if (window.T31Store) window.T31Store.setTheme(t); this.setState({ theme: t }); },
|
||||||
|
toggleAccount: () => this.setState(st => ({ accountMenu: !st.accountMenu })),
|
||||||
|
closeAccount: () => this.setState({ accountMenu: false }),
|
||||||
|
accountMenu: s.accountMenu,
|
||||||
|
summary, summaryColor,
|
||||||
|
sections, allClear: open.length === 0 && done.length === 0 ? false : open.length === 0,
|
||||||
|
tabs,
|
||||||
|
doneBg: theme === 'light' ? '#10b98118' : '#10b9812e', doneFg: theme === 'light' ? '#057a55' : '#6ee7b7',
|
||||||
|
snoozeBg: theme === 'light' ? '#e0b34120' : '#e0b3412e', snoozeFg: theme === 'light' ? '#8a6c12' : '#e0b341',
|
||||||
|
hasDone: done.length > 0, doneCount: String(done.length), doneItems,
|
||||||
|
completedOpen: s.completedOpen, completedRot: s.completedOpen ? 90 : 0,
|
||||||
|
toggleCompleted: () => this.setState(st => ({ completedOpen: !st.completedOpen })),
|
||||||
|
openAdd: () => this.setState({ sheet: { kind: 'add', orgId: null, q: '', note: '', due: '2026-06-20' } }),
|
||||||
|
sheetOpen: !!s.sheet, sheetTitle: s.sheet ? ({ add: 'New reminder', edit: 'Edit reminder', snooze: 'Snooze reminder' })[s.sheet.kind] : '',
|
||||||
|
sheetBody, closeSheet: () => this.setState({ sheet: null }), stop: e => e.stopPropagation(),
|
||||||
|
toast: s.toast,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
duePresets() {
|
||||||
|
return [ ['Today', '2026-06-19'], ['Tomorrow', '2026-06-20'], ['In 3 days', '2026-06-22'], ['Next week', '2026-06-26'] ];
|
||||||
|
}
|
||||||
|
snoozePresets() {
|
||||||
|
return [ ['Tomorrow', '2026-06-20'], ['In 3 days', '2026-06-22'], ['1 week', '2026-06-26'], ['2 weeks', '2026-07-03'] ];
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSheet(sh) {
|
||||||
|
const h = React.createElement;
|
||||||
|
const theme = this.state.theme;
|
||||||
|
const dark = theme !== 'light';
|
||||||
|
const T = { input: dark ? '#0d1622' : '#eef2f7', border: dark ? '#263548' : '#d6dde7', bstrong: dark ? '#35506a' : '#b6c3d4',
|
||||||
|
t1: dark ? '#e5edf5' : '#16202c', t2: dark ? '#c7d3e0' : '#33414f', t3: dark ? '#8ea2b7' : '#5a6b7d', t4: dark ? '#70859b' : '#84909e',
|
||||||
|
elev: dark ? '#152233' : '#f4f7fb', accentlight: dark ? '#93c5fd' : '#1f6fb8', danger: dark ? '#e06c6c' : '#c0322f' };
|
||||||
|
const inputStyle = { width: '100%', height: 46, background: T.input, border: '1px solid ' + T.border, borderRadius: 8, color: T.t1, fontFamily: 'var(--sans)', fontSize: 15, padding: '0 14px', outline: 'none', boxSizing: 'border-box' };
|
||||||
|
const label = (t) => h('div', { style: { fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '0.08em', textTransform: 'uppercase', color: T.t3, margin: '16px 0 8px' } }, t);
|
||||||
|
const dueChips = (sel, on) => h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, this.duePresets().map(d => {
|
||||||
|
const active = sel === d[1];
|
||||||
|
return h('button', { key: d[1], onClick: () => on(d[1]), style: { flex: '1 0 40%', height: 42, borderRadius: 7, cursor: 'pointer', fontSize: 13, fontWeight: 500, border: '1px solid ' + (active ? T.bstrong : T.border), background: active ? T.elev : T.input, color: active ? T.t1 : T.t3 } }, d[0] + ' · ' + this.monthDay(d[1]));
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (sh.kind === 'edit') {
|
||||||
|
return h('div', null,
|
||||||
|
h('div', { style: { fontSize: 13, color: T.t3 } }, 'For ' + sh.org),
|
||||||
|
label('Reminder'),
|
||||||
|
h('input', { value: sh.note, onChange: e => this.setSheet({ note: e.target.value }), style: inputStyle, autoFocus: true }),
|
||||||
|
label('Due'),
|
||||||
|
dueChips(sh.due, v => this.setSheet({ due: v })),
|
||||||
|
h('button', { onClick: () => { if (window.T31Store) window.T31Store.updateReminder(sh.id, { note: sh.note, due: sh.due }); this.setState({ sheet: null }); this.toast('Reminder saved'); }, disabled: !sh.note.trim(), style: { width: '100%', height: 48, marginTop: 22, borderRadius: 8, border: 'none', background: !sh.note.trim() ? T.elev : 'linear-gradient(#3b82c4,#2f6ea9)', color: !sh.note.trim() ? T.t4 : '#fff', fontSize: 15, fontWeight: 600, cursor: 'pointer' } }, 'Save reminder'),
|
||||||
|
h('div', { style: { display: 'flex', gap: 10, marginTop: 10 } },
|
||||||
|
h('button', { onClick: () => { this.toggleDone(sh.id); this.setState({ sheet: null }); }, style: { flex: 1, height: 46, borderRadius: 8, border: '1px solid ' + T.bstrong, background: T.elev, color: T.t2, fontSize: 14, fontWeight: 500, cursor: 'pointer' } }, 'Mark done'),
|
||||||
|
h('button', { onClick: () => { if (window.T31Store) window.T31Store.deleteReminder(sh.id); this.setState({ sheet: null }); this.toast('Reminder deleted'); }, style: { flex: 'none', height: 46, padding: '0 18px', borderRadius: 8, border: '1px solid ' + (dark ? '#dc262655' : '#e3b4b2'), background: dark ? '#2a1416' : '#fbeceb', color: dark ? '#fca5a5' : '#c0322f', fontSize: 14, fontWeight: 500, cursor: 'pointer' } }, 'Delete')),
|
||||||
|
h('button', { onClick: () => { if (window.T31Store) window.T31Store.openInvestor(sh.orgId); }, style: { width: '100%', marginTop: 14, background: 'none', border: 'none', cursor: 'pointer', color: T.accentlight, fontSize: 13 } }, 'Open ' + sh.org + ' in Grid ›')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sh.kind === 'snooze') {
|
||||||
|
return h('div', null,
|
||||||
|
h('div', { style: { fontSize: 13, color: T.t3, marginBottom: 4 } }, 'For ' + sh.org),
|
||||||
|
label('Snooze until'),
|
||||||
|
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } }, this.snoozePresets().map(d =>
|
||||||
|
h('button', { key: d[1], onClick: () => { if (window.T31Store) window.T31Store.updateReminder(sh.id, { due: d[1], done: false }); this.setState({ sheet: null }); this.toast('Snoozed to ' + this.monthDay(d[1])); }, style: { width: '100%', height: 50, borderRadius: 8, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', border: '1px solid ' + T.border, background: T.input, color: T.t1, fontSize: 15, fontWeight: 500 } },
|
||||||
|
h('span', null, d[0]),
|
||||||
|
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 13, color: T.t4 } }, this.monthDay(d[1])))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add
|
||||||
|
if (!sh.orgId) {
|
||||||
|
const qn = (sh.q || '').trim().toLowerCase();
|
||||||
|
let pool = this.investorList();
|
||||||
|
if (qn) pool = pool.filter(i => i.name.toLowerCase().includes(qn));
|
||||||
|
return h('div', null,
|
||||||
|
h('div', { style: { fontSize: 13, color: T.t3, marginBottom: 12 } }, 'Which investor is this reminder for?'),
|
||||||
|
h('input', { value: sh.q, onChange: e => this.setSheet({ q: e.target.value }), style: inputStyle, placeholder: 'Search investor…', autoFocus: true }),
|
||||||
|
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 } }, pool.length ? pool.map(i =>
|
||||||
|
h('button', { key: i.id, onClick: () => this.setSheet({ orgId: i.id, org: i.name }), style: { width: '100%', textAlign: 'left', cursor: 'pointer', background: T.input, border: '1px solid ' + T.border, borderRadius: 10, padding: '13px 14px', color: T.t1, fontSize: 15, fontWeight: 500 } }, i.name)
|
||||||
|
) : h('div', { style: { fontSize: 13, color: T.t4, padding: '16px 4px' } }, 'No matches.'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return h('div', null,
|
||||||
|
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, background: T.input, border: '1px solid ' + T.border, borderRadius: 10, padding: '11px 13px' } },
|
||||||
|
h('span', { style: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 } },
|
||||||
|
h('span', { style: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '0.06em', textTransform: 'uppercase', color: T.t4 } }, 'Reminder for'),
|
||||||
|
h('span', { style: { fontSize: 15, fontWeight: 600, color: T.t1 } }, sh.org)),
|
||||||
|
h('button', { onClick: () => this.setSheet({ orgId: null }), style: { flex: 'none', background: 'none', border: 'none', color: T.accentlight, fontSize: 13, cursor: 'pointer' } }, 'Change')),
|
||||||
|
label('Reminder'),
|
||||||
|
h('input', { value: sh.note, onChange: e => this.setSheet({ note: e.target.value }), style: inputStyle, placeholder: 'What needs doing?', autoFocus: true }),
|
||||||
|
label('Due'),
|
||||||
|
dueChips(sh.due, v => this.setSheet({ due: v })),
|
||||||
|
h('button', { onClick: () => { if (window.T31Store) window.T31Store.addReminder(sh.orgId, sh.note.trim(), sh.due); this.setState({ sheet: null }); this.toast('Reminder added'); }, disabled: !sh.note.trim(), style: { width: '100%', height: 48, marginTop: 22, borderRadius: 8, border: 'none', background: !sh.note.trim() ? T.elev : 'linear-gradient(#3b82c4,#2f6ea9)', color: !sh.note.trim() ? T.t4 : '#fff', fontSize: 15, fontWeight: 600, cursor: 'pointer' } }, 'Add reminder')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tabIcon(key, active) {
|
||||||
|
const c = active ? '#3b82c4' : (this.state.theme === 'light' ? '#84909e' : '#70859b');
|
||||||
|
const mk = (children) => React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 20 20', fill: 'none' }, children);
|
||||||
|
const r = (pp) => React.createElement('rect', pp);
|
||||||
|
const ln = (pp) => React.createElement('line', Object.assign({}, pp, { stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }));
|
||||||
|
if (key === 'grid') return mk([
|
||||||
|
r({ key: 1, x: 3, y: 3, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 2, x: 11, y: 3, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 3, x: 3, y: 11, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 4, x: 11, y: 11, width: 6, height: 6, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
]);
|
||||||
|
if (key === 'pipeline') return mk([
|
||||||
|
r({ key: 1, x: 3, y: 3, width: 4.5, height: 14, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 2, x: 9.25, y: 3, width: 4.5, height: 10, rx: 1, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
r({ key: 3, x: 15.5, y: 3, width: 1.5, height: 6, rx: 0.7, fill: c }),
|
||||||
|
]);
|
||||||
|
if (key === 'reminders') return mk([
|
||||||
|
React.createElement('circle', { key: 1, cx: 10, cy: 11, r: 6.2, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
ln({ key: 2, x1: 10, y1: 11, x2: 10, y2: 7.5 }),
|
||||||
|
ln({ key: 3, x1: 10, y1: 11, x2: 12.4, y2: 12 }),
|
||||||
|
ln({ key: 4, x1: 7, y1: 3.4, x2: 4.4, y2: 5.4 }),
|
||||||
|
ln({ key: 5, x1: 13, y1: 3.4, x2: 15.6, y2: 5.4 }),
|
||||||
|
]);
|
||||||
|
return mk([
|
||||||
|
React.createElement('circle', { key: 1, cx: 10, cy: 7, r: 3.2, stroke: c, strokeWidth: 1.6 }),
|
||||||
|
React.createElement('path', { key: 2, d: 'M4 16.5c0-3 2.7-4.8 6-4.8s6 1.8 6 4.8', stroke: c, strokeWidth: 1.6, strokeLinecap: 'round' }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<script src="./support.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<x-dc>
|
||||||
|
<helmet>
|
||||||
|
<script src="store.js"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body { font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif; }
|
||||||
|
</style>
|
||||||
|
</helmet>
|
||||||
|
<div style="min-width:100%; min-height:100vh; width:max-content; box-sizing:border-box; padding:48px 56px 64px; background:#e7e5df; display:flex; flex-direction:column;">
|
||||||
|
<div style="font:700 22px 'IBM Plex Sans'; color:#161b22; letter-spacing:-0.01em;">Ten31 CRM — mobile prototype</div>
|
||||||
|
<div style="font:400 14px 'IBM Plex Sans'; color:#5a5f66; margin-top:8px; max-width:620px; line-height:1.5;">All four surfaces in one navigable app. The bottom tab bar switches between Grid, Pipeline, Reminders, and Contacts; theme and data are shared — a stage move, a logged communication, or a reminder edit on one tab shows up on the others. Tap around.</div>
|
||||||
|
<div style="display:flex; gap:48px; align-items:flex-start; margin-top:36px;">
|
||||||
|
<div style="display:flex; flex-direction:column; gap:16px; flex:none;">
|
||||||
|
<div style="font:600 12px 'IBM Plex Mono'; color:#46505b; letter-spacing:0.08em; text-transform:uppercase;">Ten31 CRM · {{ tabLabel }}</div>
|
||||||
|
<div style="width:393px; height:812px; background:#05080c; border-radius:52px; padding:11px; box-shadow:0 40px 80px -20px rgba(10,20,35,0.45), 0 0 0 2px #1c2530; flex:none;">
|
||||||
|
<div style="width:100%; height:100%; border-radius:42px; overflow:hidden; position:relative; background:{{ frameBg }};">
|
||||||
|
<sc-if value="{{ isGrid }}" hint-placeholder-val="{{ true }}">
|
||||||
|
<dc-import name="GridApp" theme="{{ theme }}" variant="compact" hint-size="100%,100%"></dc-import>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ isPipeline }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<dc-import name="PipelineApp" theme="{{ theme }}" mode="swipe" hint-size="100%,100%"></dc-import>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ isReminders }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<dc-import name="RemindersApp" theme="{{ theme }}" hint-size="100%,100%"></dc-import>
|
||||||
|
</sc-if>
|
||||||
|
<sc-if value="{{ isContacts }}" hint-placeholder-val="{{ false }}">
|
||||||
|
<dc-import name="ContactsApp" theme="{{ theme }}" hint-size="100%,100%"></dc-import>
|
||||||
|
</sc-if>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-dc>
|
||||||
|
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":620,"height":980}}">
|
||||||
|
class Component extends DCLogic {
|
||||||
|
componentDidMount() {
|
||||||
|
if (window.T31Store) this._unsub = window.T31Store.subscribe(() => this.forceUpdate());
|
||||||
|
}
|
||||||
|
componentWillUnmount() { if (this._unsub) this._unsub(); }
|
||||||
|
|
||||||
|
renderVals() {
|
||||||
|
const S = window.T31Store || { tab: 'grid', theme: 'dark' };
|
||||||
|
const map = { grid: 'GridApp', pipeline: 'PipelineApp', reminders: 'RemindersApp', contacts: 'ContactsApp' };
|
||||||
|
const labels = { grid: 'Grid', pipeline: 'Pipeline', reminders: 'Reminders', contacts: 'Contacts' };
|
||||||
|
const tab = map[S.tab] ? S.tab : 'grid';
|
||||||
|
return {
|
||||||
|
tabLabel: labels[tab],
|
||||||
|
theme: S.theme,
|
||||||
|
frameBg: S.theme === 'light' ? '#eaeef3' : '#0b1118',
|
||||||
|
isGrid: tab === 'grid', isPipeline: tab === 'pipeline',
|
||||||
|
isReminders: tab === 'reminders', isContacts: tab === 'contacts',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
/* Ten31 CRM — shared client store (single source of truth across the four mobile surfaces).
|
||||||
|
Mirrors the server model: investors carry stage/notes/funds/contacts/priority/last_activity;
|
||||||
|
reminders are their own collection keyed by investor. One canonical copy so a stage move,
|
||||||
|
logged communication, or reminder edit on any tab is reflected on every other tab.
|
||||||
|
Installed as a window singleton so it survives child-surface remounts within the shell. */
|
||||||
|
(function () {
|
||||||
|
if (window.T31Store) return;
|
||||||
|
|
||||||
|
var C = function (name, email) { return { name: name, email: email }; };
|
||||||
|
|
||||||
|
function seedInvestors() {
|
||||||
|
return [
|
||||||
|
{ id: 1, name: 'Northwall Capital', priority: true, stage: 'commitment', daysAgo: 2,
|
||||||
|
contacts: [C('Dana Reyes', 'dana@northwall.com'), C('Per Holt', 'per@northwall.com')],
|
||||||
|
funds: [['Ten31 Terahash', 1500000], ['Sats and Stats', 600000], ['Join the Fold', 400000]],
|
||||||
|
views: ['Main Fundraising', 'All Investors'],
|
||||||
|
notes: [['Email', 'Confirmed $2.5M allocation across funds', '2026-06-17'], ['Meeting', 'DD call — covered redemption terms', '2026-06-10']] },
|
||||||
|
{ id: 2, name: 'Brightseed Partners', priority: true, stage: 'engaged', daysAgo: 5,
|
||||||
|
contacts: [C('Omar Said', 'omar@brightseed.vc')], funds: [['Ten31 Terahash', 0]],
|
||||||
|
views: ['Main Fundraising', 'Follow-up List'], notes: [['Note', 'Intro from Polaris — warm', '2026-06-14']] },
|
||||||
|
{ id: 3, name: 'Cedarline Family Office', priority: false, stage: 'commitment', daysAgo: 7,
|
||||||
|
contacts: [C('Lena Cho', 'lena@cedarline.com')], funds: [['Ten31 Terahash', 800000], ['Pawn to F4', 400000]],
|
||||||
|
views: ['Main Fundraising', 'All Investors', 'Fund II investors'], notes: [['Call', 'Wire received, fully funded', '2026-06-12']] },
|
||||||
|
{ id: 4, name: 'Vance & Co', priority: false, stage: 'engaged', daysAgo: 3,
|
||||||
|
contacts: [C('Marcus Vance', 'mv@vanceco.com')], funds: [['Ten31 Terahash', 0]],
|
||||||
|
views: ['Main Fundraising', 'Follow-up List'], notes: [] },
|
||||||
|
{ id: 5, name: 'Polaris Endowment', priority: true, stage: 'diligence', daysAgo: 1,
|
||||||
|
contacts: [C('Ruth Almeida', 'ralmeida@polaris.org')], funds: [['Ten31 Terahash', 3000000], ['Sats and Stats', 2000000]],
|
||||||
|
views: ['Main Fundraising', 'All Investors', 'Follow-up List', 'Fund II investors'],
|
||||||
|
notes: [['Meeting', 'IC presentation went well', '2026-06-18'], ['Email', 'Sent data room access', '2026-06-15']] },
|
||||||
|
{ id: 6, name: 'Hartman Group', priority: false, stage: null, daysAgo: 14,
|
||||||
|
contacts: [], funds: [['Ten31 Terahash', 0]], views: ['Main Fundraising'], notes: [] },
|
||||||
|
{ id: 7, name: 'Meridian Trust', priority: false, stage: 'commitment', daysAgo: 4,
|
||||||
|
contacts: [C('Sofia Marin', 'sofia@meridiantrust.com')], funds: [['Ten31 Terahash', 800000]],
|
||||||
|
views: ['Main Fundraising', 'All Investors'], notes: [['Note', 'Signed side letter', '2026-06-14']] },
|
||||||
|
{ id: 8, name: 'Atlas Ventures Fund', priority: false, stage: 'engaged', daysAgo: 6,
|
||||||
|
contacts: [C('Will Tanaka', 'will@atlasvf.com')], funds: [['Ten31 Terahash', 0]],
|
||||||
|
views: ['Main Fundraising'], notes: [] },
|
||||||
|
{ id: 9, name: 'K. Whitfield', priority: false, stage: null, daysAgo: 21,
|
||||||
|
contacts: [C('Kira Whitfield', 'kira@whitfield.io')], funds: [],
|
||||||
|
views: ['Graveyard'], notes: [['Note', 'No allocation — parked', '2026-05-28']] },
|
||||||
|
{ id: 10, name: 'Granite Bay LP', priority: false, stage: 'commitment', daysAgo: 30,
|
||||||
|
contacts: [C('Tom Becker', 'tom@granitebay.com')], funds: [['Ten31 Terahash', 2000000], ['Sats and Stats', 1300000]],
|
||||||
|
views: ['Main Fundraising', 'All Investors', 'Fund II investors'], notes: [] },
|
||||||
|
{ id: 11, name: 'Forsythe Holdings', priority: false, stage: 'lead', daysAgo: 35,
|
||||||
|
contacts: [], funds: [], views: ['Graveyard'], notes: [] }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedReminders() {
|
||||||
|
return [
|
||||||
|
{ id: 1, note: 'Resend deck — bounced', orgId: 4, due: '2026-06-18', done: false },
|
||||||
|
{ id: 2, note: 'Re-engage — cold 2 weeks', orgId: 6, due: '2026-06-16', done: false },
|
||||||
|
{ id: 3, note: 'IC memo due', orgId: 5, due: '2026-06-19', done: false },
|
||||||
|
{ id: 4, note: 'Follow up after intro call', orgId: 2, due: '2026-06-19', done: false },
|
||||||
|
{ id: 5, note: 'Share data room link', orgId: 8, due: '2026-06-20', done: false },
|
||||||
|
{ id: 6, note: 'Countersign side letter', orgId: 7, due: '2026-06-21', done: false },
|
||||||
|
{ id: 7, note: 'Send Q2 update deck', orgId: 1, due: '2026-06-24', done: false },
|
||||||
|
{ id: 8, note: 'Quarterly check-in call', orgId: 3, due: '2026-07-08', done: false },
|
||||||
|
{ id: 9, note: 'Thank-you note post-wire', orgId: 10, due: '2026-06-13', done: true }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
var store = {
|
||||||
|
investors: seedInvestors(),
|
||||||
|
reminders: seedReminders(),
|
||||||
|
tab: 'grid',
|
||||||
|
theme: 'dark',
|
||||||
|
focusInvestorId: null,
|
||||||
|
today: new Date(2026, 5, 19),
|
||||||
|
_subs: [],
|
||||||
|
|
||||||
|
subscribe: function (fn) {
|
||||||
|
this._subs.push(fn);
|
||||||
|
var self = this;
|
||||||
|
return function () { self._subs = self._subs.filter(function (f) { return f !== fn; }); };
|
||||||
|
},
|
||||||
|
_notify: function () { this._subs.slice().forEach(function (f) { try { f(); } catch (e) {} }); },
|
||||||
|
|
||||||
|
setTab: function (t) { this.tab = t; this._notify(); },
|
||||||
|
openInvestor: function (id) { this.focusInvestorId = id; this.tab = 'grid'; this._notify(); },
|
||||||
|
setTheme: function (t) { this.theme = t; this._notify(); },
|
||||||
|
toggleTheme: function () { this.theme = this.theme === 'light' ? 'dark' : 'light'; this._notify(); },
|
||||||
|
|
||||||
|
// ----- investor mutations -----
|
||||||
|
updateInvestor: function (id, patch) {
|
||||||
|
this.investors = this.investors.map(function (i) { return i.id === id ? Object.assign({}, i, patch) : i; });
|
||||||
|
this._notify();
|
||||||
|
},
|
||||||
|
logNote: function (id, entry) {
|
||||||
|
this.investors = this.investors.map(function (i) { return i.id === id ? Object.assign({}, i, { notes: [entry].concat(i.notes), daysAgo: 0 }) : i; });
|
||||||
|
this._notify();
|
||||||
|
},
|
||||||
|
addInvestor: function (inv) {
|
||||||
|
var id = this.investors.reduce(function (m, i) { return Math.max(m, i.id); }, 0) + 1;
|
||||||
|
var ni = Object.assign({ id: id, priority: false, stage: 'lead', daysAgo: 0, contacts: [], funds: [['Ten31 Terahash', 0]], views: ['Main Fundraising'], notes: [] }, inv);
|
||||||
|
this.investors = [ni].concat(this.investors);
|
||||||
|
this._notify();
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----- reminder mutations -----
|
||||||
|
addReminder: function (orgId, note, due) {
|
||||||
|
var id = this.reminders.reduce(function (m, r) { return Math.max(m, r.id); }, 0) + 1;
|
||||||
|
this.reminders = [{ id: id, note: note, orgId: orgId, due: due, done: false }].concat(this.reminders);
|
||||||
|
this._notify();
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
updateReminder: function (id, patch) {
|
||||||
|
this.reminders = this.reminders.map(function (r) { return r.id === id ? Object.assign({}, r, patch) : r; });
|
||||||
|
this._notify();
|
||||||
|
},
|
||||||
|
deleteReminder: function (id) {
|
||||||
|
this.reminders = this.reminders.filter(function (r) { return r.id !== id; });
|
||||||
|
this._notify();
|
||||||
|
},
|
||||||
|
toggleReminder: function (id) {
|
||||||
|
this.reminders = this.reminders.map(function (r) { return r.id === id ? Object.assign({}, r, { done: !r.done }) : r; });
|
||||||
|
this._notify();
|
||||||
|
},
|
||||||
|
reminderFor: function (orgId) {
|
||||||
|
// the soonest open reminder for an investor (used by the Grid detail)
|
||||||
|
var open = this.reminders.filter(function (r) { return r.orgId === orgId && !r.done; });
|
||||||
|
open.sort(function (a, b) { return a.due < b.due ? -1 : 1; });
|
||||||
|
return open[0] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----- derived helpers -----
|
||||||
|
investorById: function (id) { return this.investors.find(function (i) { return i.id === id; }); },
|
||||||
|
committed: function (i) { return (i.funds || []).reduce(function (a, f) { return a + f[1]; }, 0); },
|
||||||
|
money: function (n) {
|
||||||
|
if (!n) return '$0';
|
||||||
|
if (n >= 1e6) return '$' + (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M';
|
||||||
|
if (n >= 1e3) return '$' + Math.round(n / 1e3) + 'K';
|
||||||
|
return '$' + n;
|
||||||
|
},
|
||||||
|
parseDate: function (iso) { var p = iso.split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); },
|
||||||
|
diffDays: function (iso) { return Math.round((this.parseDate(iso) - this.today) / 86400000); },
|
||||||
|
monthDay: function (iso) { var d = this.parseDate(iso); return MONTHS[d.getMonth()] + ' ' + d.getDate(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.T31Store = store;
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
|||||||
|
# Design brief — Ten31 CRM mobile-first redesign
|
||||||
|
|
||||||
|
*The input packet for a Claude Design (or equivalent) round-trip. Goal: make the phone a
|
||||||
|
first-class, **preferred** surface for the Ten31 CRM without losing the existing look. This
|
||||||
|
is a **layout / information-architecture / interaction** redesign — the visual language is
|
||||||
|
captured in `design/DESIGN.md` + `design/tokens.tokens.json` and is **preserved**. Posture:
|
||||||
|
**preserve-but-refine** — keep the brand DNA; small mobile-warranted tweaks (type scale,
|
||||||
|
density, touch sizing) are welcome, a visual reskin is not.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The one instruction that matters most
|
||||||
|
|
||||||
|
**Preserve the visual language; redesign only layout, navigation, and touch interaction.**
|
||||||
|
The current app is already a coherent, deliberate dark venture-CRM look (see `DESIGN.md`).
|
||||||
|
Do **not** reinvent the palette, typography, or component styling. Where mobile genuinely
|
||||||
|
warrants it, you may refine — bump body type from 13px toward 15–16px, loosen touch density,
|
||||||
|
add bottom-sheet patterns — but the colors, the IBM Plex faces, the bordered-panel +
|
||||||
|
tinted-badge idiom, and the single `#3b82c4` accent stay.
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
The Ten31 CRM is the fund's system of record (~150 LPs, 250+ prospects, the capital-raise
|
||||||
|
pipeline), used by a ~5-person team. Today it's desktop-first; the team increasingly works
|
||||||
|
from phones — before/after investor meetings, on the move. Make **mobile the primary,
|
||||||
|
preferred surface**: every common on-the-go task is thumb-reachable and fast, and desktop
|
||||||
|
becomes the wide-screen enhancement rather than the baseline.
|
||||||
|
|
||||||
|
**Mobile is a focused subset, not the whole app** (decided 2026-06-18). Mobile carries only
|
||||||
|
the on-the-go core; everything else stays desktop-only.
|
||||||
|
|
||||||
|
- **Mobile surfaces — the only four:** **Fundraising Grid** (with fast switching between its
|
||||||
|
saved *views*), **Pipeline**, **Reminders**, **Contacts**. Every other screen — Dashboard,
|
||||||
|
Thesis, Thesis Workshop, Outreach, Communications, Email Capture, System Status, Feedback,
|
||||||
|
Instructions, Settings — is **desktop-only and simply absent** from the mobile UI (keep only
|
||||||
|
a minimal account/logout control).
|
||||||
|
- **Mobile editing — core records + quick capture (expanded 2026-06-18):** read everything;
|
||||||
|
editable on mobile is **investor name**, **contacts (name + email)**, **notes / communication
|
||||||
|
/ outreach log** (logging activity, not composing/sending — see §3a Backend reality),
|
||||||
|
**pipeline stage**, and **reminders** — *and* **creating a new investor**
|
||||||
|
(name + one or more contacts + type/stage). Still **desktop-only**: commitments/amounts, the
|
||||||
|
full 20+ column set, column structure, bulk ops, and CSV. So mobile is "create and manage the
|
||||||
|
core investor record + log activity," not the full spreadsheet.
|
||||||
|
- **Create/edit investors go through the Grid — the canonical write path — never Contacts.**
|
||||||
|
Per the app's model, the `fundraising_*` grid is the system of record (investor row → contact
|
||||||
|
"pills" → commitments) and the **Contacts tab is a read-only directory auto-populated from
|
||||||
|
it**. So the **"+ add investor"** entry point and all name/contact/email edits live on the
|
||||||
|
**Grid** (its card list and detail); **Contacts stays read-only** on mobile — do not put an
|
||||||
|
add/edit affordance there.
|
||||||
|
- **Guard against duplicate investors on create.** Adding an investor from a phone is a
|
||||||
|
dupe-generation risk; the app already has entity resolution/merge. The mobile "+ add
|
||||||
|
investor" flow should **check for an existing match first** (search-as-you-type on name)
|
||||||
|
before creating a new row.
|
||||||
|
- **The Grid is card/detail on a phone, never a spreadsheet.** A row is one investor; mobile
|
||||||
|
shows an investor card list → full-screen detail with the editable set above, plus a create flow.
|
||||||
|
|
||||||
|
## 2. Layout (global, mobile-first)
|
||||||
|
|
||||||
|
- **Base = mobile; enhance up.** Author layout for a 375px column first, then add `min-width`
|
||||||
|
breakpoints for tablet/desktop. (Implementation note for later: the app's ~1300 inline
|
||||||
|
`style={{}}` objects can't respond to media queries — responsive layout must live in the
|
||||||
|
CSS `<style>` block / utility classes. The design tool doesn't need to solve this, but the
|
||||||
|
brief should assume layout, not inline style, carries responsiveness.)
|
||||||
|
- **Navigation → a 4-tab bottom bar; everything else is desktop-only.** Replace the 250px
|
||||||
|
sidebar with a **bottom tab bar of exactly four**: **Grid · Pipeline · Reminders ·
|
||||||
|
Contacts**. The other ten destinations are **not** present on mobile — there is no "More"
|
||||||
|
feature menu. Keep only a minimal **account control** (e.g. a top-bar avatar/menu) for
|
||||||
|
profile + logout. The bar respects `env(safe-area-inset-bottom)`.
|
||||||
|
- **Grid views are a first-class mobile control (high priority — second only to the tabs).**
|
||||||
|
The Grid's saved *views* (Main, Follow-up, All Investors, … and a growing set of
|
||||||
|
filter-based cuts) are how the team reads the investor list different ways. Surface a **view
|
||||||
|
picker at the top of the Grid screen** — a tappable current-view header that opens a
|
||||||
|
**bottom-sheet list of views**. Use a sheet/dropdown, not a fixed segmented control, because
|
||||||
|
the set of views grows over time. Switching a view re-filters the card list in place.
|
||||||
|
- **Overlays → bottom sheets.** Today's centered modal (500px) and right slide-over (400px,
|
||||||
|
which overflows a phone) become **drag-to-dismiss bottom sheets** or full-screen detail
|
||||||
|
views on mobile.
|
||||||
|
- **Touch + safe areas.** 44px minimum touch targets; sticky bottom nav respects
|
||||||
|
`env(safe-area-inset-bottom)`; content gets bottom padding so the nav never overlaps it.
|
||||||
|
|
||||||
|
## 3. Per-screen briefs
|
||||||
|
|
||||||
|
Take the design tool through these **one screen at a time** (mobile re-layout is screen-by-
|
||||||
|
screen, not a global token swap). Ordered by value and difficulty.
|
||||||
|
|
||||||
|
### 3a. Fundraising Grid — the crux (do first)
|
||||||
|
The core feature and system of record: a 20+ column, ~3,000px-wide editable table. A row =
|
||||||
|
**one investor** (contact "pills" + per-fund commitments). On a phone it is a **card list**,
|
||||||
|
never a wide table.
|
||||||
|
- **View switching, up top (high priority):** a tappable current-view name → **bottom-sheet
|
||||||
|
view picker** (Main / Follow-up / All Investors / … a growing set), plus search. Switching
|
||||||
|
re-filters the card list in place.
|
||||||
|
- **Card:** ~5 at-a-glance fields — **name · type badge · committed amount · pipeline stage ·
|
||||||
|
last contact**. Tap → **full-screen investor detail** (today's slide-over, promoted).
|
||||||
|
- **Detail + edit:** the full field set is grouped into sections and **read-only**, *except*
|
||||||
|
the editable set: **investor name**, **contacts (name + email — the contact "pills")**,
|
||||||
|
**notes / communication / outreach** (a text area or "log a note" entry), **pipeline stage**
|
||||||
|
(a picker / segmented control), and **set/update a reminder** (date + note). Each edit happens
|
||||||
|
in a **bottom sheet**, one field at a time — no spreadsheet grid. Commitments/amounts and the
|
||||||
|
rest of the columns stay read-only on mobile.
|
||||||
|
- **Add investor (`+` on the Grid):** a create flow capturing **investor name + one or more
|
||||||
|
contacts (name, email) + type + stage** — the minimum to start a record; the rest is filled
|
||||||
|
later on desktop. **Search-as-you-type on name first** and offer existing matches before
|
||||||
|
creating, so a phone-added investor doesn't duplicate one already in the grid.
|
||||||
|
- The full multi-column spreadsheet (commitments/amounts, column reorder, bulk/CSV) stays
|
||||||
|
**desktop-only**.
|
||||||
|
|
||||||
|
> **Backend reality (read before designing the edit interactions — the write layer is not
|
||||||
|
> what "edit a field in a sheet" implies):**
|
||||||
|
> - **There is no field-level write. The grid is one JSON blob saved wholesale.**
|
||||||
|
> `PUT /api/fundraising/state` takes the *entire* grid and rejects with **409** if the global
|
||||||
|
> `version` moved under you (5 people edit live). So a naive "edit one field" = load the whole
|
||||||
|
> grid → mutate one row → PUT it all back → race everyone else. **Mobile single-investor edits
|
||||||
|
> (name, pills, add-investor, log-note) should instead go through the targeted, server-side,
|
||||||
|
> one-row path** `POST /api/fundraising/log-communication` (it finds/creates a single row,
|
||||||
|
> appends a note, and can create a new investor + first contact in one call via
|
||||||
|
> `create_investor_if_missing`, with no whole-grid version race) — or a new narrow per-row
|
||||||
|
> PATCH. Do **not** model these as whole-grid saves. The Matrix bot already uses this path.
|
||||||
|
> - **"Pipeline stage" is not a grid field** — it lives on the separate `opportunities` table and
|
||||||
|
> editing it is a **two-call flow with a precondition**: the row must first be *linked* to a
|
||||||
|
> pipeline opp (`POST /api/fundraising/pipeline/link`, which **requires ≥1 contact on the row**),
|
||||||
|
> *then* `PATCH /api/opportunities/{id}/stage`. The grid only *displays* stage read-only. So the
|
||||||
|
> detail-sheet "change stage" control needs a "not in pipeline yet → add to pipeline" state, and
|
||||||
|
> it shares the *same* opportunities endpoint as the Pipeline tab (3c) — consistent with that, not
|
||||||
|
> with the grid blob.
|
||||||
|
> - **Removing a contact pill has no tombstone/undo** — the `fundraising_*` tables are rebuilt on
|
||||||
|
> every save; the JSON blob is canonical. Don't promise soft-delete/undo semantics for pill
|
||||||
|
> removal (unlike comms/reminders, which *are* soft-deleted).
|
||||||
|
> - **Dedup typeahead is client-side** — filter the already-loaded rows; there is no investor-search
|
||||||
|
> endpoint, and the app's `entity_merge` is an admin-only *after-the-fact* reconciliation, not a
|
||||||
|
> create-time guard.
|
||||||
|
> - **"Notes / communication" = the `log-communication` path above (immediate write).** **Outreach
|
||||||
|
> *composition*** is a different, **gated** feature (agent drafts → human edits → human sends; the
|
||||||
|
> Outreach screen is desktop-only) — only the investor's outreach *log/notes* belong on mobile.
|
||||||
|
> - All of these are **plain-authenticated immediate writes** — a human **member** can do them on a
|
||||||
|
> phone; there is no draft→approve gate on a *human's own* edit (that gate is for *agent*-originated
|
||||||
|
> actions). "Agents draft, humans send" constrains the bot, not this UI.
|
||||||
|
|
||||||
|
### 3b. Contacts — lowest-risk transform (good pattern validator)
|
||||||
|
A **read-only** per-person directory (auto-populated from the Grid — no create/edit here),
|
||||||
|
today a table + tabs (All / Investors / Prospects) + a detail slide-over.
|
||||||
|
- **Mobile pattern:** a **list of contact rows** (initial/avatar · name · organization · type
|
||||||
|
badge · last contact) with the tabs as a top segmented control and search pinned → tap →
|
||||||
|
**full-screen read-only detail** (contact info, linked investor, communication history).
|
||||||
|
- Pure browse→detail, no edit — validate the list+detail+sheet pattern here before the Grid.
|
||||||
|
|
||||||
|
### 3c. Pipeline (Kanban) — re-think the horizontal board
|
||||||
|
Today: horizontal kanban columns, one per stage (count + total per column), tap card → edit.
|
||||||
|
Horizontal columns don't work on a phone.
|
||||||
|
- **Mobile pattern (lead option):** **swipe between stage columns** — one full-width stage at
|
||||||
|
a time, snap-scrolling, a stage indicator/segmented control at top, vertical list of
|
||||||
|
investor cards within each stage. (Alternative to weigh: a vertical accordion of collapsible
|
||||||
|
stages.) Tap a card → the same investor detail sheet as the Grid. **Editing pipeline stage**
|
||||||
|
is one of the mobile-editable fields, so make stage changeable here too (a stage picker on the
|
||||||
|
card, or drag/swipe a card to the next stage).
|
||||||
|
|
||||||
|
### 3d. Reminders — a primary tab + an edit surface
|
||||||
|
A follow-up/tickler list tied to investors (one of the mobile-editable areas).
|
||||||
|
- **Mobile pattern:** a **list grouped by urgency** (overdue → due-soon → later), each row
|
||||||
|
showing title, investor, due date (urgency-colored: overdue `#e06c6c`, due-soon `#e0b341`),
|
||||||
|
and assignee. **Quick actions** (done / snooze / edit) via swipe or a row menu; a **`+`**
|
||||||
|
creates one. Tap → a **bottom-sheet edit** (title, investor, due date, note, assignee). This
|
||||||
|
is also the editor reached from an investor detail's "set a reminder" action.
|
||||||
|
|
||||||
|
## 4. Brand description (~120 words)
|
||||||
|
|
||||||
|
Ten31's CRM is a *trustworthy instrument* — a dense, dark, data-forward venture-fund
|
||||||
|
workspace for a small team handling sensitive LP relationships. The voice is serious,
|
||||||
|
discreet, and precise: cool blue-greys, a single confident blue accent, IBM Plex's
|
||||||
|
engineered-but-humane type, monospace for every number and date. It feels like a well-made
|
||||||
|
financial terminal, not a consumer app — restraint and legibility over decoration. The
|
||||||
|
mobile version should feel like the *same instrument in your pocket*: calmer and roomier for
|
||||||
|
touch, but unmistakably the same tool. It is **not** playful, colorful, skeuomorphic, or
|
||||||
|
trend-chasing; **not** a second bright color or a borderless/flat reskin. Quiet confidence,
|
||||||
|
information density made thumb-friendly.
|
||||||
|
|
||||||
|
## 5. Inputs to bring to the cloud tool
|
||||||
|
|
||||||
|
- **Point at:** `frontend/` (the single `index.html` holds the whole UI — point at the
|
||||||
|
directory, not the repo root).
|
||||||
|
- **Upload:** `design/DESIGN.md`, `design/tokens.tokens.json`, `design/brand/ten31-logo-white.svg`,
|
||||||
|
`design/brand/ten31-favicon.svg`, and **screenshots of each screen** below at desktop width
|
||||||
|
(Fundraising Grid, Contacts, Pipeline, Dashboard, a modal, the slide-over) so the tool sees
|
||||||
|
the as-built look it must preserve.
|
||||||
|
- **Web-capture:** the running app URL if convenient (it's behind auth on the Start9 box; a
|
||||||
|
set of screenshots is the reliable path).
|
||||||
|
|
||||||
|
## 6. Prompt blocks (paste into the design tool, one per screen)
|
||||||
|
|
||||||
|
**Global frame (paste first):**
|
||||||
|
> Redesign this dark venture-CRM web app to be mobile-first and mobile-preferred. **Preserve
|
||||||
|
> the existing visual language exactly** (see the uploaded DESIGN.md + tokens: dark blue-grey
|
||||||
|
> palette, single `#3b82c4` accent, IBM Plex Sans/Mono, bordered panels, tinted badges) —
|
||||||
|
> change only **layout, navigation, and touch interaction**. Small mobile-warranted refinements
|
||||||
|
> (body type 13→15–16px, looser touch density, bottom sheets) are welcome; no visual reskin.
|
||||||
|
> Mobile carries only **four surfaces in a bottom tab bar — Grid, Pipeline, Reminders,
|
||||||
|
> Contacts**; every other screen is desktop-only and absent here (keep just a minimal top-bar
|
||||||
|
> account/logout menu). Convert centered modals and the right slide-over into **drag-to-dismiss
|
||||||
|
> bottom sheets / full-screen detail views**. 44px touch targets, safe-area-aware sticky bottom
|
||||||
|
> nav. Design for a 375px phone first.
|
||||||
|
|
||||||
|
**Fundraising Grid:**
|
||||||
|
> This is a 20+ column editable data table; each row is one investor (contact pills + per-fund
|
||||||
|
> commitments) — unusable as a wide table on a phone. Design a mobile **investor card list**:
|
||||||
|
> card shows name, type badge, committed amount, pipeline stage, last contact. At the top, a
|
||||||
|
> **tappable current-view name that opens a bottom-sheet list of saved views** (Main, Follow-up,
|
||||||
|
> All Investors, and a growing set of filtered cuts) plus search; switching a view re-filters
|
||||||
|
> the list. Tapping a card opens a **full-screen detail**: most fields read-only **except** the
|
||||||
|
> editable set — **investor name**, **contacts (name + email)**, **notes/communication/outreach**,
|
||||||
|
> **pipeline stage**, and **set a reminder** — each edited in a bottom sheet. Add a **`+` to
|
||||||
|
> create a new investor** (name + one or more contacts with name/email + type + stage), with
|
||||||
|
> search-as-you-type on name to surface existing matches before creating (avoid duplicates).
|
||||||
|
> Commitments/amounts and the full column set stay read-only / desktop-only.
|
||||||
|
|
||||||
|
**Contacts:**
|
||||||
|
> A read-only people directory (no create/edit here). Today it's a table with All/Investors/
|
||||||
|
> Prospects tabs and a detail panel. Design a mobile **contact list** (initial, name,
|
||||||
|
> organization, type badge, last contact), tabs as a top segmented control, search pinned;
|
||||||
|
> tapping a row opens a **full-screen read-only detail** with contact info, linked investor,
|
||||||
|
> and communication history.
|
||||||
|
|
||||||
|
**Pipeline (Kanban):**
|
||||||
|
> A kanban board of pipeline stages (one column per stage, each showing count + total),
|
||||||
|
> cards are investors. Horizontal columns don't fit a phone. Design a **swipe-between-stages**
|
||||||
|
> mobile view: one full-width stage at a time with snap-scrolling and a stage segmented
|
||||||
|
> control at top, a vertical list of investor cards within each stage; tapping a card opens
|
||||||
|
> the investor detail sheet. Stage is editable here (a stage picker on the card, or drag a card
|
||||||
|
> to the next stage). Also sketch a vertical-accordion alternative for comparison.
|
||||||
|
|
||||||
|
**Reminders:**
|
||||||
|
> A follow-up/tickler list tied to investors. Design a mobile **reminders list grouped by
|
||||||
|
> urgency** (overdue, due-soon, later); each row shows title, investor, due date (urgency-
|
||||||
|
> colored), assignee, with quick actions (done / snooze / edit) via swipe or a row menu and a
|
||||||
|
> `+` to create. Tapping a row opens a **bottom-sheet edit** (title, investor, due date, note,
|
||||||
|
> assignee).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. After the round-trip (Phase C reminder)
|
||||||
|
|
||||||
|
Export the **"Handoff to Claude Code" bundle** + screenshots, drop them in
|
||||||
|
`design/_imports/<date>/`, then distill back into the contract: update `DESIGN.md` §8
|
||||||
|
(Responsive behavior) with the real mobile-first system, add mobile component states (bottom
|
||||||
|
nav, sheets, card list) to §4, and bump the mobile type scale in `tokens.tokens.json` if it
|
||||||
|
changed. The gap between the new contract and the current `index.html` is the implementation
|
||||||
|
backlog → capture it to `ROADMAP.md` (incl. the inline-style→CSS migration that makes
|
||||||
|
responsive layout possible at all). Do not silently reskin existing code in the same pass.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Ten31 CRM — Design contract (DESIGN.md)
|
||||||
|
|
||||||
|
*The durable brand brief. Any agent (or person) building or changing user-facing UI reads
|
||||||
|
this file and `design/tokens.tokens.json` first and conforms to them. Extracted **as-built**
|
||||||
|
from `frontend/index.html` on 2026-06-18 (document-as-is); this is a faithful record of the
|
||||||
|
look that grew in the code, not an aspirational redesign. The mobile-first redesign in
|
||||||
|
`design/BRIEF.md` builds **on top of** this contract — it changes layout/navigation/touch,
|
||||||
|
not the visual language below.*
|
||||||
|
|
||||||
|
## 1. Visual theme
|
||||||
|
|
||||||
|
A dense, professional, **dark** venture-CRM workspace — calm, data-forward, slightly
|
||||||
|
"terminal/financial." Cool blue-greys throughout, a single saturated blue as the only vivid
|
||||||
|
accent, and IBM Plex's engineered-but-humane character. Monospace (IBM Plex Mono) carries
|
||||||
|
all numbers, dates, and codes, reinforcing the data-tool feel. The mood is *trustworthy
|
||||||
|
instrument*, not consumer-app playful: restraint, legibility, and information density over
|
||||||
|
decoration. It is used by a ~5-person fund team handling sensitive LP data, so it should
|
||||||
|
read as serious and discreet.
|
||||||
|
|
||||||
|
## 2. Color palette
|
||||||
|
|
||||||
|
Canonical values live in `design/tokens.tokens.json`. Summary:
|
||||||
|
|
||||||
|
- **Backgrounds (darkest → lightest):** base `#0b1118` → panel `#111a27` → elevated
|
||||||
|
`#152233` → hover `#1b2a3a`. Recessed input/table-header surface: `#0d1622`.
|
||||||
|
- **Borders:** default `#263548` (the workhorse — borders, dividers, every table grid line),
|
||||||
|
strong `#35506a` (hover/emphasis).
|
||||||
|
- **Text:** primary `#e5edf5`, secondary `#c7d3e0`, muted `#8ea2b7` (most common),
|
||||||
|
subtle `#70859b`.
|
||||||
|
- **Accent (the one brand color):** `#3b82c4`. Hover/gradient-end `#2f6ea9`, tint
|
||||||
|
`#3b82c422`, on-tint text `#93c5fd`. Used for primary actions, active nav, links, focus.
|
||||||
|
- **Semantic:** success `#10b981`, warning `#f59e0b`/`#fcd34d`, due-soon `#e0b341`,
|
||||||
|
danger `#dc2626`/`#e06c6c`, error-text `#fca5a5`. Badges render semantic color at low
|
||||||
|
alpha for the fill with a lighter tint for the text.
|
||||||
|
|
||||||
|
White (`#ffffff`) appears only as text on accent fills and in the brand mark.
|
||||||
|
|
||||||
|
## 3. Typography
|
||||||
|
|
||||||
|
- **Families:** `IBM Plex Sans` (UI/body), `IBM Plex Mono` (numbers, dates, badges, logs,
|
||||||
|
nav icons). Loaded from Google Fonts; weights 400/500/600/700 sans, 500/600 mono.
|
||||||
|
- **Scale (as-built):** 11px micro/table-header/badge · 12px help/meta · **13px body / table
|
||||||
|
/ inputs (desktop base)** · 14px nav · 16px section title · 18px modal title · 20px page
|
||||||
|
title · 24px login title & KPI value.
|
||||||
|
- **Treatments:** global letter-spacing `0.01em`; table headers uppercase with `0.08em`
|
||||||
|
tracking; badges uppercase `0.5px`; numbers use `font-variant-numeric: tabular-nums`.
|
||||||
|
- **Mobile note:** 13px body is comfortable for desktop and tight on a phone — the redesign
|
||||||
|
bumps the mobile base toward 15–16px. See `BRIEF.md`.
|
||||||
|
|
||||||
|
## 4. Component styling
|
||||||
|
|
||||||
|
- **Buttons:** primary = top-to-bottom gradient `#3b82c4 → #2f6ea9`, white text, radius 6px,
|
||||||
|
padding 10×16, lift on hover (`translateY(-1px)` + soft blue shadow). Secondary = flat
|
||||||
|
`#1b2837`. Danger = `#dc2626`.
|
||||||
|
- **Cards / sections:** panel `#111a27`, 1px `#263548` border, radius 8px, padding 16–20px,
|
||||||
|
composite drop shadow + inset top highlight. Hover lifts 1px and strengthens the border.
|
||||||
|
- **Tables:** recessed header `#0d1622`, uppercase muted 11px headers, sticky header +
|
||||||
|
sticky first column, 1px grid lines, row hover `#172435`, sticky aggregate footer.
|
||||||
|
- **Forms:** inputs on `#0d1622`, 1px border, radius 6px, accent focus ring; label 13/500,
|
||||||
|
help 12px muted, error 12px `#fca5a5`.
|
||||||
|
- **Badges:** radius 4px, 11px/600 uppercase, low-alpha semantic fill + tinted text.
|
||||||
|
- **Overlays:** modal (centered, radius 12, max-width 500, blurred backdrop) and slide-over
|
||||||
|
(right drawer, 400px) for detail/edit.
|
||||||
|
- **Other:** kanban cards (radius 6), toasts (bottom-right), accent spinner, shimmer
|
||||||
|
skeletons, left-marker timeline for activity feeds.
|
||||||
|
|
||||||
|
## 5. Layout
|
||||||
|
|
||||||
|
Desktop shell = **fixed 250px left sidebar + flexible main content** with a top header bar
|
||||||
|
(page title + user). Content max-width 1400px, padding 20px. Primary content patterns:
|
||||||
|
wide data table (Fundraising Grid), KPI grid + timelines (Dashboard), kanban columns
|
||||||
|
(Pipeline), list + detail drawer (Contacts/Reminders), two-column (Thesis). Auto-fit grids
|
||||||
|
are used for KPI cards (`minmax(180px,1fr)`) and kanban columns (`minmax(300px,1fr)`).
|
||||||
|
|
||||||
|
## 6. Depth / elevation
|
||||||
|
|
||||||
|
Elevation is built from three stacked cues, not just one shadow: (a) a **layered radial-
|
||||||
|
gradient page background** (two soft blue glows top-left and top-right over `#0b1118`),
|
||||||
|
(b) panel-color steps (base → panel → elevated), and (c) composite drop shadows with a
|
||||||
|
1px inset white top-highlight (`inset 0 1px 0 #ffffff07-08`) that gives panels a faint lit
|
||||||
|
edge. Hover states lift elements 1px and deepen the shadow. Keep this restrained, cool, and
|
||||||
|
low-contrast — depth should be felt, not seen.
|
||||||
|
|
||||||
|
## 7. Do's and don'ts
|
||||||
|
|
||||||
|
- **Do** keep `#3b82c4` as the only vivid accent; everything else stays in the blue-grey
|
||||||
|
range. **Don't** introduce a second bright hue for emphasis — use weight, tint, or the
|
||||||
|
semantic colors.
|
||||||
|
- **Do** use IBM Plex Mono for every number/date/code. **Don't** set tabular data in the
|
||||||
|
sans face.
|
||||||
|
- **Do** reach for tinted-fill + tinted-text badges for status. **Don't** use solid
|
||||||
|
saturated fills for status chips.
|
||||||
|
- **Do** keep borders at `#263548` and lean on them for structure (this is a grid-lined,
|
||||||
|
bordered aesthetic). **Don't** switch to borderless/shadow-only cards.
|
||||||
|
- **Do** preserve the restrained 1px hover-lift motion. **Don't** add bouncy or long
|
||||||
|
animations (and honor `prefers-reduced-motion`, already wired).
|
||||||
|
|
||||||
|
## 8. Responsive behavior
|
||||||
|
|
||||||
|
**Current (as-built): desktop-first.** Breakpoints are `max-width` (900px, 768px); the
|
||||||
|
sidebar simply `display:none`s below 768px with no real mobile navigation; wide tables
|
||||||
|
overflow horizontally; the 400px slide-over overflows a 375px screen. A correct viewport
|
||||||
|
meta tag is present.
|
||||||
|
|
||||||
|
**Target: mobile-first, mobile-preferred — active redesign.** The team increasingly works
|
||||||
|
from phones, so mobile is becoming the primary surface. The full plan (navigation
|
||||||
|
re-architecture to a bottom tab bar, table→card transforms, bottom sheets, touch sizing,
|
||||||
|
type bump) lives in **`design/BRIEF.md`**. Update this section to describe the new
|
||||||
|
mobile-first system once that redesign lands.
|
||||||
|
|
||||||
|
## 9. Agent prompt guide
|
||||||
|
|
||||||
|
When building or changing UI here:
|
||||||
|
- Pull every color, size, radius, and space value from `design/tokens.tokens.json` — do not
|
||||||
|
hand-pick new hexes. The app still inlines many values; prefer the `:root` CSS variables
|
||||||
|
(and grow that set) over fresh literals.
|
||||||
|
- Match the established components above; if you need a new one, compose it from existing
|
||||||
|
tokens and the bordered-panel + tinted-badge idiom rather than inventing a new visual style.
|
||||||
|
- Numbers/dates/codes → IBM Plex Mono + tabular-nums. Status → tinted badge. Primary action →
|
||||||
|
the blue gradient button. Destructive → `#dc2626` and a confirm step.
|
||||||
|
- **Building anything mobile/responsive?** Read `design/BRIEF.md` first — it holds the
|
||||||
|
mobile-first layout, navigation, and interaction decisions this section will eventually
|
||||||
|
absorb.
|
||||||
|
- Reminder: inline `style={{}}` objects cannot respond to media queries — put any
|
||||||
|
responsive layout in the CSS `<style>` block (or a utility class), not inline.
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"$description": "Ten31 CRM design tokens (W3C DTCG). Extracted as-built from frontend/index.html :root + an inline-style census, 2026-06-18. The app currently inlines these values (CSS :root vars + ~1300 inline style objects); this file is the canonical source going forward. Some real values (composite shadows, the radial-gradient page background) do not map to DTCG primitives and are documented as strings.",
|
||||||
|
"color": {
|
||||||
|
"bg": {
|
||||||
|
"base": { "$type": "color", "$value": "#0b1118", "$description": "Page background (darkest layer). Also the de-facto theme color; use for a future PWA manifest theme_color." },
|
||||||
|
"panel": { "$type": "color", "$value": "#111a27", "$description": "Cards, sections, modals, sidebar, slide-over." },
|
||||||
|
"elevated": { "$type": "color", "$value": "#152233", "$description": "Elevated/hover panel state." },
|
||||||
|
"hover": { "$type": "color", "$value": "#1b2a3a", "$description": "Generic hover background." },
|
||||||
|
"input": { "$type": "color", "$value": "#0d1622", "$description": "Form input + table-header background (recessed)." }
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"default": { "$type": "color", "$value": "#263548", "$description": "All borders, dividers, table grid lines. The most-used non-text color." },
|
||||||
|
"strong": { "$type": "color", "$value": "#35506a", "$description": "Emphasized border / card hover border." }
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"primary": { "$type": "color", "$value": "#e5edf5", "$description": "Headings, primary content." },
|
||||||
|
"secondary": { "$type": "color", "$value": "#c7d3e0", "$description": "Body text, labels." },
|
||||||
|
"muted": { "$type": "color", "$value": "#8ea2b7", "$description": "Hints, metadata, table headers. Highest-frequency text color." },
|
||||||
|
"subtle": { "$type": "color", "$value": "#70859b", "$description": "Very secondary labels / inactive tabs." }
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"default": { "$type": "color", "$value": "#3b82c4", "$description": "Primary action, active nav, links, focus ring. The one vibrant brand color." },
|
||||||
|
"strong": { "$type": "color", "$value": "#2f6ea9", "$description": "Accent hover / gradient endpoint." },
|
||||||
|
"soft": { "$type": "color", "$value": "#3b82c422", "$description": "Accent at ~13% alpha — tinted badge/active backgrounds." },
|
||||||
|
"light": { "$type": "color", "$value": "#93c5fd", "$description": "Accent text on dark tinted backgrounds (badges, pills)." }
|
||||||
|
},
|
||||||
|
"semantic": {
|
||||||
|
"success": { "$type": "color", "$value": "#10b981", "$description": "Money / positive values." },
|
||||||
|
"success-text": { "$type": "color", "$value": "#6ee7b7" },
|
||||||
|
"warning": { "$type": "color", "$value": "#f59e0b", "$description": "Advisor / warning." },
|
||||||
|
"warning-text": { "$type": "color", "$value": "#fcd34d" },
|
||||||
|
"due-soon": { "$type": "color", "$value": "#e0b341", "$description": "Reminder due-soon urgency." },
|
||||||
|
"danger": { "$type": "color", "$value": "#dc2626", "$description": "Destructive action button." },
|
||||||
|
"danger-soft": { "$type": "color", "$value": "#e06c6c", "$description": "Overdue / error emphasis." },
|
||||||
|
"danger-text": { "$type": "color", "$value": "#fca5a5", "$description": "Inline error text." }
|
||||||
|
},
|
||||||
|
"constant": {
|
||||||
|
"white": { "$type": "color", "$value": "#ffffff", "$description": "Text on accent fills; brand mark." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"font": {
|
||||||
|
"family": {
|
||||||
|
"sans": { "$type": "fontFamily", "$value": ["IBM Plex Sans", "Avenir Next", "Segoe UI", "sans-serif"] },
|
||||||
|
"mono": { "$type": "fontFamily", "$value": ["IBM Plex Mono", "monospace"], "$description": "Numbers, dates, badges, logs, nav icons." }
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"xs": { "$type": "dimension", "$value": "11px", "$description": "Table headers, badges, micro-labels." },
|
||||||
|
"sm": { "$type": "dimension", "$value": "12px", "$description": "Help text, metadata." },
|
||||||
|
"md": { "$type": "dimension", "$value": "13px", "$description": "Body / table cells / inputs (current desktop base). NOTE: bump toward 15–16px for mobile body — see BRIEF.md." },
|
||||||
|
"lg": { "$type": "dimension", "$value": "14px", "$description": "Nav items." },
|
||||||
|
"xl": { "$type": "dimension", "$value": "16px", "$description": "Section titles." },
|
||||||
|
"2xl": { "$type": "dimension", "$value": "18px", "$description": "Modal titles." },
|
||||||
|
"3xl": { "$type": "dimension", "$value": "20px", "$description": "Page/header title." },
|
||||||
|
"4xl": { "$type": "dimension", "$value": "24px", "$description": "Login title, KPI values." }
|
||||||
|
},
|
||||||
|
"weight": {
|
||||||
|
"regular": { "$type": "fontWeight", "$value": 400 },
|
||||||
|
"medium": { "$type": "fontWeight", "$value": 500 },
|
||||||
|
"semibold": { "$type": "fontWeight", "$value": 600 },
|
||||||
|
"bold": { "$type": "fontWeight", "$value": 700 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"space": {
|
||||||
|
"2xs": { "$type": "dimension", "$value": "4px" },
|
||||||
|
"xs": { "$type": "dimension", "$value": "6px" },
|
||||||
|
"sm": { "$type": "dimension", "$value": "8px" },
|
||||||
|
"md": { "$type": "dimension", "$value": "12px", "$description": "Most common padding/gap unit." },
|
||||||
|
"lg": { "$type": "dimension", "$value": "16px" },
|
||||||
|
"xl": { "$type": "dimension", "$value": "20px", "$description": "Sidebar/header/content padding." },
|
||||||
|
"2xl": { "$type": "dimension", "$value": "24px", "$description": "Modal padding." }
|
||||||
|
},
|
||||||
|
"radius": {
|
||||||
|
"sm": { "$type": "dimension", "$value": "4px", "$description": "Badges." },
|
||||||
|
"md": { "$type": "dimension", "$value": "6px", "$description": "Buttons, inputs, kanban cards." },
|
||||||
|
"lg": { "$type": "dimension", "$value": "8px", "$description": "Cards, nav items, sections." },
|
||||||
|
"xl": { "$type": "dimension", "$value": "12px", "$description": "Modals." },
|
||||||
|
"pill": { "$type": "dimension", "$value": "999px", "$description": "Pills, skeleton lines." }
|
||||||
|
},
|
||||||
|
"shadow": {
|
||||||
|
"$description": "Real composite shadows from the as-built CSS; kept as raw strings (multi-layer + inset highlight don't map to a single DTCG shadow token).",
|
||||||
|
"card": { "$type": "shadow", "$value": "0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07" },
|
||||||
|
"card-hover":{ "$type": "shadow", "$value": "0 10px 20px rgba(7,17,30,0.35)" },
|
||||||
|
"button-hover": { "$type": "shadow", "$value": "0 6px 14px rgba(12,40,68,0.35)" },
|
||||||
|
"modal": { "$type": "shadow", "$value": "0 24px 56px rgba(1,8,17,0.5), inset 0 1px 0 #ffffff08" },
|
||||||
|
"slide-over":{ "$type": "shadow", "$value": "-12px 0 32px rgba(4,10,18,0.45), inset 1px 0 0 #ffffff07" },
|
||||||
|
"toast": { "$type": "shadow", "$value": "0 10px 24px rgba(4,12,22,0.45)" }
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"fast": { "$type": "duration", "$value": "120ms", "$description": "Press/transform feedback." },
|
||||||
|
"base": { "$type": "duration", "$value": "150ms", "$description": "Hover color/shadow." },
|
||||||
|
"panel": { "$type": "duration", "$value": "300ms", "$description": "Slide-over / toast entry." }
|
||||||
|
},
|
||||||
|
"_unmappable": {
|
||||||
|
"$description": "Documented-but-not-tokenized: the page background is a layered radial-gradient ('radial-gradient(1200px 600px at 15% -10%, #1a3c5e44, transparent 60%), radial-gradient(1000px 500px at 90% 0%, #27496b33, transparent 58%), #0b1118') — see DESIGN.md §Depth/elevation."
|
||||||
|
}
|
||||||
|
}
|
||||||
+465
-344
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user