Files
ten31-database/design/_imports/2026-06-19_zip-file/Venture-CRM mobile redesign/ContactsApp.dc.html
T
Keysat e6a89450da 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.
2026-06-19 16:38:30 -05:00

490 lines
33 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="{&quot;$preview&quot;:{&quot;width&quot;:393,&quot;height&quot;:812},&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;dark&quot;,&quot;light&quot;],&quot;default&quot;:&quot;dark&quot;,&quot;tsType&quot;:&quot;'dark'|'light'&quot;}}">
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>