diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 6d971d6..0655d1f 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -24,6 +24,7 @@ const AllSites = lazy(() => const Anomalies = lazy(() => import('./components/Anomalies.js').then((m) => ({ default: m.Anomalies })), ); +const Crm = lazy(() => import('./components/Crm.js').then((m) => ({ default: m.Crm }))); const AskPanel = lazy(() => import('./components/AskPanel.js').then((m) => ({ default: m.AskPanel })), ); @@ -92,6 +93,7 @@ type View = | 'retention' | 'experiments' | 'anomalies' + | 'crm' | 'ask' | 'docs'; @@ -167,6 +169,7 @@ const TABS: { id: View; label: string }[] = [ { id: 'retention', label: 'Retention' }, { id: 'experiments', label: 'Experiments' }, { id: 'anomalies', label: 'Anomalies' }, + { id: 'crm', label: 'CRM' }, { id: 'ask', label: 'Ask' }, { id: 'docs', label: 'Documentation' }, ]; @@ -620,6 +623,8 @@ function Dashboard(): ReactElement { range={range} onInvestigate={investigate} /> + ) : view === 'crm' ? ( + ) : view === 'ask' ? ( ) : ( diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 80cc7b3..3307ded 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -56,3 +56,47 @@ export async function apiPost(path: string, apiKey: string, body: unknown): P export function fetchStats(apiKey: string, query: StatsQuery): Promise { return apiFetch(`/api/stats?${qs(query)}`, apiKey); } + +/** + * The error code for a failed response: the API's own `{ error }` when it sent one, otherwise + * derived from the status. The fallback matters for the session routes, whose whole UI hinges on + * telling a 501 (this deployment has no CRM database) apart from a 403 (your role is too low) — a + * proxy or a truncated body must not collapse both into an indistinguishable "request_failed". + */ +function errorCode(status: number, body: { error?: string }): string { + if (body.error) return body.error; + if (status === 501) return 'crm_unavailable'; + if (status === 403) return 'forbidden'; + if (status === 401) return 'unauthorized'; + return 'request_failed'; +} + +/** + * Canonical helper for the SESSION-authenticated API (`/api/auth/me`, `/api/crm/*`). + * + * Deliberately sends no `Authorization` header: those routes refuse an API key by design, because a + * `clk_` key reads aggregate analytics and is handed out accordingly while contact PII is not. Auth + * is the HttpOnly session cookie, which a same-origin request carries on its own — `same-origin` is + * the browser default and is stated here so the intent survives the next refactor. + */ +export async function sessionFetch( + path: string, + init?: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown }, +): Promise { + const method = init?.method ?? 'GET'; + const res = await fetch(path, { + method, + credentials: 'same-origin', + ...(init?.body === undefined + ? {} + : { + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(init.body), + }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errorCode(res.status, body)); + } + return (await res.json()) as T; +} diff --git a/apps/dashboard/src/components/Crm.tsx b/apps/dashboard/src/components/Crm.tsx new file mode 100644 index 0000000..7d6d9dd --- /dev/null +++ b/apps/dashboard/src/components/Crm.tsx @@ -0,0 +1,131 @@ +// The CRM tab: contacts and companies, the one surface in this dashboard that holds directly +// identifying personal data. +// +// It is authenticated differently from every other tab, and that is deliberate rather than an +// oversight to fix. Everything else here reads with a per-site `clk_` API key; these routes refuse +// one, because a key that leaks costs you aggregate pageview counts while a key that could read +// contacts would cost you your customers' names, emails and phone numbers. So the CRM is gated on an +// operator session cookie plus a team role, and the panels below explain that rather than failing +// with a bare 401. +// +// Two states dominate what this tab renders in practice: +// • NO CRM DATABASE (501). The extension is an optional second D1 binding that most deployments +// never enable, so this is the DEFAULT and it renders as an explanation, never as an error. +// • ROLE. `viewer` sees nothing here; `analyst` reads and writes; `admin` deletes and exports. +// The destructive controls are hidden rather than offered and then refused — see +// `canAdministerCrm` for what the browser can actually prove about the operator's role. + +import { type ReactElement, useState } from 'react'; +import { cn } from '../lib/cn.js'; +import { SegmentNotice } from './CubeFilterBar.js'; +import { CompaniesPanel } from './crm/CompaniesPanel.js'; +import { ContactsPanel } from './crm/ContactsPanel.js'; + +type Section = 'contacts' | 'companies'; + +const SECTIONS: { id: Section; label: string }[] = [ + { id: 'contacts', label: 'Contacts' }, + { id: 'companies', label: 'Companies' }, +]; + +/** Roving-tabindex arrow navigation, as `role="tablist"` promises to assistive tech. Returns true + * when the key was consumed, so the caller can suppress the page scroll. */ +function onSectionKey(key: string, current: Section, select: (id: Section) => void): boolean { + const index = SECTIONS.findIndex((s) => s.id === current); + if (index < 0) return false; + let next: number; + if (key === 'ArrowRight') next = (index + 1) % SECTIONS.length; + else if (key === 'ArrowLeft') next = (index - 1 + SECTIONS.length) % SECTIONS.length; + else if (key === 'Home') next = 0; + else if (key === 'End') next = SECTIONS.length - 1; + else return false; + const target = SECTIONS[next]; + if (!target) return false; + select(target.id); + document.getElementById(`crm-tab-${target.id}`)?.focus(); + return true; +} + +export function Crm({ siteId }: { siteId: string }): ReactElement { + const [section, setSection] = useState
('contacts'); + // Both selections live here so the two panels can hand off to each other: a contact's employer + // opens the company, and a company's roster opens the person. + const [contactId, setContactId] = useState(''); + const [companyId, setCompanyId] = useState(''); + + const openCompany = (id: string): void => { + setCompanyId(id); + if (id) setSection('companies'); + }; + const openContact = (id: string): void => { + setContactId(id); + if (id) setSection('contacts'); + }; + + return ( +
+
+

CRM

+

+ The people and organizations behind the numbers. A contact is only ever + connected to analytics through an active signed consent record — never through + anything stored on the contact itself — so a person with no consent simply has + no link, and this tab says so rather than showing zeroes. +

+
+ + {/* The chips above this tab are a filter over analytics dimensions. Nothing here honours + them, and a filtered label over unfiltered numbers is worse than no filter at all. */} + + +
+ {SECTIONS.map((s) => ( + + ))} +
+ +
+ {section === 'contacts' ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/crm/CompaniesPanel.tsx b/apps/dashboard/src/components/crm/CompaniesPanel.tsx new file mode 100644 index 0000000..640ff1f --- /dev/null +++ b/apps/dashboard/src/components/crm/CompaniesPanel.tsx @@ -0,0 +1,270 @@ +// The companies roster: search, status filter, paging, create, and a detail pane carrying the +// consent-gated rollup for the selected company. + +import { Plus } from 'lucide-react'; +import { type ReactElement, useEffect, useState } from 'react'; +import { CRM_PAGE_SIZE, useCompanies, useCompany, useCreateCompany } from '../../hooks/crm.js'; +import { cn } from '../../lib/cn.js'; +import { COMPANY_STATUSES, canAdministerCrm } from '../../lib/crm.js'; +import { CardSkeletons, EmptyState } from '../StatusStates.js'; +import { CompanyDetail } from './CompanyDetail.js'; +import { CompanyForm } from './CompanyForm.js'; +import { CrmAccessNotice, Pager, StatusChip } from './shared.js'; + +const SEARCH_DEBOUNCE_MS = 300; + +export function CompaniesPanel({ + siteId, + selectedId, + onSelect, + onOpenContact, +}: { + siteId: string; + /** Owned by the tab shell so a contact's company link can select one from the other panel. */ + selectedId: string; + onSelect: (companyId: string) => void; + onOpenContact: (contactId: string) => void; +}): ReactElement { + // The role the server served this list under — the only authoritative answer available to + // the browser. See `canAdministerCrm`. + const [search, setSearch] = useState(''); + const [query, setQuery] = useState(''); + const [status, setStatus] = useState(''); + const [offset, setOffset] = useState(0); + const [creating, setCreating] = useState(false); + const [deleted, setDeleted] = useState(null); + + useEffect(() => { + const timer = setTimeout(() => { + setQuery(search); + setOffset(0); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [search]); + + const list = useCompanies(siteId, { status, q: query, offset }); + const canAdminister = canAdministerCrm(list.data?.role); + const selected = useCompany(siteId, selectedId); + const create = useCreateCompany(siteId); + + if (list.error) { + return ( + void list.refetch()} + retrying={list.isFetching} + /> + ); + } + + const companies = list.data?.companies ?? []; + const total = list.data?.total ?? 0; + const filtering = Boolean(query.trim() || status); + + return ( +
+
+
+
+ + setSearch(e.target.value)} + placeholder="Name or domain" + className="input mt-1 block w-full rounded-lg px-3 py-1.5 text-sm" + /> +
+
+ + +
+ +
+ + {creating ? ( + setCreating(false)} + onSubmit={(fields) => + create.mutate(fields, { + onSuccess: (result) => { + setCreating(false); + onSelect(result.company.id); + }, + }) + } + /> + ) : null} + + {deleted ? ( +

+ {deleted} +

+ ) : null} + + {list.isLoading ? ( + + ) : companies.length === 0 ? ( + + {filtering ? ( + <>Clear the search or the status filter to see every company. + ) : ( + <> + A company turns a contact’s employer into a structured link + instead of typed text, and gives you one rollup across everyone who + works there. + + )} + + ) : ( + <> +
+ + + + + {['Company', 'Domain', 'Status'].map((label) => ( + + ))} + + + + {companies.map((company) => ( + + + + + + ))} + +
Companies on this site.
+ {label} +
+ + + {company.domain ?? '—'} + + +
+
+ + + )} +
+ +
+ {!selectedId ? ( +

+ Pick a company to see who works there and how much of its traffic Facet is + actually allowed to attribute to it. +

+ ) : selected.error ? ( + void selected.refetch()} + retrying={selected.isFetching} + /> + ) : selected.data ? ( + { + setDeleted( + unlinked === 1 + ? 'Company deleted. 1 contact was unlinked and kept.' + : `Company deleted. ${unlinked} contacts were unlinked and kept.`, + ); + onSelect(''); + }} + /> + ) : ( + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/crm/CompanyDetail.tsx b/apps/dashboard/src/components/crm/CompanyDetail.tsx new file mode 100644 index 0000000..e003a79 --- /dev/null +++ b/apps/dashboard/src/components/crm/CompanyDetail.tsx @@ -0,0 +1,315 @@ +// One company: its record, its roster, and the consent-gated rollup of its contacts' analytics. +// +// THE DENOMINATOR IS NOT DECORATION. A rollup covering one of twelve people reads as the account's +// traffic unless the coverage is stated next to the numbers, and an operator who mistakes it for the +// whole will start reasoning about the eleven who never consented. So `contacts_linked of +// contacts_total` is rendered above the figures on every response — linked or not — and a truncated +// rollup says it is a lower bound rather than presenting a capped sum as a total. + +import { Link2Off, Users } from 'lucide-react'; +import { type ReactElement, useState } from 'react'; +import { + CRM_PAGE_SIZE, + useCompanyAnalytics, + useCompanyContacts, + useDeleteCompany, + useUpdateCompany, +} from '../../hooks/crm.js'; +import { type CompanyRollupCounts, type CrmCompany, linkReasonText } from '../../lib/crm.js'; +import { formatDateTime } from '../../lib/datetime.js'; +import { formatNumber } from '../../lib/format.js'; +import { Skeleton } from '../StatusStates.js'; +import { ConfirmDelete, MutationStatus } from '../settings/kit.js'; +import { CompanyForm } from './CompanyForm.js'; +import { ActivityFigures, CrmAccessNotice, DetailRow, Pager, StatusChip } from './shared.js'; + +/** The coverage statement. Always rendered, because "how much of this company is in these numbers" + * is part of the answer and not a caveat on it. */ +function Coverage({ counts }: { counts: CompanyRollupCounts }): ReactElement { + const { contacts_linked, contacts_total, contacts_considered, contacts_truncated } = counts; + return ( +
+
+

+ Coverage +

+

+ {formatNumber(contacts_linked)} of {formatNumber(contacts_total)}{' '} + {contacts_total === 1 ? 'contact' : 'contacts'} linked +

+

+ Only a contact with an active signed consent record contributes. These figures + are not this company’s whole traffic — they are the part {contacts_linked}{' '} + of its {contacts_total} people authorized. +

+
+ {contacts_truncated ? ( +

+ Lower bound, not a total. Consent was resolved for the first{' '} + {formatNumber(contacts_considered)} contacts only (the per-rollup limit is{' '} + {formatNumber(counts.contacts_limit)}), so contacts beyond that are missing from + these numbers. +

+ ) : null} +
+ ); +} + +function CompanyAnalyticsPanel({ + siteId, + companyId, +}: { + siteId: string; + companyId: string; +}): ReactElement { + const analytics = useCompanyAnalytics(siteId, companyId); + + if (analytics.error) { + return ( + void analytics.refetch()} + retrying={analytics.isFetching} + /> + ); + } + if (!analytics.data) return ; + + const data = analytics.data; + return ( +
+ + {data.linked ? ( + <> + +

+ Summed over {formatNumber(data.visitor_hashes)} visitor{' '} + {data.visitor_hashes === 1 ? 'hash' : 'hashes'} — a linkage-breadth number, + not a headcount. The headcount is the coverage above. +

+ + ) : ( +
+
+ )} +
+ ); +} + +/** The roster query is owned by the detail, not by this list: the delete confirmation needs its + * `total` to say how many people survive the deletion, and a child cannot hand a parent state + * during render. */ +type RosterQuery = ReturnType; + +function Roster({ + roster, + offset, + onOffset, + onOpenContact, +}: { + roster: RosterQuery; + offset: number; + onOffset: (next: number) => void; + onOpenContact: (contactId: string) => void; +}): ReactElement { + if (roster.error) { + return ( + void roster.refetch()} + retrying={roster.isFetching} + /> + ); + } + if (!roster.data) return ; + if (roster.data.contacts.length === 0) { + return ( +

+ No contact is linked to this company yet. Link one from their record, under{' '} + Linked company. +

+ ); + } + + return ( +
+
    + {roster.data.contacts.map((contact) => ( +
  • + +
  • + ))} +
+ +
+ ); +} + +export function CompanyDetail({ + siteId, + company, + canAdminister, + onDeleted, + onOpenContact, +}: { + siteId: string; + company: CrmCompany; + /** True only when this operator provably holds `admin`; see `canAdministerCrm`. */ + canAdminister: boolean; + onDeleted: (contactsUnlinked: number) => void; + onOpenContact: (contactId: string) => void; +}): ReactElement { + const [editing, setEditing] = useState(false); + const [rosterOffset, setRosterOffset] = useState(0); + const roster = useCompanyContacts(siteId, company.id, rosterOffset); + const contactCount = roster.data?.total ?? null; + const update = useUpdateCompany(siteId, company.id); + const remove = useDeleteCompany(siteId); + + if (editing) { + return ( +
+

Edit company

+ setEditing(false)} + onSubmit={(fields) => + update.mutate(fields, { onSuccess: () => setEditing(false) }) + } + /> +
+ ); + } + + const survivors = + contactCount === null + ? 'Its contacts are kept' + : contactCount === 1 + ? 'Its 1 contact is kept' + : `Its ${contactCount} contacts are kept`; + + return ( +
+
+
+

+ {company.name} +

+

+ Updated {formatDateTime(company.updated_at)} +

+
+
+ + +
+
+ +
+ {company.domain} + + {company.notes ? ( + {company.notes} + ) : null} + + {formatDateTime(company.created_at)} +
+ +
+

+

+ +
+ +
+

Analytics rollup

+ +
+ +
+ {canAdminister ? ( + + remove.mutate(company.id, { + onSuccess: (result) => onDeleted(result.contacts_unlinked), + }) + } + /> + ) : ( +

+ Deleting a company needs the admin role on the team that + owns this site. +

+ )} + +
+
+ ); +} diff --git a/apps/dashboard/src/components/crm/CompanyForm.tsx b/apps/dashboard/src/components/crm/CompanyForm.tsx new file mode 100644 index 0000000..6217cc2 --- /dev/null +++ b/apps/dashboard/src/components/crm/CompanyForm.tsx @@ -0,0 +1,125 @@ +// One form for creating and for editing a company. As with contacts, every field is submitted on +// every save because the API reads `''` as "clear this" — the only way a form can unset a field. + +import { type ReactElement, useState } from 'react'; +import type { CrmFields } from '../../hooks/crm.js'; +import { COMPANY_STATUSES, type CrmCompany } from '../../lib/crm.js'; +import { BlockedReason, Field, FormControls, MutationStatus, Select } from '../settings/kit.js'; + +interface Draft { + name: string; + domain: string; + status: string; + notes: string; +} + +export function CompanyForm({ + company, + submitLabel, + pendingLabel, + onSubmit, + onCancel, + isPending, + error, +}: { + /** The company being edited, or null when creating. */ + company: CrmCompany | null; + submitLabel: string; + pendingLabel: string; + onSubmit: (fields: CrmFields) => void; + onCancel: () => void; + isPending: boolean; + error: unknown; +}): ReactElement { + const [draft, setDraft] = useState(() => ({ + name: company?.name ?? '', + domain: company?.domain ?? '', + status: company?.status ?? 'lead', + notes: company?.notes ?? '', + })); + const set = (key: keyof Draft) => (value: string) => + setDraft((prev) => ({ ...prev, [key]: value })); + // `companies.name` is NOT NULL and it is the display value: a company with no name is a row + // nothing can refer to. + const blocked = draft.name.trim() ? null : 'A company needs a name.'; + const idFor = (field: string) => `company-${company?.id ?? 'new'}-${field}`; + + return ( +
{ + e.preventDefault(); + if (blocked || isPending) return; + onSubmit({ + name: draft.name, + domain: draft.domain, + status: draft.status, + notes: draft.notes, + }); + }} + > + + + + +
+ +