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

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

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

Verified: render-smoke green + a throwaway jsdom interaction harness mounting
the real app at 375px (list/grouping/sort-sheet/detail/back, 14/14). Deploy-
pending (folds into the next s9pk with P0/P1/view-reorder).
This commit is contained in:
Keysat
2026-06-19 13:57:05 -05:00
parent 4ed16ca828
commit 984b950f80
3 changed files with 547 additions and 11 deletions
+525 -1
View File
@@ -38,6 +38,8 @@
--accent-soft: #3b82c422;
--text-subtle: #70859b;
--border-strong: #35506a;
--bg-input: #0d1622; /* recessed input/table-header surface (tokens color.bg.input) */
--accent-light: #93c5fd; /* accent text on dark tinted fills (tokens color.accent.light) */
/* Mobile-first foundation (DESIGN §3/§8, tokens `mobile` group). Sizing/radii used
by the bottom tab bar + bottom-sheet primitive; per-surface type bumps land with
each surface (Phases 25), not as a global body rule (components set own px). */
@@ -45,9 +47,14 @@
--mobile-touch-target: 44px;
--mobile-input-h: 46px;
--mobile-sheet-radius: 20px;
--mobile-card-radius: 10px;
--mobile-control-radius: 8px;
--mobile-screen-pad-x: 16px;
--mobile-card-gap: 10px;
--mobile-font-body: 15px;
--mobile-font-card-title: 16px;
--mobile-font-screen-title: 21px;
--mobile-font-detail-title: 22px;
--mobile-font-sheet-title: 18px;
--mobile-font-tab-label: 10px;
}
@@ -2005,6 +2012,130 @@
}
.account-popover-logout:hover { background: var(--bg-hover); }
/* Larger grab region for the bottom-sheet (handle + title). touch-action:none so a
downward drag dismisses the sheet instead of scrolling the page behind it. */
.sheet-grab { touch-action: none; cursor: grab; }
.sheet-grab:active { cursor: grabbing; }
/* ─── Phase 2 — Contacts mobile surface (list → detail → sheet) ──────────────
Rendered only when useIsMobile() is true (the mobile component is JS-gated), so
these never apply on desktop; kept here with the mobile foundation. The 13→15px
body bump (DESIGN §3) lands on .mobile-screen, per-surface as planned. */
.mobile-screen { font-size: var(--mobile-font-body); }
.mobile-caption { font-size: 12px; color: var(--text-subtle); margin: -2px 0 12px; }
.mobile-toolbar { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.mobile-search {
width: 100%; height: var(--mobile-input-h);
background: var(--bg-input); color: var(--text-primary);
border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
font-size: var(--mobile-font-body); font-family: inherit; padding: 0 12px;
}
.mobile-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
.mobile-seg { display: flex; gap: 6px; }
.mobile-seg-tab {
flex: 1; min-height: var(--mobile-touch-target);
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-control-radius);
color: var(--text-subtle); font-size: 13px; font-weight: 600;
font-family: inherit; cursor: pointer;
}
.mobile-seg-tab.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-light); }
.mobile-sortbar { display: flex; justify-content: space-between; align-items: center; }
.mobile-count { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); }
.mobile-sort-btn {
background: transparent; border: none; color: var(--text-muted);
font-size: 13px; font-family: inherit; cursor: pointer; padding: 6px 2px;
display: inline-flex; align-items: center; gap: 6px;
}
/* AZ directory: sticky letter headers over a card list. */
.az-header {
position: sticky; top: 0; z-index: 1;
background: var(--bg-base);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-subtle);
padding: 10px 2px 6px;
}
.contact-card {
display: flex; align-items: center; gap: 12px;
width: 100%; text-align: left; color: inherit;
background: var(--bg-panel); border: 1px solid var(--border);
border-radius: var(--mobile-card-radius);
padding: 12px; margin-bottom: var(--mobile-card-gap); cursor: pointer;
box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
}
.contact-card:active { border-color: var(--border-strong); }
.mobile-avatar {
flex: none; width: 38px; height: 38px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
background: var(--accent-soft); color: var(--accent-light);
font-weight: 600; font-size: 15px;
}
.mobile-avatar.lg { width: 52px; height: 52px; font-size: 20px; }
.contact-card-main { flex: 1; min-width: 0; }
.contact-card-name {
display: block; font-size: var(--mobile-font-card-title); font-weight: 600;
color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.contact-card-sub {
display: block; font-size: 13px; color: var(--text-muted); margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.contact-card-meta { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.contact-card-date { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); }
/* Sort sheet rows (the BottomSheet's first consumer — read-only). */
.sheet-option {
display: flex; align-items: center; justify-content: space-between;
width: 100%; text-align: left; background: transparent;
border: none; border-bottom: 1px solid var(--border);
color: var(--text-secondary); font-size: var(--mobile-font-body);
font-family: inherit; padding: 15px 2px; cursor: pointer;
}
.sheet-option:last-child { border-bottom: none; }
.sheet-option.active { color: var(--accent-light); }
.sheet-option-check { color: var(--accent); }
/* Full-screen read-only detail — promotes the desktop slide-over (DESIGN §8). */
.fs-detail {
position: fixed; inset: 0; z-index: 210;
background: var(--bg-base);
display: flex; flex-direction: column;
animation: screenIn 0.2s ease;
}
@keyframes screenIn { from { opacity: 0; transform: translateX(14px); } to { opacity: 1; transform: translateX(0); } }
.fs-detail-header {
display: flex; align-items: center; flex: none;
padding: 12px; border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #121d2b 0%, #101926 100%);
}
.fs-detail-back {
background: transparent; border: none; color: var(--accent);
font-size: 15px; font-family: inherit; cursor: pointer;
padding: 6px 8px 6px 0; display: inline-flex; align-items: center; gap: 4px;
}
.fs-detail-body {
flex: 1; overflow-y: auto; font-size: var(--mobile-font-body);
padding: 16px var(--mobile-screen-pad-x) calc(env(safe-area-inset-bottom, 0px) + 28px);
}
.fs-detail-id { display: flex; align-items: center; gap: 14px; margin-bottom: 22px; }
.fs-detail-title { font-size: var(--mobile-font-detail-title); font-weight: 600; }
.fs-detail-subtitle { font-size: 13px; color: var(--text-muted); margin-top: 3px; }
.fs-section { margin-bottom: 22px; }
.fs-section-label {
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-subtle);
margin-bottom: 10px;
}
.fs-row { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border); }
.fs-row:last-child { border-bottom: none; }
.fs-row-label { font-size: 13px; color: var(--text-muted); flex: none; }
.fs-row-value { font-size: var(--mobile-font-body); color: var(--text-secondary); text-align: right; word-break: break-word; }
.fs-row-value.mono { font-family: 'IBM Plex Mono', monospace; }
.fs-copy-hint { color: var(--accent); margin-left: 6px; font-size: 12px; }
/* Visibility utilities — base = desktop; flipped under the breakpoint. */
.mobile-only { display: none; }
@@ -3307,6 +3438,132 @@
</div>
);
/* ─── Shared mobile primitives (landed in Phase 2, reused by Phases 35) ────────── */
// True below the 768px breakpoint (matches the CSS .bottom-tab-bar / .mobile-only switch).
// Used to swap whole surfaces (mobile component vs desktop component), never to toggle
// hooks within one component — keep the rules-of-hooks-safe pattern (switch at the wrapper).
const MOBILE_MEDIA_QUERY = '(max-width: 768px)';
const useIsMobile = () => {
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && window.matchMedia
? window.matchMedia(MOBILE_MEDIA_QUERY).matches : false
);
useEffect(() => {
if (!window.matchMedia) return;
const mql = window.matchMedia(MOBILE_MEDIA_QUERY);
const onChange = (e) => setIsMobile(e.matches);
setIsMobile(mql.matches);
if (mql.addEventListener) mql.addEventListener('change', onChange);
else mql.addListener(onChange); // Safari < 14
return () => {
if (mql.removeEventListener) mql.removeEventListener('change', onChange);
else mql.removeListener(onChange);
};
}, []);
return isMobile;
};
// Drag-to-dismiss bottom sheet — replaces the centered modal + right slide-over on mobile
// (DESIGN §4/§8). Styling is the Phase-1 .bottom-sheet/.sheet-scrim/.sheet-handle CSS; this
// adds the mount/enter-exit animation, scrim/Escape dismiss, and pointer drag-down close.
const BottomSheet = ({ open, onClose, title, children }) => {
const [mounted, setMounted] = useState(open);
const [shown, setShown] = useState(false);
const [dragY, setDragY] = useState(0);
const dragRef = useRef(null);
useEffect(() => {
if (open) {
setMounted(true);
setDragY(0);
const id = requestAnimationFrame(() => setShown(true));
return () => cancelAnimationFrame(id);
}
setShown(false);
const t = setTimeout(() => setMounted(false), 300); // match --motion.sheet exit
return () => clearTimeout(t);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!mounted) return null;
const onPointerDown = (e) => {
dragRef.current = { startY: e.clientY };
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {}
};
const onPointerMove = (e) => {
if (!dragRef.current) return;
const dy = e.clientY - dragRef.current.startY;
if (dy > 0) setDragY(dy);
};
const endDrag = () => {
if (!dragRef.current) return;
dragRef.current = null;
const shouldClose = dragY > 90;
setDragY(0); // reset so the class-based slide (snap-back or dismiss) plays
if (shouldClose) onClose();
};
const sheetStyle = dragY > 0 ? { transform: `translateY(${dragY}px)`, transition: 'none' } : undefined;
return (
<>
<div className={`sheet-scrim ${shown ? 'open' : ''}`} onClick={onClose} />
<div className={`bottom-sheet ${shown ? 'open' : ''}`} style={sheetStyle} role="dialog" aria-modal="true">
<div
className="sheet-grab"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="sheet-handle" />
{title && <div className="sheet-title">{title}</div>}
</div>
<div className="sheet-body">{children}</div>
</div>
</>
);
};
// Read-only labelled row for full-screen mobile detail; hides empty values. `copyable`
// makes the value tap-to-copy (used for email).
const MobileDetailRow = ({ label, value, mono, copyable, onShowToast }) => {
if (!value) return null;
const onCopy = copyable ? () => {
try {
if (navigator.clipboard) {
navigator.clipboard.writeText(value);
if (onShowToast) onShowToast('Copied', 'success');
}
} catch (_) {}
} : undefined;
return (
<div className="fs-row">
<span className="fs-row-label">{label}</span>
<span
className={`fs-row-value ${mono ? 'mono' : ''}`}
onClick={onCopy}
style={copyable ? { cursor: 'pointer' } : undefined}
>
{value}{copyable && <span className="fs-copy-hint">copy</span>}
</span>
</div>
);
};
const contactTypeBadgeClass = (type) => ({
investor: 'badge-investor', prospect: 'badge-prospect',
advisor: 'badge-advisor', other: 'badge-other'
}[type] || 'badge-other');
const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -3846,7 +4103,9 @@
);
};
const ContactsPage = ({ token, onShowToast }) => {
// Desktop Contacts surface (table + slide-over). Unchanged; rendered on >768px via the
// ContactsPage switch below. Mobile (<768px) renders MobileContactsPage instead.
const DesktopContactsPage = ({ token, onShowToast }) => {
const CONTACTS_PAGE_SIZE = 100;
const [contacts, setContacts] = useState([]);
const [contactsTotal, setContactsTotal] = useState(0);
@@ -4045,6 +4304,271 @@
);
};
// Mobile Contacts (<768px): read-only AZ directory full-screen detail sort sheet.
// Phase 2 of the mobile-first redesign — the lowest-risk surface, validating the
// list→detail→sheet pattern + the shared BottomSheet/useIsMobile before the Grid (Phase 3).
// Contacts is READ-ONLY on mobile (BRIEF §3b): create/edit live on the Grid, never here.
const SORT_OPTIONS = [
{ id: 'name-asc', label: 'Name (AZ)', short: 'AZ' },
{ id: 'name-desc', label: 'Name (ZA)', short: 'ZA' },
{ id: 'recent', label: 'Recently contacted', short: 'Recent' },
];
const MobileContactDetail = ({ contact, token, onClose, onShowToast }) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const r = await api(`/api/contacts/${contact.id}`, {}, token);
if (!cancelled) setDetails(r.data);
} catch (err) {
if (!cancelled) onShowToast(getErrorMessage(err, 'Failed to load contact'), 'error');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [contact.id, token]);
const base = details || contact;
const name = `${base.first_name || ''} ${base.last_name || ''}`.trim() || base.email || 'Contact';
const initial = (base.last_name || base.first_name || name || '?').charAt(0).toUpperCase();
const org = base.organization || base.organization_name || '';
const type = base.contact_type || 'other';
const location = details
? ([details.city, details.state, details.country].filter(Boolean).join(', ') || details.location_query || '')
: '';
return (
<div className="fs-detail" role="dialog" aria-modal="true">
<div className="fs-detail-header">
<button className="fs-detail-back" onClick={onClose}> Contacts</button>
</div>
<div className="fs-detail-body">
<div className="fs-detail-id">
<span className="mobile-avatar lg">{initial}</span>
<span style={{ minWidth: 0, flex: 1 }}>
<div className="fs-detail-title">{name}</div>
<div className="fs-detail-subtitle">{org || '—'}</div>
</span>
<span className={`badge ${contactTypeBadgeClass(type)}`}>{type}</span>
</div>
{loading ? <SkeletonBlock lines={6} /> : (
<>
<div className="fs-section">
<div className="fs-section-label">Contact</div>
<MobileDetailRow label="Email" value={base.email} mono copyable onShowToast={onShowToast} />
<MobileDetailRow label="Phone" value={base.phone} mono />
<MobileDetailRow label="Title" value={base.title} />
<MobileDetailRow label="Organization" value={org} />
<MobileDetailRow label="Lead Source" value={base.source} />
<MobileDetailRow label="LinkedIn" value={base.linkedin_url} />
<MobileDetailRow label="Location" value={location} />
</div>
{details && details.opportunities && details.opportunities.length > 0 && (
<div className="fs-section">
<div className="fs-section-label">Opportunities</div>
{details.opportunities.map((o) => (
<div className="fs-row" key={o.id}>
<span className="fs-row-label">{o.name}</span>
<span className="fs-row-value mono">{o.stage} · {formatCurrencyLong(o.expected_amount)}</span>
</div>
))}
</div>
)}
<div className="fs-section">
<div className="fs-section-label">Communication History</div>
{details && details.communications && details.communications.length > 0 ? (
<div className="timeline">
{details.communications.map((cm) => (
<div key={cm.id} className="timeline-item">
<div className="timeline-marker"></div>
<div className="timeline-content">
<div className="timeline-header">{cm.type}</div>
<div className="timeline-meta">{formatDate(cm.communication_date)}</div>
{cm.subject && <div className="timeline-body">{cm.subject}</div>}
</div>
</div>
))}
</div>
) : (
<div style={{ color: 'var(--text-subtle)', fontSize: '13px' }}>No communications logged.</div>
)}
</div>
</>
)}
</div>
</div>
);
};
const MobileContactsPage = ({ token, onShowToast }) => {
const [contacts, setContacts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [tab, setTab] = useState('all');
const [search, setSearch] = useState('');
const [sort, setSort] = useState('name-asc');
const [sortOpen, setSortOpen] = useState(false);
const [selected, setSelected] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
// One fetch of the full directory (server cap 500); tab + search + sort are
// applied client-side so switching is instant and needs no refetch.
const r = await api('/api/contacts?sort=last_name&order=asc&limit=500', {}, token);
if (!cancelled) { setContacts(r.data || []); setError(''); }
} catch (err) {
if (!cancelled) setError(getErrorMessage(err, 'Failed to load contacts'));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [token]);
const displayName = (c) => `${c.first_name || ''} ${c.last_name || ''}`.trim() || c.email || 'Unknown';
// Directory convention (matches the desktop default + phone Contacts): order and section
// by last name, even though the card shows "First Last".
const sortBasis = (c) => (c.last_name || c.first_name || c.email || '').trim();
const lastContactTs = (c) => (c.last_contact_date ? new Date(c.last_contact_date).getTime() : 0);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const list = contacts.filter((c) => {
if (tab === 'investors' && c.contact_type !== 'investor') return false;
if (tab === 'prospects' && c.contact_type !== 'prospect') return false;
if (!q) return true;
const org = c.organization || c.organization_name || '';
return displayName(c).toLowerCase().includes(q)
|| (c.email || '').toLowerCase().includes(q)
|| org.toLowerCase().includes(q);
});
if (sort === 'recent') {
list.sort((a, b) => lastContactTs(b) - lastContactTs(a));
} else {
const dir = sort === 'name-desc' ? -1 : 1;
list.sort((a, b) => dir * sortBasis(a).localeCompare(sortBasis(b), undefined, { sensitivity: 'base' }));
}
return list;
}, [contacts, tab, search, sort]);
// AZ letter groups only in name-sort modes; "recently contacted" is a flat list.
const groups = useMemo(() => {
if (sort === 'recent') return null;
const m = new Map();
for (const c of filtered) {
let letter = (sortBasis(c).charAt(0) || '#').toUpperCase();
if (!/[A-Z]/.test(letter)) letter = '#';
if (!m.has(letter)) m.set(letter, []);
m.get(letter).push(c);
}
return Array.from(m.entries());
}, [filtered, sort]);
const renderCard = (c) => {
const org = c.organization || c.organization_name || '';
const type = c.contact_type || 'other';
const initial = (sortBasis(c).charAt(0) || displayName(c).charAt(0) || '?').toUpperCase();
return (
<button className="contact-card" key={c.id} onClick={() => setSelected(c)}>
<span className="mobile-avatar">{initial}</span>
<span className="contact-card-main">
<span className="contact-card-name">{displayName(c)}</span>
<span className="contact-card-sub">{org || '—'}</span>
</span>
<span className="contact-card-meta">
<span className={`badge ${contactTypeBadgeClass(type)}`}>{type}</span>
{c.last_contact_date && <span className="contact-card-date">{formatDate(c.last_contact_date)}</span>}
</span>
</button>
);
};
const sortShort = (SORT_OPTIONS.find((o) => o.id === sort) || SORT_OPTIONS[0]).short;
return (
<div className="mobile-screen">
<div className="mobile-caption">Read-only directory — people are added and edited from the Fundraising Grid.</div>
<div className="mobile-toolbar">
<input
className="mobile-search"
type="text"
placeholder="Search contacts…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="mobile-seg">
{['all', 'investors', 'prospects'].map((t) => (
<button key={t} className={`mobile-seg-tab ${tab === t ? 'active' : ''}`} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<div className="mobile-sortbar">
<span className="mobile-count">{filtered.length} {filtered.length === 1 ? 'contact' : 'contacts'}</span>
<button className="mobile-sort-btn" onClick={() => setSortOpen(true)}>⇅ {sortShort}</button>
</div>
</div>
{loading ? (
<SkeletonBlock lines={8} />
) : error ? (
<div className="empty-state">{error}</div>
) : filtered.length === 0 ? (
<div className="empty-state">No contacts found</div>
) : groups ? (
groups.map(([letter, items]) => (
<div key={letter}>
<div className="az-header">{letter}</div>
{items.map(renderCard)}
</div>
))
) : (
filtered.map(renderCard)
)}
<BottomSheet open={sortOpen} onClose={() => setSortOpen(false)} title="Sort">
{SORT_OPTIONS.map((o) => (
<button
key={o.id}
className={`sheet-option ${sort === o.id ? 'active' : ''}`}
onClick={() => { setSort(o.id); setSortOpen(false); }}
>
<span>{o.label}</span>
{sort === o.id && <span className="sheet-option-check"></span>}
</button>
))}
</BottomSheet>
{selected && (
<MobileContactDetail
contact={selected}
token={token}
onClose={() => setSelected(null)}
onShowToast={onShowToast}
/>
)}
</div>
);
};
// Switch by viewport. Only useIsMobile() runs here, so the hook count is constant; the two
// surfaces mount/unmount on a breakpoint cross (rules-of-hooks-safe — each owns its hooks).
const ContactsPage = (props) => {
const isMobile = useIsMobile();
return isMobile ? <MobileContactsPage {...props} /> : <DesktopContactsPage {...props} />;
};
const ContactDetailPanel = ({ contact, onClose, onDelete, token, onShowToast, onRefresh }) => {
const [details, setDetails] = useState(null);
const [loading, setLoading] = useState(true);