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