CRM contact page
A CRM contact in five screens. Profile with tabs, a history with filters and new entries, deals as a stage ladder, notes with mentions and a list with detail.
One customer, everything in reach
The contact page is where a CRM is used most, right before a call or after a meeting. These five screens put the person, the conversation so far, the open deals and the notes one tab apart, and mail, call and meeting work as real actions.
Contact profile shows avatar, company, the key fields, three numbers and tabs for history, deals and notes. Contact history lays mails, calls, meetings and notes by day on one line, with filters and counts, a composer for new entries and the contact in numbers. Contact deals lists every deal with value, chance and close date, the stage as a ladder to change, live sums for pipeline, weighted and won, and a form for a new deal. Contact notes keeps pinned notes first, writes with @ mentions, edits in place and deletes with undo. Contact list with detail lets the arrow keys move through a filtered list while the detail shows fields, open deals and recent activity.
Lead with the next step for this contact, then the history. Keep the key fields to those your team really fills in, five good ones beat twenty empty ones. Weighted pipeline sums need honest chances, so let people set the chance next to the stage.
Variants
Details
What CRM contact brings along, read from the registry and the source.
- Category
- CRM and invoicing
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/crm-contact.tsx
- npm packages
- None
- Languages
- English and German, 107 texts of its own
- Studio
- Adjust in code
- In collections
- SaaS app screens
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CrmContactProps interface from components/ui/crm-contact.tsx, with the comments from the code.
variantoptionalCrmContactVariantprofiletimelinedealsnoteslist-detail
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titleoptionalstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
currencyoptionalstringISO currency code, e.g. EUR or USD
contactoptionalCrmPersonThe person on screen (all variants but list-detail)
contactsoptionalCrmPerson[]list-detail: everybody in the list
activitiesoptionalCrmActivity[]dealsoptionalCrmDeal[]stagesoptionalCrmStage[]The pipeline in order. The last stage counts as won.
notesoptionalCrmNote[]meoptionalstringName of the person using the app, author of new entries
peopleoptionalstring[]People you can mention in notes with @
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on add, edit, stage change, pin and delete. Without it the block simulates the request.
Built-in texts
CRM contact writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 107 texts
| Key | English | German |
|---|---|---|
| English Email | German E-Mail | |
| call | English Call | German Anruf |
| meeting | English Meeting | German Termin |
| note | English Note | German Notiz |
| emails | English Emails | German E-Mails |
| calls | English Calls | German Anrufe |
| meetings | English Meetings | German Termine |
| notesTab | English Notes | German Notizen |
| all | English All | German Alle |
| history | English History | German Verlauf |
| deals | English Deals | German Deals |
| details | English Details | German Details |
| showDetails | English Show details | German Details zeigen |
| hideDetails | English Hide details | German Details ausblenden |
| sendEmail | English Write email | German E-Mail schreiben |
| callNow | English Call | German Anrufen |
| schedule | English Book meeting | German Termin buchen |
| scheduled | English Meeting request sent to {name}. | German Terminanfrage an {name} gesendet. |
| lead | English Lead | German Lead |
| customer | English Customer | German Kunde |
| inactive | English Inactive | German Inaktiv |
| owner | English Owner | German Betreut von |
| location | English Location | German Ort |
| phone | English Phone | German Telefon |
| English Email | German E-Mail | |
| revenue | English Revenue so far | German Umsatz bisher |
| openPipeline | English Open deals | German Offene Deals |
| lastContact | English Last contact | German Letzter Kontakt |
| nextMeeting | English Next meeting | German Nächster Termin |
| none | English None planned | German Noch keiner geplant |
| contactSheet | English Contact | German Kontakt |
| tabs | English Contact sections | German Bereiche des Kontakts |
| composer | English New entry | German Neuer Eintrag |
| entryType | English Type of entry | German Art des Eintrags |
| placeholderNote | English What did you agree on? | German Was habt ihr vereinbart? |
| placeholderCall | English What did you talk about? | German Worüber habt ihr gesprochen? |
| placeholderEmail | English Subject and short summary of the email | German Betreff und kurze Zusammenfassung der E-Mail |
| placeholderMeeting | English Topic and outcome of the meeting | German Thema und Ergebnis des Termins |
| add | English Add entry | German Eintrag hinzufügen |
| adding | English Adding… | German Wird hinzugefügt… |
| added | English Entry added to the history. | German Eintrag im Verlauf gespeichert. |
| shortcut | English Ctrl and Enter adds the entry | German Strg und Enter fügt den Eintrag hinzu |
| filter | English Show | German Zeigen |
| today | English Today | German Heute |
| yesterday | English Yesterday | German Gestern |
| minutes | English {n} min | German {n} Min. |
| more | English Read more | German Weiterlesen |
| less | English Show less | German Weniger zeigen |
| empty | English Nothing here yet. Your first entry starts the history. | German Hier steht noch nichts. Dein erster Eintrag beginnt den Verlauf. |
| inNumbers | English Contact in numbers | German Kontakt in Zahlen |
| pipeline | English Pipeline | German Pipeline |
| weighted | English Weighted | German Gewichtet |
| won | English Won | German Gewonnen |
| winRate | English Win rate | German Abschlussquote |
| stage | English Stage of {name} | German Phase von {name} |
| stageNow | English {name} is now in {stage}. | German {name} ist jetzt in {stage}. |
| nextStage | English Next stage | German Nächste Phase |
| prevStage | English Previous stage | German Vorige Phase |
| chance | English Chance {p} | German Chance {p} |
| closes | English Closes {date} | German Abschluss {date} |
| markLost | English Mark as lost | German Als verloren markieren |
| reopen | English Reopen deal | German Deal wieder öffnen |
| lostNow | English {name} is marked as lost. | German {name} ist als verloren markiert. |
| reopened | English {name} is open again. | German {name} ist wieder offen. |
| lostTag | English Lost | German Verloren |
| wonTag | English Won | German Gewonnen |
| newDeal | English New deal | German Neuer Deal |
| dealName | English Deal name | German Name des Deals |
| dealValue | English Value | German Wert |
| dealStage | English Stage | German Phase |
| createDeal | English Create deal | German Deal anlegen |
| dealCreated | English Deal {name} created. | German Deal {name} angelegt. |
| nameNeeded | English Give the deal a name. | German Gib dem Deal einen Namen. |
| valueNeeded | English Enter a value above zero. | German Trag einen Wert über null ein. |
| noDeals | English No deals yet. Create the first one below. | German Noch keine Deals. Leg unten den ersten an. |
| notes | English Notes | German Notizen |
| pinned | English Pinned | German Angeheftet |
| others | English More notes | German Weitere Notizen |
| writeNote | English Write a note, type @ to mention someone | German Notiz schreiben, mit @ jemanden erwähnen |
| saveNote | English Save note | German Notiz speichern |
| noteSaved | English Note saved. | German Notiz gespeichert. |
| pin | English Pin note | German Notiz anheften |
| unpin | English Unpin note | German Notiz lösen |
| edit | English Edit note | German Notiz bearbeiten |
| del | English Delete note | German Notiz löschen |
| deleted | English Note deleted. | German Notiz gelöscht. |
| undo | English Undo | German Rückgängig |
| restored | English Note restored. | German Notiz wiederhergestellt. |
| save | English Save changes | German Änderungen speichern |
| cancel | English Cancel | German Abbrechen |
| edited | English Note updated. | German Notiz aktualisiert. |
| mentions | English Mentions | German Erwähnungen |
| mentionsOf | English Notes mentioning {name} | German Notizen, die {name} erwähnen |
| allNotes | English All notes | German Alle Notizen |
| suggestions | English People to mention | German Personen zum Erwähnen |
| noNotes | English No note matches. Write the first one above. | German Keine Notiz passt. Schreib oben die erste. |
| searchNotes | English Search notes | German Notizen durchsuchen |
| contacts | English Contacts | German Kontakte |
| search | English Search contacts | German Kontakte suchen |
| noMatch | English No contact matches. Try a company or a city. | German Kein Kontakt passt. Versuch es mit Firma oder Ort. |
| back | English Back to contacts | German Zurück zu den Kontakten |
| recent | English Recent activity | German Letzte Aktivität |
| openDeals | English Open deals | German Offene Deals |
| count | English {n} contacts | German {n} Kontakte |
| nDeals | English {n} open | German {n} offen |
| ago | English Last contact {when} | German Letzter Kontakt {when} |
| noContact | English Pick a contact to see the details. | German Wähle einen Kontakt, um die Details zu sehen. |
Accessibility
Read from the source of the component.
- CRM contact handles the keys Escape, Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Tab and Space itself.
- Sets aria-activedescendant, aria-autocomplete, aria-checked, aria-controls, aria-describedby, aria-disabled, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles combobox, group, listbox, option, radio, radiogroup, status, tab, tablist and tabpanel.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,776 lines
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { useScrollFade } from '@/components/ui/motion';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type CrmContactVariant = 'profile' | 'timeline' | 'deals' | 'notes' | 'list-detail';
export type CrmActivityType = 'email' | 'call' | 'meeting' | 'note';
export interface CrmField {
label: string;
value: string;
/** mailto:, tel: or a web address */
href?: string;
}
export interface CrmPerson {
id: string;
name: string;
role?: string;
company?: string;
email?: string;
phone?: string;
location?: string;
/** Who looks after the contact */
owner?: string;
status?: 'lead' | 'customer' | 'inactive';
tags?: string[];
/** More fields, e.g. industry, website, customer number */
fields?: CrmField[];
/** Revenue so far, in the currency of the block */
revenue?: number;
/** ISO date and time of the last contact */
lastContact?: string;
/** ISO date and time of the next appointment */
nextMeeting?: string;
}
export interface CrmActivity {
id: string;
type: CrmActivityType;
title: string;
body?: string;
/** ISO date and time */
time: string;
author?: string;
/** Calls and meetings, in minutes */
minutes?: number;
/** list-detail: the contact it belongs to */
contactId?: string;
}
export interface CrmStage {
id: string;
label: string;
/** Default chance to win in this stage, 0 to 100 */
probability: number;
}
export interface CrmDeal {
id: string;
name: string;
value: number;
/** Stage id */
stage: string;
/** 0 to 100, defaults to the stage */
probability?: number;
/** ISO date of the expected close */
close?: string;
owner?: string;
lost?: boolean;
contactId?: string;
}
export interface CrmNote {
id: string;
/** Plain text, mentions written as @Name */
body: string;
author: string;
/** ISO date and time */
time: string;
pinned?: boolean;
contactId?: string;
}
export interface CrmContactProps {
variant?: CrmContactVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title?: string;
subtitle?: string;
/** ISO date and time that counts as now */
now?: string;
/** ISO currency code, e.g. EUR or USD */
currency?: string;
/** The person on screen (all variants but list-detail) */
contact?: CrmPerson;
/** list-detail: everybody in the list */
contacts?: CrmPerson[];
activities?: CrmActivity[];
deals?: CrmDeal[];
/** The pipeline in order. The last stage counts as won. */
stages?: CrmStage[];
notes?: CrmNote[];
/** Name of the person using the app, author of new entries */
me?: string;
/** People you can mention in notes with @ */
people?: string[];
/** Called on add, edit, stage change, pin and delete. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
email: 'Email',
call: 'Call',
meeting: 'Meeting',
note: 'Note',
emails: 'Emails',
calls: 'Calls',
meetings: 'Meetings',
notesTab: 'Notes',
all: 'All',
history: 'History',
deals: 'Deals',
details: 'Details',
showDetails: 'Show details',
hideDetails: 'Hide details',
sendEmail: 'Write email',
callNow: 'Call',
schedule: 'Book meeting',
scheduled: 'Meeting request sent to {name}.',
lead: 'Lead',
customer: 'Customer',
inactive: 'Inactive',
owner: 'Owner',
location: 'Location',
phone: 'Phone',
mail: 'Email',
revenue: 'Revenue so far',
openPipeline: 'Open deals',
lastContact: 'Last contact',
nextMeeting: 'Next meeting',
none: 'None planned',
contactSheet: 'Contact',
tabs: 'Contact sections',
composer: 'New entry',
entryType: 'Type of entry',
placeholderNote: 'What did you agree on?',
placeholderCall: 'What did you talk about?',
placeholderEmail: 'Subject and short summary of the email',
placeholderMeeting: 'Topic and outcome of the meeting',
add: 'Add entry',
adding: 'Adding…',
added: 'Entry added to the history.',
shortcut: 'Ctrl and Enter adds the entry',
filter: 'Show',
today: 'Today',
yesterday: 'Yesterday',
minutes: '{n} min',
more: 'Read more',
less: 'Show less',
empty: 'Nothing here yet. Your first entry starts the history.',
inNumbers: 'Contact in numbers',
pipeline: 'Pipeline',
weighted: 'Weighted',
won: 'Won',
winRate: 'Win rate',
stage: 'Stage of {name}',
stageNow: '{name} is now in {stage}.',
nextStage: 'Next stage',
prevStage: 'Previous stage',
chance: 'Chance {p}',
closes: 'Closes {date}',
markLost: 'Mark as lost',
reopen: 'Reopen deal',
lostNow: '{name} is marked as lost.',
reopened: '{name} is open again.',
lostTag: 'Lost',
wonTag: 'Won',
newDeal: 'New deal',
dealName: 'Deal name',
dealValue: 'Value',
dealStage: 'Stage',
createDeal: 'Create deal',
dealCreated: 'Deal {name} created.',
nameNeeded: 'Give the deal a name.',
valueNeeded: 'Enter a value above zero.',
noDeals: 'No deals yet. Create the first one below.',
notes: 'Notes',
pinned: 'Pinned',
others: 'More notes',
writeNote: 'Write a note, type @ to mention someone',
saveNote: 'Save note',
noteSaved: 'Note saved.',
pin: 'Pin note',
unpin: 'Unpin note',
edit: 'Edit note',
del: 'Delete note',
deleted: 'Note deleted.',
undo: 'Undo',
restored: 'Note restored.',
save: 'Save changes',
cancel: 'Cancel',
edited: 'Note updated.',
mentions: 'Mentions',
mentionsOf: 'Notes mentioning {name}',
allNotes: 'All notes',
suggestions: 'People to mention',
noNotes: 'No note matches. Write the first one above.',
searchNotes: 'Search notes',
contacts: 'Contacts',
search: 'Search contacts',
noMatch: 'No contact matches. Try a company or a city.',
back: 'Back to contacts',
recent: 'Recent activity',
openDeals: 'Open deals',
count: '{n} contacts',
nDeals: '{n} open',
ago: 'Last contact {when}',
noContact: 'Pick a contact to see the details.',
},
de: {
email: 'E-Mail',
call: 'Anruf',
meeting: 'Termin',
note: 'Notiz',
emails: 'E-Mails',
calls: 'Anrufe',
meetings: 'Termine',
notesTab: 'Notizen',
all: 'Alle',
history: 'Verlauf',
deals: 'Deals',
details: 'Details',
showDetails: 'Details zeigen',
hideDetails: 'Details ausblenden',
sendEmail: 'E-Mail schreiben',
callNow: 'Anrufen',
schedule: 'Termin buchen',
scheduled: 'Terminanfrage an {name} gesendet.',
lead: 'Lead',
customer: 'Kunde',
inactive: 'Inaktiv',
owner: 'Betreut von',
location: 'Ort',
phone: 'Telefon',
mail: 'E-Mail',
revenue: 'Umsatz bisher',
openPipeline: 'Offene Deals',
lastContact: 'Letzter Kontakt',
nextMeeting: 'Nächster Termin',
none: 'Noch keiner geplant',
contactSheet: 'Kontakt',
tabs: 'Bereiche des Kontakts',
composer: 'Neuer Eintrag',
entryType: 'Art des Eintrags',
placeholderNote: 'Was habt ihr vereinbart?',
placeholderCall: 'Worüber habt ihr gesprochen?',
placeholderEmail: 'Betreff und kurze Zusammenfassung der E-Mail',
placeholderMeeting: 'Thema und Ergebnis des Termins',
add: 'Eintrag hinzufügen',
adding: 'Wird hinzugefügt…',
added: 'Eintrag im Verlauf gespeichert.',
shortcut: 'Strg und Enter fügt den Eintrag hinzu',
filter: 'Zeigen',
today: 'Heute',
yesterday: 'Gestern',
minutes: '{n} Min.',
more: 'Weiterlesen',
less: 'Weniger zeigen',
empty: 'Hier steht noch nichts. Dein erster Eintrag beginnt den Verlauf.',
inNumbers: 'Kontakt in Zahlen',
pipeline: 'Pipeline',
weighted: 'Gewichtet',
won: 'Gewonnen',
winRate: 'Abschlussquote',
stage: 'Phase von {name}',
stageNow: '{name} ist jetzt in {stage}.',
nextStage: 'Nächste Phase',
prevStage: 'Vorige Phase',
chance: 'Chance {p}',
closes: 'Abschluss {date}',
markLost: 'Als verloren markieren',
reopen: 'Deal wieder öffnen',
lostNow: '{name} ist als verloren markiert.',
reopened: '{name} ist wieder offen.',
lostTag: 'Verloren',
wonTag: 'Gewonnen',
newDeal: 'Neuer Deal',
dealName: 'Name des Deals',
dealValue: 'Wert',
dealStage: 'Phase',
createDeal: 'Deal anlegen',
dealCreated: 'Deal {name} angelegt.',
nameNeeded: 'Gib dem Deal einen Namen.',
valueNeeded: 'Trag einen Wert über null ein.',
noDeals: 'Noch keine Deals. Leg unten den ersten an.',
notes: 'Notizen',
pinned: 'Angeheftet',
others: 'Weitere Notizen',
writeNote: 'Notiz schreiben, mit @ jemanden erwähnen',
saveNote: 'Notiz speichern',
noteSaved: 'Notiz gespeichert.',
pin: 'Notiz anheften',
unpin: 'Notiz lösen',
edit: 'Notiz bearbeiten',
del: 'Notiz löschen',
deleted: 'Notiz gelöscht.',
undo: 'Rückgängig',
restored: 'Notiz wiederhergestellt.',
save: 'Änderungen speichern',
cancel: 'Abbrechen',
edited: 'Notiz aktualisiert.',
mentions: 'Erwähnungen',
mentionsOf: 'Notizen, die {name} erwähnen',
allNotes: 'Alle Notizen',
suggestions: 'Personen zum Erwähnen',
noNotes: 'Keine Notiz passt. Schreib oben die erste.',
searchNotes: 'Notizen durchsuchen',
contacts: 'Kontakte',
search: 'Kontakte suchen',
noMatch: 'Kein Kontakt passt. Versuch es mit Firma oder Ort.',
back: 'Zurück zu den Kontakten',
recent: 'Letzte Aktivität',
openDeals: 'Offene Deals',
count: '{n} Kontakte',
nDeals: '{n} offen',
ago: 'Letzter Kontakt {when}',
noContact: 'Wähle einen Kontakt, um die Details zu sehen.',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
/*
* An area waits until it scrolls in (data-crmc=wait), then plays. Children
* take crmc-up, crmc-pop or crmc-grow with --crmc-d as delay. crmc-in, crmc-fly
* and crmc-flash run once when something new appears. The server renders the
* end state, without JavaScript nothing waits longer than 2.5 s, with reduced
* motion nothing moves.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const FX: [string, string, number][] = [
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.94)', 450],
['grow', 'transform:scaleX(0)', 900],
];
const CSS =
FX.map(([k, v]) => `@keyframes crmc-${k}{from{${v}}}@keyframes crmc-${k}-h{from{${v}}}`).join('') +
'@keyframes crmc-fly{from{opacity:0;transform:translateX(16px)}}@keyframes crmc-flash{from{background-color:color-mix(in oklab,var(--primary) 12%,transparent)}}.crmc-grow{transform-origin:left}' +
'@media (prefers-reduced-motion:no-preference){' +
FX.map(([k, , d]) => `[data-crmc=play] .crmc-${k}{animation:crmc-${k} ${d}ms var(--ease-kit) var(--crmc-d,0ms) both}[data-crmc=wait] .crmc-${k}{animation:crmc-${k}-h 0s 1s both paused}`).join('') +
'.crmc-in{animation:crmc-up .45s var(--ease-kit) var(--crmc-d,0ms) both}.crmc-fly{animation:crmc-fly .45s var(--ease-kit) var(--crmc-d,0ms) both}.crmc-flash{animation:crmc-flash 1.8s ease-out both}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
FX.map(([k]) => `[data-crmc-scope]:not([data-crmc]) .crmc-${k}{animation:crmc-${k}-h 0s 2.5s both}`).join('') +
'}';
function Style() {
return (
<style href="kitb5-crm-contact" precedence="default">
{CSS}
</style>
);
}
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-crmc-scope': '', 'data-crmc': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
/** The marker [data-glide] inside the active item glides over from the one before */
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const MIN = 60000;
const toMs = (iso: string) => Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const tagOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: CrmContactProps['onAction'], action: string, data: Record<string, unknown>, ms = 600) {
if (onAction) await onAction(action, data);
else await wait(prefersReducedMotion() ? 0 : ms);
}
type Fmt = ReturnType<typeof makeFmt>;
function makeFmt(locale: Locale, currency: string, now: string) {
const tag = tagOf(locale);
const money0 = new Intl.NumberFormat(tag, { style: 'currency', currency, maximumFractionDigits: 0 });
const num = new Intl.NumberFormat(tag);
const pct = (n: number) => (locale === 'de' ? `${num.format(Math.round(n))} %` : `${num.format(Math.round(n))}%`);
const date = (iso: string, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(tag, { timeZone: 'UTC', ...o }).format(new Date(toMs(iso)));
const time = (iso: string) => date(iso, { hour: locale === 'de' ? '2-digit' : 'numeric', minute: '2-digit' });
const rtf = new Intl.RelativeTimeFormat(tag, { numeric: 'auto' });
const ago = (iso: string) => {
const d = Math.round((toMs(iso) - toMs(now)) / MIN);
const a = Math.abs(d);
if (a < 60) return rtf.format(d, 'minute');
if (a < 1440) return rtf.format(Math.round(d / 60), 'hour');
if (a < 1440 * 45) return rtf.format(Math.round(d / 1440), 'day');
return rtf.format(Math.round(d / 43200), 'month');
};
const dayKey = (iso: string) => Math.floor(toMs(iso) / 86400000);
return { money: (n: number) => money0.format(n), num: (n: number) => num.format(n), pct, date, time, ago, dayKey };
}
type P = Omit<CrmContactProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; now: string; locale: Locale; f: Fmt; me: string };
const TYPE: Record<CrmActivityType, { icon: IconName; tone: string }> = {
email: { icon: 'mail', tone: 'bg-[color-mix(in_oklab,var(--primary)_13%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' },
call: { icon: 'phone', tone: 'bg-[color-mix(in_oklab,var(--success)_16%,var(--card))] text-success-foreground' },
meeting: { icon: 'calendar', tone: 'bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-warning-foreground' },
note: { icon: 'edit', tone: 'bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] text-muted-foreground' },
};
const focusRing = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const field =
'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground hover:border-border-strong focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] aria-[invalid=true]:border-destructive sm:h-9 sm:text-[13px]';
const area =
'block w-full min-w-0 resize-none rounded-lg border border-input bg-background px-3 py-2.5 text-base leading-relaxed text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground hover:border-border-strong focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:text-[13.5px]';
const iconBtn = cn('grid size-11 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground sm:size-9', focusRing);
function Header({ title, subtitle, children }: { title?: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
if (!title && !subtitle && !children) return null;
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
{title && <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>}
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
const cardCls = 'rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
/** Chips as a radiogroup with arrow keys, scrolls sideways on phones */
function Chips<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: string; count?: number; icon?: IconName }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const [row, fade] = useScrollFade<HTMLDivElement>();
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
const to = e.key === 'Home' ? 0 : e.key === 'End' ? options.length - 1 : dir ? (i + dir + options.length) % options.length : -1;
if (to < 0) return;
e.preventDefault();
onChange(options[to].value);
const el = row.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[to];
el?.focus();
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
};
return (
<div ref={row} role="radiogroup" aria-label={label} className={cn('kit-no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 py-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0', fade, className)}>
{options.map((o, i) => {
const on = o.value === value;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn(
'inline-flex h-11 shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border px-3.5 text-[13px] font-medium transition-[background-color,border-color,color] duration-200 sm:h-9',
focusRing,
on ? 'border-transparent bg-foreground text-background' : 'border-border bg-card text-muted-foreground hover:border-border-strong hover:text-foreground',
)}
>
{o.icon && <Icon name={o.icon} size={14} />}
{o.label}
{o.count !== undefined && <span className={cn('text-[12px] tabular-nums', on ? 'text-[color-mix(in_oklab,var(--background)_70%,transparent)]' : 'text-subtle-foreground')}>{o.count}</span>}
</button>
);
})}
</div>
);
}
/** Tabs after the APG pattern: arrow keys, Home and End, automatic activation */
function Tabs<T extends string>({ id, tabs, value, onChange, label, className }: { id: string; tabs: { value: T; label: string; count?: number }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useGlide<HTMLDivElement>(value);
const go = (i: number) => {
const n = (i + tabs.length) % tabs.length;
onChange(tabs[n].value);
document.getElementById(`${id}-tab-${tabs[n].value}`)?.focus();
};
return (
<div ref={ref} role="tablist" aria-label={label} className={cn('relative flex border-b border-border', className)}>
{tabs.map((x, i) => {
const on = x.value === value;
return (
<button
key={x.value}
id={`${id}-tab-${x.value}`}
type="button"
role="tab"
aria-selected={on}
aria-controls={`${id}-panel-${x.value}`}
tabIndex={on ? 0 : -1}
onClick={() => onChange(x.value)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') (e.preventDefault(), go(i + 1));
if (e.key === 'ArrowLeft') (e.preventDefault(), go(i - 1));
if (e.key === 'Home') (e.preventDefault(), go(0));
if (e.key === 'End') (e.preventDefault(), go(tabs.length - 1));
}}
className={cn('relative inline-flex h-12 flex-1 cursor-pointer items-center justify-center gap-1.5 px-3 text-[13.5px] font-medium outline-none transition-colors sm:flex-none sm:justify-start sm:px-4', 'focus-visible:bg-[color-mix(in_oklab,var(--primary)_8%,transparent)] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
>
{x.label}
{x.count !== undefined && <span className="rounded-full bg-muted px-1.5 text-[11.5px] tabular-nums text-muted-foreground">{x.count}</span>}
{on && <span data-glide aria-hidden className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-primary" />}
</button>
);
})}
</div>
);
}
function StatusBadge({ status, t }: { status?: CrmPerson['status']; t: Texts }) {
if (!status) return null;
return (
<span
className={cn(
'inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium',
status === 'customer' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : status === 'lead' ? 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'bg-muted text-muted-foreground',
)}
>
<span className={cn('size-1.5 rounded-full', status === 'customer' ? 'bg-success' : status === 'lead' ? 'bg-primary' : 'bg-subtle-foreground')} />
{t[status]}
</span>
);
}
/** Mail, call and meeting as three real actions */
function QuickActions({ c, t, onMeeting, className }: { c: CrmPerson; t: Texts; onMeeting: () => void; className?: string }) {
const item = cn('group flex min-h-11 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-border bg-surface px-2 py-2.5 text-[12.5px] font-medium text-foreground transition-[border-color,background-color,translate] duration-300 hover:-translate-y-0.5 hover:border-border-strong hover:bg-card', focusRing);
const ic = 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] transition-transform duration-300 group-hover:scale-110';
return (
<div className={cn('grid grid-cols-3 gap-2', className)}>
<a href={c.email ? `mailto:${c.email}` : undefined} className={item}>
<Icon name="mail" size={16} className={ic} />
{t.sendEmail}
</a>
<a href={c.phone ? `tel:${c.phone.replace(/[^\d+]/g, '')}` : undefined} className={item}>
<Icon name="phone" size={16} className={ic} />
{t.callNow}
</a>
<button type="button" onClick={onMeeting} className={item}>
<Icon name="calendar-check" size={16} className={ic} />
{t.schedule}
</button>
</div>
);
}
function detailRows(c: CrmPerson, t: Texts, f: Fmt): CrmField[] {
const rows: CrmField[] = [];
if (c.email) rows.push({ label: t.mail, value: c.email, href: `mailto:${c.email}` });
if (c.phone) rows.push({ label: t.phone, value: c.phone, href: `tel:${c.phone.replace(/[^\d+]/g, '')}` });
if (c.location) rows.push({ label: t.location, value: c.location });
if (c.owner) rows.push({ label: t.owner, value: c.owner });
if (c.revenue !== undefined) rows.push({ label: t.revenue, value: f.money(c.revenue) });
return [...rows, ...(c.fields ?? [])];
}
function Fields({ rows, className }: { rows: CrmField[]; className?: string }) {
return (
<dl className={cn('grid gap-0', className)}>
{rows.map((r) => (
<div key={r.label} className="grid grid-cols-[minmax(0,7.5rem)_minmax(0,1fr)] items-baseline gap-3 border-b border-border py-2.5 last:border-b-0">
<dt className="truncate text-[12.5px] text-muted-foreground">{r.label}</dt>
<dd className={cn('min-w-0 text-[13.5px] text-foreground', r.href ? 'break-all' : 'truncate')}>
{r.href ? (
<a href={r.href} className={cn('cursor-pointer rounded-sm underline decoration-border-strong underline-offset-4 transition-colors hover:decoration-primary', focusRing)}>
{r.value}
</a>
) : (
r.value
)}
</dd>
</div>
))}
</dl>
);
}
/** Head of a contact: avatar, name, role at company, status and tags */
function PersonHead({ c, t, size = 'xl', center }: { c: CrmPerson; t: Texts; size?: 'lg' | 'xl'; center?: boolean }) {
return (
<div className={cn('flex min-w-0 gap-4', center ? 'flex-col items-center text-center sm:flex-row sm:items-center sm:text-left' : 'items-center')}>
<Avatar name={c.name} size={size} />
<div className="min-w-0">
<h2 className="truncate text-[19px] font-semibold tracking-[-0.02em] text-foreground">{c.name}</h2>
<p className="mt-0.5 truncate text-[13.5px] text-muted-foreground">{[c.role, c.company].filter(Boolean).join(' · ')}</p>
<div className={cn('mt-2 flex flex-wrap items-center gap-1.5', center && 'justify-center sm:justify-start')}>
<StatusBadge status={c.status} t={t} />
{c.tags?.map((x) => (
<span key={x} className="inline-flex h-6 items-center rounded-md border border-border px-2 text-[12px] text-muted-foreground">
{x}
</span>
))}
</div>
</div>
</div>
);
}
function ActivityRow({ a, t, f, compact, fresh }: { a: CrmActivity; t: Texts; f: Fmt; compact?: boolean; fresh?: boolean }) {
const [open, setOpen] = useState(false);
const id = useId();
const long = !compact && (a.body?.length ?? 0) > 150;
const ty = TYPE[a.type];
return (
<li className={cn('relative grid grid-cols-[2.25rem_minmax(0,1fr)] gap-3 rounded-lg py-3', fresh && 'crmc-in crmc-flash')}>
<span className={cn('relative z-[1] grid size-9 place-items-center rounded-full ring-4 ring-card', ty.tone)}>
<Icon name={ty.icon} size={15} />
</span>
<div className="min-w-0 pt-0.5">
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-[14px] font-medium text-foreground">{a.title}</span>
<span className="text-[12.5px] tabular-nums text-subtle-foreground">
{[t[a.type], a.author, f.time(a.time), a.minutes ? fill(t.minutes, { n: a.minutes }) : ''].filter(Boolean).join(' · ')}
</span>
</p>
{a.body && (
<div id={id} className={cn('mt-1 text-pretty text-[13.5px] leading-relaxed text-muted-foreground', (compact || (long && !open)) && 'line-clamp-2')}>
{a.body}
</div>
)}
{long && (
<button type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)} className={cn('mt-1 inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-sm text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))] hover:underline sm:min-h-0', focusRing)}>
{open ? t.less : t.more}
<Icon name="chevron-down" size={13} className={cn('transition-transform duration-300', open && 'rotate-180')} />
</button>
)}
</div>
</li>
);
}
/** Activities grouped by day on one vertical line */
function ActivityList({ items, t, f, now, compact, freshId }: { items: CrmActivity[]; t: Texts; f: Fmt; now: string; compact?: boolean; freshId?: string }) {
const today = f.dayKey(now);
const groups = useMemo(() => {
const out: { key: number; items: CrmActivity[] }[] = [];
[...items].sort((a, b) => toMs(b.time) - toMs(a.time)).forEach((a) => {
const k = f.dayKey(a.time);
const g = out.find((x) => x.key === k);
if (g) g.items.push(a);
else out.push({ key: k, items: [a] });
});
return out;
}, [items, f]);
if (!items.length) return <p className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.empty}</p>;
return (
<div className="grid gap-4">
{groups.map((g) => (
<section key={g.key} aria-label={g.key === today ? t.today : g.key === today - 1 ? t.yesterday : f.date(g.items[0].time, { weekday: 'long', day: 'numeric', month: 'long' })}>
<h3 className="py-1 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">
{g.key === today ? t.today : g.key === today - 1 ? t.yesterday : f.date(g.items[0].time, { weekday: 'long', day: 'numeric', month: 'long' })}
</h3>
<ol className="relative">
<span aria-hidden className="absolute bottom-5 start-[17.5px] top-5 w-px bg-border" />
{g.items.map((a) => (
<ActivityRow key={a.id} a={a} t={t} f={f} compact={compact} fresh={a.id === freshId} />
))}
</ol>
</section>
))}
</div>
);
}
const stageOf = (stages: CrmStage[], id: string) => Math.max(0, stages.findIndex((s) => s.id === id));
const probOf = (d: CrmDeal, stages: CrmStage[]) => (d.lost ? 0 : (d.probability ?? stages.find((s) => s.id === d.stage)?.probability ?? 0));
function DealRow({ d, stages, t, f }: { d: CrmDeal; stages: CrmStage[]; t: Texts; f: Fmt }) {
const i = stageOf(stages, d.stage);
const p = probOf(d, stages);
const won = i === stages.length - 1 && !d.lost;
return (
<li className="grid gap-2 rounded-lg border border-border bg-surface p-3.5 transition-[border-color,translate] duration-300 hover:-translate-y-0.5 hover:border-border-strong">
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-[14px] font-medium text-foreground">{d.name}</span>
<span className="shrink-0 text-[14px] font-semibold tabular-nums text-foreground">{f.money(d.value)}</span>
</div>
<div className="flex gap-1" aria-hidden>
{stages.map((s, k) => (
<span key={s.id} className={cn('h-1.5 flex-1 rounded-full', d.lost ? 'bg-track' : k <= i ? (won ? 'bg-success' : 'bg-primary') : 'bg-track')} />
))}
</div>
<p className="flex flex-wrap justify-between gap-x-3 text-[12.5px] text-muted-foreground">
<span>{d.lost ? t.lostTag : stages[i]?.label}</span>
<span className="tabular-nums">{[fill(t.chance, { p: f.pct(p) }), d.close ? fill(t.closes, { date: f.date(d.close, { day: 'numeric', month: 'short' }) }) : ''].filter(Boolean).join(' · ')}</span>
</p>
</li>
);
}
function Mentioned({ text, people }: { text: string; people: string[] }) {
const parts = useMemo(() => {
if (!people.length) return [text];
const names = [...people].sort((a, b) => b.length - a.length).map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
return text.split(new RegExp(`(@(?:${names.join('|')}))`, 'g'));
}, [text, people]);
return (
<>
{parts.map((p, i) =>
p.startsWith('@') && people.includes(p.slice(1)) ? (
<span key={i} className="rounded bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-1 font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
{p}
</span>
) : (
<span key={i}>{p}</span>
),
)}
</>
);
}
/* ------------------------------------------------------------------ */
/* profile */
/* ------------------------------------------------------------------ */
function Profile({ t, f, title, subtitle, contact, activities = [], deals = [], stages = [], notes = [], people = [], now, onAction }: P) {
const id = useId();
const [tab, setTab] = useState<'history' | 'deals' | 'notes'>('history');
const [details, setDetails] = useState(false);
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
if (!contact) return null;
const c = contact;
const open = deals.filter((d) => !d.lost && stageOf(stages, d.stage) < stages.length - 1);
const openSum = open.reduce((s, d) => s + d.value, 0);
const sorted = [...notes].sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned) || toMs(b.time) - toMs(a.time));
const tiles = [
{ label: t.openPipeline, value: f.money(openSum), note: fill(t.nDeals, { n: open.length }) },
{ label: t.lastContact, value: c.lastContact ? f.ago(c.lastContact) : t.none, note: c.lastContact ? f.date(c.lastContact, { day: 'numeric', month: 'short' }) : '' },
{ label: t.nextMeeting, value: c.nextMeeting ? f.date(c.nextMeeting, { weekday: 'short', day: 'numeric', month: 'short' }) : t.none, note: c.nextMeeting ? f.time(c.nextMeeting) : '' },
];
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[22rem_minmax(0,1fr)] lg:items-start">
<section aria-label={t.contactSheet} className={cn(cardCls, 'crmc-up p-4 sm:p-5 lg:sticky lg:top-6')}>
<PersonHead c={c} t={t} center />
<QuickActions
c={c}
t={t}
className="mt-5"
onMeeting={async () => {
await run(onAction, 'schedule', { id: c.id });
setMsg(fill(t.scheduled, { name: c.name }));
}}
/>
{/* Phones and tablets: details fold away under a button. Desktop: always open. */}
<span aria-hidden className="mt-4 block h-px bg-border lg:hidden" />
<button type="button" aria-expanded={details} aria-controls={`${id}-details`} onClick={() => setDetails(!details)} className={cn('mt-1 flex h-11 w-full cursor-pointer items-center justify-between rounded-md px-1 text-[13.5px] font-medium text-foreground lg:hidden', focusRing)}>
{details ? t.hideDetails : t.showDetails}
<Icon name="chevron-down" size={16} className={cn('text-muted-foreground transition-transform duration-300', details && 'rotate-180')} />
</button>
<div id={`${id}-details`} className={cn('mt-2 lg:mt-5 lg:block lg:border-t lg:border-border lg:pt-2', details ? 'block crmc-in' : 'hidden')}>
<h3 className="sr-only">{t.details}</h3>
<Fields rows={detailRows(c, t, f)} />
</div>
</section>
<div className="grid min-w-0 gap-5">
<ul className="grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-3">
{tiles.map((x, i) => (
<li key={x.label} className="bg-card px-4 py-3.5 sm:px-5 sm:py-4">
<div className="crmc-up flex items-baseline justify-between gap-3 sm:block" style={{ '--crmc-d': `${80 + i * 70}ms` } as React.CSSProperties}>
<p className="text-[12.5px] text-muted-foreground">{x.label}</p>
<p className="text-right sm:mt-1 sm:text-left">
<span className="text-[17px] font-semibold tracking-[-0.02em] tabular-nums text-foreground sm:text-[20px]">{x.value}</span>
{x.note && <span className="ms-2 text-[12.5px] tabular-nums text-subtle-foreground sm:ms-0 sm:block">{x.note}</span>}
</p>
</div>
</li>
))}
</ul>
<section className={cn(cardCls, 'crmc-up min-w-0 overflow-hidden [--crmc-d:160ms]')}>
<Tabs
id={id}
label={t.tabs}
value={tab}
onChange={setTab}
className="px-1 sm:px-3"
tabs={[
{ value: 'history', label: t.history, count: activities.length },
{ value: 'deals', label: t.deals, count: deals.length },
{ value: 'notes', label: t.notesTab, count: notes.length },
]}
/>
<div role="tabpanel" id={`${id}-panel-${tab}`} aria-labelledby={`${id}-tab-${tab}`} tabIndex={0} className={cn('p-4 sm:p-5', 'outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_50%,transparent)]')}>
<div key={tab} className="crmc-in">
{tab === 'history' && <ActivityList items={activities.slice(0, 6)} t={t} f={f} now={now} compact />}
{tab === 'deals' &&
(deals.length ? (
<ul className="grid gap-2.5">
{deals.map((d) => (
<DealRow key={d.id} d={d} stages={stages} t={t} f={f} />
))}
</ul>
) : (
<p className="py-8 text-center text-[13.5px] text-muted-foreground">{t.noDeals}</p>
))}
{tab === 'notes' && (
<ul className="grid gap-2.5">
{sorted.map((n) => (
<li key={n.id} className="rounded-lg border border-border bg-surface p-3.5">
<p className="flex items-center gap-2 text-[12.5px] text-subtle-foreground">
{n.pinned && <Icon name="pin" size={13} className="text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />}
<span className="font-medium text-muted-foreground">{n.author}</span>
<span className="tabular-nums">{f.ago(n.time)}</span>
</p>
<p className="mt-1.5 text-pretty text-[13.5px] leading-relaxed text-foreground">
<Mentioned text={n.body} people={people} />
</p>
</li>
))}
</ul>
)}
</div>
</div>
</section>
</div>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* timeline */
/* ------------------------------------------------------------------ */
function Timeline({ t, f, title, subtitle, contact, activities = [], now, me, onAction }: P) {
const id = useId();
const [items, setItems] = useState(activities);
const [filter, setFilter] = useState<'all' | CrmActivityType>('all');
const [kind, setKind] = useState<CrmActivityType>('note');
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const [fresh, setFresh] = useState('');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const counts = (k: CrmActivityType) => items.filter((a) => a.type === k).length;
const shown = items.filter((a) => filter === 'all' || a.type === filter);
const kinds: CrmActivityType[] = ['note', 'call', 'email', 'meeting'];
const ph = { note: t.placeholderNote, call: t.placeholderCall, email: t.placeholderEmail, meeting: t.placeholderMeeting };
const add = async () => {
const body = text.trim();
if (!body || busy) return;
setBusy(true);
const lines = body.split('\n');
const a: CrmActivity = { id: `a${Date.now()}`, type: kind, title: lines[0].slice(0, 80), body: lines.slice(1).join('\n').trim() || (lines[0].length > 80 ? body : undefined), time: now, author: me };
await run(onAction, 'add-activity', { activity: a });
setItems((l) => [a, ...l]);
setFresh(a.id);
setText('');
setBusy(false);
if (filter !== 'all' && filter !== kind) setFilter('all');
setMsg(t.added);
};
const total = Math.max(1, items.length);
const stats = kinds.map((k) => ({ k, n: counts(k) }));
const kindRef = useGlide<HTMLDivElement>(kind);
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[minmax(0,1fr)_19rem] lg:items-start">
<div className="grid min-w-0 gap-5">
{/* Composer */}
<section aria-labelledby={`${id}-c`} className={cn(cardCls, 'crmc-up p-4 sm:p-5')}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 id={`${id}-c`} className="text-[14px] font-semibold text-foreground">
{t.composer}
</h2>
<div
ref={kindRef}
role="radiogroup"
aria-label={t.entryType}
className="relative grid grid-cols-4 rounded-lg bg-muted p-1 sm:inline-grid"
onKeyDown={(e) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const n = kinds[(kinds.indexOf(kind) + dir + kinds.length) % kinds.length];
setKind(n);
kindRef.current?.querySelector<HTMLButtonElement>(`[data-k=${n}]`)?.focus();
}}
>
{kinds.map((k) => (
<button key={k} data-k={k} type="button" role="radio" title={t[k]} aria-checked={kind === k} tabIndex={kind === k ? 0 : -1} onClick={() => setKind(k)} className={cn('relative inline-flex h-11 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium transition-colors sm:h-8', focusRing, kind === k ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
{kind === k && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<Icon name={TYPE[k].icon} size={14} className="relative" />
<span className="relative max-sm:sr-only lg:max-xl:sr-only">{t[k]}</span>
</button>
))}
</div>
</div>
<label htmlFor={`${id}-text`} className="sr-only">
{ph[kind]}
</label>
<textarea
id={`${id}-text`}
rows={3}
value={text}
placeholder={ph[kind]}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) (e.preventDefault(), void add());
}}
aria-describedby={`${id}-hint`}
className={cn(area, 'mt-3')}
/>
<div className="mt-3 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<p id={`${id}-hint`} className="text-[12.5px] text-subtle-foreground max-sm:hidden">
{t.shortcut}
</p>
<Button size="sm" iconLeft={busy ? 'loader' : 'plus'} disabled={!text.trim() || busy} onClick={() => void add()} className="max-sm:w-full">
{busy ? t.adding : t.add}
</Button>
</div>
</section>
{/* Phones and tablets: the numbers as one row above the history */}
<ul aria-label={t.inNumbers} className="crmc-up grid grid-cols-4 gap-px overflow-hidden rounded-xl border border-border bg-border [--crmc-d:60ms] lg:hidden">
{stats.map(({ k, n }) => (
<li key={k} className="flex flex-col items-center gap-1 bg-card px-1 py-3">
<Icon name={TYPE[k].icon} size={15} className="text-muted-foreground" />
<span className="text-[16px] font-semibold tabular-nums text-foreground">{n}</span>
<span className="w-full truncate text-center text-[11.5px] text-muted-foreground">{t[`${k}s` as 'emails'] ?? t[k]}</span>
</li>
))}
</ul>
<section aria-label={t.history} className={cn(cardCls, 'crmc-up min-w-0 p-4 [--crmc-d:120ms] sm:p-5')}>
<Chips
label={t.filter}
value={filter}
onChange={setFilter}
options={[
{ value: 'all', label: t.all, count: items.length },
{ value: 'email', label: t.emails, count: counts('email') },
{ value: 'call', label: t.calls, count: counts('call') },
{ value: 'meeting', label: t.meetings, count: counts('meeting') },
{ value: 'note', label: t.notesTab, count: counts('note') },
]}
/>
<div key={filter} className="crmc-in mt-4">
<ActivityList items={shown} t={t} f={f} now={now} freshId={fresh} />
</div>
</section>
</div>
{/* Desktop: the contact and the numbers next to the history */}
<aside className="hidden gap-5 lg:sticky lg:top-6 lg:grid">
{contact && (
<section aria-label={t.contactSheet} className={cn(cardCls, 'crmc-up p-5 [--crmc-d:80ms]')}>
<PersonHead c={contact} t={t} size="lg" />
<dl className="mt-4 grid gap-2 border-t border-border pt-4 text-[13px]">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">{t.lastContact}</dt>
<dd className="tabular-nums text-foreground">{contact.lastContact ? f.ago(contact.lastContact) : t.none}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">{t.nextMeeting}</dt>
<dd className="text-right tabular-nums text-foreground">{contact.nextMeeting ? `${f.date(contact.nextMeeting, { weekday: 'short', day: 'numeric', month: 'short' })}, ${f.time(contact.nextMeeting)}` : t.none}</dd>
</div>
</dl>
</section>
)}
<section className={cn(cardCls, 'crmc-up p-5 [--crmc-d:160ms]')}>
<h2 className="text-[13px] font-semibold text-foreground">{t.inNumbers}</h2>
<ul className="mt-4 grid gap-3.5">
{stats.map(({ k, n }, i) => (
<li key={k} className="grid gap-1.5">
<p className="flex items-center justify-between text-[13px]">
<span className="inline-flex items-center gap-2 text-muted-foreground">
<Icon name={TYPE[k].icon} size={14} />
{t[`${k}s` as 'emails'] ?? t[k]}
</span>
<span className="font-medium tabular-nums text-foreground">{n}</span>
</p>
<span className="block h-1.5 overflow-hidden rounded-full bg-track">
<span className="crmc-grow block h-full rounded-full bg-primary transition-[width] duration-500" style={{ width: `${Math.round((n / total) * 100)}%`, '--crmc-d': `${200 + i * 80}ms` } as React.CSSProperties} />
</span>
</li>
))}
</ul>
</section>
</aside>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* deals */
/* ------------------------------------------------------------------ */
function Ladder({ d, stages, t, onChange }: { d: CrmDeal; stages: CrmStage[]; t: Texts; onChange: (stage: string) => void }) {
const ref = useRef<HTMLDivElement>(null);
const i = stageOf(stages, d.stage);
const won = i === stages.length - 1;
const go = (n: number) => {
const k = Math.max(0, Math.min(stages.length - 1, n));
onChange(stages[k].id);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[k]?.focus();
};
return (
<>
{/* Tablet and desktop: every stage is a step you can pick */}
<div ref={ref} role="radiogroup" aria-label={fill(t.stage, { name: d.name })} aria-disabled={d.lost || undefined} className="hidden grid-flow-col gap-1 sm:grid sm:auto-cols-fr">
{stages.map((s, k) => {
const on = k === i;
const done = k <= i && !d.lost;
return (
<button
key={s.id}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
disabled={d.lost}
onClick={() => go(k)}
onKeyDown={(e) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (dir) (e.preventDefault(), go(k + dir));
if (e.key === 'Home') (e.preventDefault(), go(0));
if (e.key === 'End') (e.preventDefault(), go(stages.length - 1));
}}
className={cn('group relative min-w-0 cursor-pointer pt-3 text-left disabled:cursor-not-allowed', focusRing, 'rounded-sm')}
>
<span className={cn('absolute inset-x-0 top-0 h-1.5 rounded-full transition-[background-color] duration-500', done ? (won ? 'bg-success' : 'bg-primary') : 'bg-track group-hover:bg-[color-mix(in_oklab,var(--foreground)_22%,transparent)]')} />
<span className={cn('block truncate text-[12px] transition-colors', on ? 'font-semibold text-foreground' : 'text-muted-foreground group-hover:text-foreground')}>{s.label}</span>
</button>
);
})}
</div>
{/* Phones: the bar to read, one step back or on as buttons */}
<div className="grid gap-2 sm:hidden">
<div className="flex gap-1" aria-hidden>
{stages.map((s, k) => (
<span key={s.id} className={cn('h-1.5 flex-1 rounded-full transition-colors duration-500', k <= i && !d.lost ? (won ? 'bg-success' : 'bg-primary') : 'bg-track')} />
))}
</div>
<div className="flex items-center justify-between gap-2">
<button type="button" aria-label={t.prevStage} disabled={d.lost || i === 0} onClick={() => go(i - 1)} className={cn(iconBtn, 'border border-border disabled:opacity-40')}>
<Icon name="chevron-left" size={16} />
</button>
<span className="min-w-0 truncate text-center text-[13px] font-medium text-foreground" aria-live="polite">
{d.lost ? t.lostTag : stages[i]?.label}
</span>
<button type="button" aria-label={t.nextStage} disabled={d.lost || won} onClick={() => go(i + 1)} className={cn(iconBtn, 'border border-border disabled:opacity-40')}>
<Icon name="chevron-right" size={16} />
</button>
</div>
</div>
</>
);
}
function Deals({ t, f, title, subtitle, deals = [], stages = [], me, onAction }: P) {
const id = useId();
const [items, setItems] = useState(deals);
const [msg, setMsg] = useState('');
const [form, setForm] = useState({ name: '', value: '', stage: stages[0]?.id ?? '' });
const [tried, setTried] = useState(false);
const [fresh, setFresh] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const last = stages.length - 1;
const isWon = (d: CrmDeal) => !d.lost && stageOf(stages, d.stage) === last;
const open = items.filter((d) => !d.lost && !isWon(d));
const pipeline = open.reduce((s, d) => s + d.value, 0);
const weighted = open.reduce((s, d) => s + (d.value * probOf(d, stages)) / 100, 0);
const wonSum = items.filter(isWon).reduce((s, d) => s + d.value, 0);
const closed = items.filter((d) => d.lost || isWon(d)).length;
const rate = closed ? (items.filter(isWon).length / closed) * 100 : 0;
const update = (d: CrmDeal, patch: Partial<CrmDeal>, text: string) => {
setItems((l) => l.map((x) => (x.id === d.id ? { ...x, ...patch } : x)));
setMsg(text);
void run(onAction, 'update-deal', { id: d.id, ...patch });
};
const num = Number(form.value.replace(/[^\d,.-]/g, '').replace(/[.,](?=\d{3}(\D|$))/g, '').replace(',', '.'));
const errName = tried && !form.name.trim();
const errValue = tried && !(num > 0);
const create = async () => {
setTried(true);
if (!form.name.trim() || !(num > 0)) {
requestAnimationFrame(() => document.getElementById(!form.name.trim() ? `${id}-dn` : `${id}-dv`)?.focus());
return;
}
const d: CrmDeal = { id: `d${Date.now()}`, name: form.name.trim(), value: num, stage: form.stage, owner: me };
await run(onAction, 'create-deal', { deal: d });
setItems((l) => [...l, d]);
setFresh(d.id);
setForm({ name: '', value: '', stage: stages[0]?.id ?? '' });
setTried(false);
setMsg(fill(t.dealCreated, { name: d.name }));
};
const tiles = [
{ label: t.pipeline, value: f.money(pipeline) },
{ label: t.weighted, value: f.money(Math.round(weighted)) },
{ label: t.won, value: f.money(wonSum) },
{ label: t.winRate, value: f.pct(rate) },
];
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid gap-5">
<ul className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border lg:grid-cols-4">
{tiles.map((x, i) => (
<li key={x.label} className="bg-card px-4 py-3.5 sm:px-5 sm:py-4">
<div className="crmc-up" style={{ '--crmc-d': `${i * 70}ms` } as React.CSSProperties}>
<p className="text-[12.5px] text-muted-foreground">{x.label}</p>
<p key={x.value} className="crmc-in mt-1 text-[18px] font-semibold tracking-[-0.02em] tabular-nums text-foreground sm:text-[22px]">
{x.value}
</p>
</div>
</li>
))}
</ul>
{items.length ? (
<ul className="grid gap-3">
{items.map((d, i) => {
const p = probOf(d, stages);
const won = isWon(d);
return (
<li key={d.id} className={cn(cardCls, 'crmc-up p-4 transition-[border-color,box-shadow,opacity] duration-300 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-5', d.lost && 'opacity-70', d.id === fresh && 'crmc-in crmc-flash')} style={{ '--crmc-d': `${120 + i * 70}ms` } as React.CSSProperties}>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1">
<h2 className="flex min-w-0 flex-wrap items-center gap-2 text-[15px] font-semibold text-foreground">
<span className="min-w-0 truncate">{d.name}</span>
{won && <span className="inline-flex h-5 items-center gap-1 rounded bg-[color-mix(in_oklab,var(--success)_14%,transparent)] px-1.5 text-[11px] font-semibold text-success-foreground"><Icon name="trophy" size={11} />{t.wonTag}</span>}
{d.lost && <span className="inline-flex h-5 items-center rounded bg-muted px-1.5 text-[11px] font-semibold text-muted-foreground">{t.lostTag}</span>}
</h2>
<p className="row-span-2 text-right">
<span className="block text-[18px] font-semibold tracking-[-0.02em] tabular-nums text-foreground sm:text-[20px]">{f.money(d.value)}</span>
<span className="block text-[12.5px] tabular-nums text-muted-foreground">
{t.weighted} {f.money(Math.round((d.value * p) / 100))}
</span>
</p>
<p className="text-[12.5px] tabular-nums text-muted-foreground">{[d.owner, d.close ? fill(t.closes, { date: f.date(d.close, { day: 'numeric', month: 'short', year: 'numeric' }) }) : ''].filter(Boolean).join(' · ')}</p>
</div>
<div className="mt-4">
<Ladder d={d} stages={stages} t={t} onChange={(s) => update(d, { stage: s, probability: stages.find((x) => x.id === s)?.probability }, fill(t.stageNow, { name: d.name, stage: stages.find((x) => x.id === s)?.label ?? s }))} />
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3">
<p className="flex min-w-0 flex-1 items-center gap-3 text-[12.5px] text-muted-foreground">
<span className="shrink-0 tabular-nums">{fill(t.chance, { p: f.pct(p) })}</span>
<span className="block h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-track">
<span className={cn('block h-full rounded-full transition-[width] duration-500', won ? 'bg-success' : 'bg-primary')} style={{ width: `${p}%` }} />
</span>
</p>
{!won && (
<Button size="sm" variant="ghost" iconLeft={d.lost ? 'rotate-ccw' : 'x-circle'} onClick={() => update(d, { lost: !d.lost }, fill(d.lost ? t.reopened : t.lostNow, { name: d.name }))}>
{d.lost ? t.reopen : t.markLost}
</Button>
)}
</div>
</li>
);
})}
</ul>
) : (
<p className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noDeals}</p>
)}
<form
noValidate
aria-labelledby={`${id}-new`}
onSubmit={(e) => {
e.preventDefault();
void create();
}}
className={cn(cardCls, 'crmc-up border-dashed p-4 sm:p-5')}
>
<h2 id={`${id}-new`} className="text-[14px] font-semibold text-foreground">
{t.newDeal}
</h2>
<div className="mt-3 grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)_auto] lg:items-start">
<label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
{t.dealName}
<input id={`${id}-dn`} value={form.name} aria-invalid={errName} aria-describedby={errName ? `${id}-en` : undefined} onChange={(e) => setForm({ ...form, name: e.target.value })} className={field} />
{errName && <span id={`${id}-en`} className="text-[12.5px] font-normal text-destructive">{t.nameNeeded}</span>}
</label>
<label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
{t.dealValue}
<input id={`${id}-dv`} inputMode="decimal" value={form.value} aria-invalid={errValue} aria-describedby={errValue ? `${id}-ev` : undefined} onChange={(e) => setForm({ ...form, value: e.target.value })} onBlur={() => num > 0 && setForm((x) => ({ ...x, value: f.num(num) }))} className={cn(field, 'tabular-nums')} />
{errValue && <span id={`${id}-ev`} className="text-[12.5px] font-normal text-destructive">{t.valueNeeded}</span>}
</label>
<label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
{t.dealStage}
<span className="relative block">
<select value={form.stage} onChange={(e) => setForm({ ...form, stage: e.target.value })} className={cn(field, 'cursor-pointer appearance-none pe-8')}>
{stages.slice(0, -1).map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</label>
<Button type="submit" size="sm" iconLeft="plus" className="sm:col-span-2 lg:col-span-1 lg:mt-[1.4rem]">
{t.createDeal}
</Button>
</div>
</form>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* notes */
/* ------------------------------------------------------------------ */
/** Textarea with @ mentions after the APG combobox pattern */
function MentionBox({ id, value, onChange, people, label, t, rows = 3, onSubmit, onEscape, autoFocus }: { id: string; value: string; onChange: (v: string) => void; people: string[]; label: string; t: Texts; rows?: number; onSubmit?: () => void; onEscape?: () => void; autoFocus?: boolean }) {
const ref = useRef<HTMLTextAreaElement>(null);
const [query, setQuery] = useState<string | null>(null);
const [active, setActive] = useState(0);
const list = query === null ? [] : people.filter((p) => p.toLowerCase().includes(query.toLowerCase())).slice(0, 5);
const open = list.length > 0;
useEffect(() => {
if (autoFocus) ref.current?.focus();
}, [autoFocus]);
const sense = (el: HTMLTextAreaElement) => {
const before = el.value.slice(0, el.selectionStart ?? el.value.length);
const m = /(?:^|\s)@([\p{L}]*(?: [\p{L}]*)?)$/u.exec(before);
setQuery(m ? m[1] : null);
setActive(0);
};
const pick = (name: string) => {
const el = ref.current;
if (!el) return;
const at = el.selectionStart ?? value.length;
const before = value.slice(0, at).replace(/@([\p{L}]*(?: [\p{L}]*)?)$/u, `@${name} `);
const next = before + value.slice(at);
onChange(next);
setQuery(null);
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(before.length, before.length);
});
};
return (
<div className="relative">
<label htmlFor={id} className="sr-only">
{label}
</label>
<textarea
ref={ref}
id={id}
rows={rows}
value={value}
placeholder={label}
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={`${id}-list`}
aria-activedescendant={open ? `${id}-o${active}` : undefined}
onChange={(e) => {
onChange(e.target.value);
sense(e.target);
}}
onClick={(e) => sense(e.currentTarget)}
onBlur={() => setTimeout(() => setQuery(null), 120)}
onKeyDown={(e) => {
if (open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault();
setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : -1) + list.length) % list.length);
return;
}
if (open && (e.key === 'Enter' || e.key === 'Tab')) {
e.preventDefault();
pick(list[active]);
return;
}
if (e.key === 'Escape') {
if (open) {
e.preventDefault();
setQuery(null);
} else onEscape?.();
return;
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onSubmit?.();
}
}}
className={area}
/>
<ul id={`${id}-list`} role="listbox" aria-label={t.suggestions} className={cn('absolute inset-x-0 top-full z-20 mt-1.5 max-w-xs overflow-hidden rounded-lg border border-border bg-card p-1 shadow-[var(--shadow-lg)]', open ? 'crmc-in' : 'invisible hidden')}>
{list.map((p, i) => (
<li
key={p}
id={`${id}-o${i}`}
role="option"
aria-selected={i === active}
onMouseDown={(e) => {
e.preventDefault();
pick(p);
}}
onMouseEnter={() => setActive(i)}
className={cn('flex min-h-11 cursor-pointer items-center gap-2.5 rounded-md px-2 text-[13.5px] text-foreground sm:min-h-9', i === active && 'bg-accent')}
>
<Avatar name={p} size="xs" />
{p}
</li>
))}
</ul>
</div>
);
}
function Notes({ t, f, title, subtitle, notes = [], people = [], me, now, onAction }: P) {
const id = useId();
const [items, setItems] = useState(notes);
const [draft, setDraft] = useState('');
const [editing, setEditing] = useState<string | null>(null);
const [editText, setEditText] = useState('');
const [who, setWho] = useState<string>('all');
const [q, setQ] = useState('');
const [msg, setMsg] = useState('');
const [removed, setRemoved] = useState<{ note: CrmNote; at: number } | null>(null);
const [fresh, setFresh] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const mentionsOf = (n: CrmNote) => people.filter((p) => n.body.includes(`@${p}`));
const shown = items.filter((n) => (who === 'all' || mentionsOf(n).includes(who)) && (!q || n.body.toLowerCase().includes(q.toLowerCase()) || n.author.toLowerCase().includes(q.toLowerCase())));
const pinned = shown.filter((n) => n.pinned).sort((a, b) => toMs(b.time) - toMs(a.time));
const rest = shown.filter((n) => !n.pinned).sort((a, b) => toMs(b.time) - toMs(a.time));
const counts = people.map((p) => ({ p, n: items.filter((x) => mentionsOf(x).includes(p)).length })).filter((x) => x.n > 0);
const save = async () => {
const body = draft.trim();
if (!body) return;
const n: CrmNote = { id: `n${Date.now()}`, body, author: me, time: now };
await run(onAction, 'add-note', { note: n }, 300);
setItems((l) => [n, ...l]);
setFresh(n.id);
setDraft('');
setMsg(t.noteSaved);
};
const toggle = (n: CrmNote) => {
setItems((l) => l.map((x) => (x.id === n.id ? { ...x, pinned: !x.pinned } : x)));
setFresh(n.id);
setMsg(n.pinned ? t.unpin : t.pin);
void run(onAction, 'pin-note', { id: n.id, pinned: !n.pinned });
};
const remove = (n: CrmNote) => {
setRemoved({ note: n, at: items.indexOf(n) });
setItems((l) => l.filter((x) => x.id !== n.id));
setMsg(t.deleted);
void run(onAction, 'delete-note', { id: n.id });
requestAnimationFrame(() => document.getElementById(`${id}-undo`)?.focus());
};
const commit = (n: CrmNote) => {
const body = editText.trim();
if (!body) return;
setItems((l) => l.map((x) => (x.id === n.id ? { ...x, body } : x)));
setEditing(null);
setFresh(n.id);
setMsg(t.edited);
void run(onAction, 'edit-note', { id: n.id, body });
requestAnimationFrame(() => document.getElementById(`${id}-e-${n.id}`)?.focus());
};
const card = (n: CrmNote) => (
<li key={n.id} className={cn('group rounded-xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,translate,box-shadow] duration-300 hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-5', n.pinned ? 'border-[color-mix(in_oklab,var(--primary)_35%,var(--border))]' : 'border-border hover:border-border-strong', n.id === fresh && 'crmc-in')}>
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-3">
<Avatar name={n.author} size="sm" />
<p className="flex min-w-0 flex-wrap items-baseline gap-x-2 pt-1 text-[13px] max-sm:flex-col">
<span className="font-medium text-foreground">{n.author}</span>
<time dateTime={n.time} className="tabular-nums text-subtle-foreground">
{f.date(n.time, { day: 'numeric', month: 'short' })}, {f.time(n.time)}
</time>
</p>
{editing !== n.id ? (
<div role="group" aria-label={t.notes} className="-me-2 -mt-2 flex shrink-0 opacity-100 transition-opacity duration-200 sm:opacity-60 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100">
<button type="button" aria-label={n.pinned ? t.unpin : t.pin} aria-pressed={!!n.pinned} onClick={() => toggle(n)} className={cn(iconBtn, n.pinned && 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
<Icon name="pin" size={15} />
</button>
<button type="button" id={`${id}-e-${n.id}`} aria-label={t.edit} onClick={() => (setEditing(n.id), setEditText(n.body))} className={iconBtn}>
<Icon name="edit" size={15} />
</button>
<button type="button" aria-label={t.del} onClick={() => remove(n)} className={cn(iconBtn, 'hover:text-destructive')}>
<Icon name="trash" size={15} />
</button>
</div>
) : (
<span />
)}
<div className="col-span-3 min-w-0 sm:col-span-2 sm:col-start-2">
{editing === n.id ? (
<div className="mt-3 grid gap-2 sm:mt-1.5">
<MentionBox id={`${id}-edit-${n.id}`} value={editText} onChange={setEditText} people={people} label={t.edit} t={t} onSubmit={() => commit(n)} onEscape={() => setEditing(null)} autoFocus />
<div className="flex flex-wrap gap-2">
<Button size="sm" iconLeft="check" disabled={!editText.trim()} onClick={() => commit(n)}>
{t.save}
</Button>
<Button size="sm" variant="ghost" onClick={() => (setEditing(null), requestAnimationFrame(() => document.getElementById(`${id}-e-${n.id}`)?.focus()))}>
{t.cancel}
</Button>
</div>
</div>
) : (
<p className="mt-3 whitespace-pre-line text-pretty text-[14px] leading-relaxed text-foreground sm:mt-1">
<Mentioned text={n.body} people={people} />
</p>
)}
</div>
</div>
</li>
);
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
<div className="grid min-w-0 gap-5">
<form
onSubmit={(e) => {
e.preventDefault();
void save();
}}
className={cn(cardCls, 'crmc-up p-4 sm:p-5')}
>
<MentionBox id={`${id}-new`} value={draft} onChange={setDraft} people={people} label={t.writeNote} t={t} onSubmit={() => void save()} />
<div className="mt-3 flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[12.5px] text-subtle-foreground max-sm:hidden">{t.shortcut}</p>
<Button type="submit" size="sm" iconLeft="check" disabled={!draft.trim()} className="max-sm:w-full">
{t.saveNote}
</Button>
</div>
</form>
{removed && (
<p className="crmc-in flex items-center justify-between gap-3 rounded-lg border border-border bg-surface px-4 py-2 text-[13.5px] text-foreground">
{t.deleted}
<Button
id={`${id}-undo`}
size="sm"
variant="ghost"
iconLeft="undo"
onClick={() => {
setItems((l) => [...l.slice(0, removed.at), removed.note, ...l.slice(removed.at)]);
setFresh(removed.note.id);
setRemoved(null);
setMsg(t.restored);
}}
>
{t.undo}
</Button>
</p>
)}
{/* Phones and tablets: the mention filter as chips above the notes */}
<label className="relative block lg:hidden">
<span className="sr-only">{t.searchNotes}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.searchNotes} className={cn(field, 'ps-9')} />
</label>
{counts.length > 0 && <Chips className="-mt-2 lg:hidden" label={t.mentions} value={who} onChange={setWho} options={[{ value: 'all', label: t.allNotes, count: items.length }, ...counts.map((x) => ({ value: x.p, label: `@${x.p}`, count: x.n }))]} />}
<div key={who + q} className="grid gap-5">
{pinned.length > 0 && (
<section aria-labelledby={`${id}-p`} className="crmc-up">
<h2 id={`${id}-p`} className="mb-2.5 flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">
<Icon name="pin" size={13} />
{t.pinned}
</h2>
<ul className="grid gap-3">{pinned.map(card)}</ul>
</section>
)}
{rest.length > 0 && (
<section aria-labelledby={`${id}-r`} className="crmc-up [--crmc-d:100ms]">
<h2 id={`${id}-r`} className="mb-2.5 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">
{pinned.length ? t.others : t.notes}
</h2>
<ul className="grid gap-3">{rest.map(card)}</ul>
</section>
)}
{!shown.length && <p className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noNotes}</p>}
</div>
</div>
{/* Desktop: search and the mention filter beside the notes */}
<aside className="hidden gap-5 lg:sticky lg:top-6 lg:grid">
<label className="crmc-up relative block">
<span className="sr-only">{t.searchNotes}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.searchNotes} className={cn(field, 'ps-9')} />
</label>
{counts.length > 0 && (
<section className={cn(cardCls, 'crmc-up p-2 [--crmc-d:100ms]')}>
<h2 className="px-3 pb-1 pt-2 text-[12px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{t.mentions}</h2>
<ul className="grid">
{[{ p: 'all', n: items.length }, ...counts].map((x) => (
<li key={x.p}>
<button type="button" aria-pressed={who === x.p} aria-label={x.p === 'all' ? t.allNotes : fill(t.mentionsOf, { name: x.p })} onClick={() => setWho(x.p)} className={cn('flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-md px-3 text-left text-[13.5px] transition-colors', focusRing, who === x.p ? 'bg-accent font-medium text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
{x.p === 'all' ? <Icon name="list" size={15} className="mx-[4px]" /> : <Avatar name={x.p} size="xs" />}
<span className="min-w-0 flex-1 truncate">{x.p === 'all' ? t.allNotes : x.p}</span>
<span className="text-[12px] tabular-nums text-subtle-foreground">{x.n}</span>
</button>
</li>
))}
</ul>
</section>
)}
</aside>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* list-detail */
/* ------------------------------------------------------------------ */
function ListDetail({ t, f, title, subtitle, contacts = [], activities = [], deals = [], stages = [], now, onAction }: P) {
const id = useId();
const [q, setQ] = useState('');
const [seg, setSeg] = useState<'all' | 'customer' | 'lead'>('all');
const [sel, setSel] = useState(contacts[0]?.id ?? '');
// Phones show the list or the detail, never both
const [phoneDetail, setPhoneDetail] = useState(false);
const [msg, setMsg] = useState('');
const listRef = useRef<HTMLUListElement>(null);
const [scope, enter] = useEnter<HTMLDivElement>();
const shown = contacts.filter((c) => (seg === 'all' || c.status === seg) && (!q || [c.name, c.company, c.location, c.role].filter(Boolean).join(' ').toLowerCase().includes(q.toLowerCase())));
const c = contacts.find((x) => x.id === sel);
const choose = (cid: string, open = false) => {
setSel(cid);
if (open) {
setPhoneDetail(true);
requestAnimationFrame(() => document.getElementById(`${id}-head`)?.focus());
}
};
const onKey = (e: React.KeyboardEvent) => {
const i = shown.findIndex((x) => x.id === sel);
let n = -1;
if (e.key === 'ArrowDown') n = Math.min(shown.length - 1, i + 1);
if (e.key === 'ArrowUp') n = Math.max(0, i - 1);
if (e.key === 'Home') n = 0;
if (e.key === 'End') n = shown.length - 1;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (sel) choose(sel, true);
return;
}
if (n < 0 || !shown[n]) return;
e.preventDefault();
setSel(shown[n].id);
listRef.current?.querySelector<HTMLElement>(`[data-id="${shown[n].id}"]`)?.scrollIntoView({ block: 'nearest' });
};
const acts = c ? activities.filter((a) => a.contactId === c.id).sort((a, b) => toMs(b.time) - toMs(a.time)).slice(0, 3) : [];
const own = c ? deals.filter((d) => d.contactId === c.id && !d.lost && stageOf(stages, d.stage) < stages.length - 1) : [];
return (
<Main className="lg:p-6">
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className={cn(cardCls, 'crmc-up grid min-h-[38rem] grid-cols-[minmax(0,1fr)] overflow-hidden sm:grid-cols-[17rem_minmax(0,1fr)] lg:grid-cols-[21rem_minmax(0,1fr)]')}>
{/* The list */}
<section aria-label={t.contacts} className={cn('min-w-0 flex-col border-border sm:flex sm:border-e', phoneDetail ? 'hidden' : 'flex')}>
<div className="grid gap-3 border-b border-border p-3 sm:p-4">
<label className="relative block">
<span className="sr-only">{t.search}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className={cn(field, 'ps-9')} />
</label>
<Chips
label={t.filter}
value={seg}
onChange={setSeg}
options={[
{ value: 'all', label: t.all, count: contacts.length },
{ value: 'customer', label: t.customer, count: contacts.filter((x) => x.status === 'customer').length },
{ value: 'lead', label: t.lead, count: contacts.filter((x) => x.status === 'lead').length },
]}
className="-mx-3 px-3 sm:-mx-4 sm:flex-nowrap sm:overflow-x-auto sm:px-4"
/>
</div>
<p className="sr-only" aria-live="polite">
{fill(t.count, { n: shown.length })}
</p>
{shown.length ? (
<ul ref={listRef} role="listbox" aria-label={t.contacts} aria-activedescendant={sel ? `${id}-o-${sel}` : undefined} tabIndex={0} onKeyDown={onKey} className="relative min-h-0 flex-1 overflow-y-auto p-1.5 outline-none focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_50%,transparent)] sm:max-h-[34rem]">
{shown.map((x, i) => {
const on = x.id === sel;
return (
<li key={x.id} id={`${id}-o-${x.id}`} data-id={x.id} role="option" aria-selected={on} onClick={() => choose(x.id, true)} className={cn('crmc-fly group flex min-h-14 cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 transition-colors', on ? 'bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))]' : 'hover:bg-accent')} style={{ '--crmc-d': `${i * 40}ms` } as React.CSSProperties}>
<Avatar name={x.name} size="md" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className={cn('truncate text-[14px] text-foreground', on ? 'font-semibold' : 'font-medium')}>{x.name}</span>
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', x.status === 'customer' ? 'bg-success' : x.status === 'lead' ? 'bg-primary' : 'bg-subtle-foreground')} />
</span>
<span className="block truncate text-[12.5px] text-muted-foreground">{x.company}</span>
</span>
<span className="shrink-0 text-[12px] tabular-nums text-subtle-foreground">{x.lastContact ? f.ago(x.lastContact) : ''}</span>
<Icon name="chevron-right" size={15} className="shrink-0 text-subtle-foreground transition-transform group-hover:translate-x-0.5 sm:hidden" />
</li>
);
})}
</ul>
) : (
<p className="px-6 py-12 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</p>
)}
</section>
{/* The detail */}
<section aria-labelledby={`${id}-head`} className={cn('min-w-0 flex-col bg-surface sm:flex', phoneDetail ? 'flex' : 'hidden')}>
{c ? (
<div key={c.id} className="crmc-in flex flex-col gap-5 p-4 sm:p-6">
<button type="button" onClick={() => (setPhoneDetail(false), requestAnimationFrame(() => listRef.current?.focus()))} className={cn('-ms-2 inline-flex h-11 cursor-pointer items-center gap-1.5 self-start rounded-md px-2 text-[13.5px] font-medium text-muted-foreground hover:text-foreground sm:hidden', focusRing)}>
<Icon name="arrow-left" size={16} />
{t.back}
</button>
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div id={`${id}-head`} tabIndex={-1} className="rounded-md outline-none">
<PersonHead c={c} t={t} size="lg" />
</div>
<QuickActions
c={c}
t={t}
className="xl:w-[22rem]"
onMeeting={async () => {
await run(onAction, 'schedule', { id: c.id });
setMsg(fill(t.scheduled, { name: c.name }));
}}
/>
</div>
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 xl:grid-cols-2">
<section className={cn(cardCls, 'px-4 py-1.5')}>
<h3 className="sr-only">{t.details}</h3>
<Fields rows={detailRows(c, t, f)} />
</section>
<div className="grid content-start gap-5">
<section className={cn(cardCls, 'p-4')}>
<h3 className="flex items-center justify-between text-[13px] font-semibold text-foreground">
{t.openDeals}
<span className="tabular-nums text-muted-foreground">{f.money(own.reduce((s, d) => s + d.value, 0))}</span>
</h3>
{own.length ? (
<ul className="mt-3 grid gap-2">
{own.map((d) => (
<DealRow key={d.id} d={d} stages={stages} t={t} f={f} />
))}
</ul>
) : (
<p className="mt-2 text-[13px] text-muted-foreground">{t.noDeals}</p>
)}
</section>
</div>
</div>
<section className={cn(cardCls, 'p-4')}>
<h3 className="text-[13px] font-semibold text-foreground">{t.recent}</h3>
<div className="mt-2">
<ActivityList items={acts} t={t} f={f} now={now} compact />
</div>
</section>
</div>
) : (
<p className="m-auto px-6 py-16 text-center text-[13.5px] text-muted-foreground">{t.noContact}</p>
)}
</section>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* CrmContact */
/* ------------------------------------------------------------------ */
/**
* A contact in a CRM: the profile with tabs, the history with filters and
* new entries, the deals with their stage as a ladder, notes with mentions,
* and a contact list with the detail next to it.
* <CrmContact variant="profile" contact={…} activities={…} deals={…} stages={…} notes={…} />
*/
export function CrmContact({ variant = 'profile', sidebar, labels, className, now = '2026-10-23T10:30', currency = 'EUR', me = 'Lena Hoffmann', ...props }: CrmContactProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const f = useMemo(() => makeFmt(locale, currency, now), [locale, currency, now]);
const p: P = { ...props, t, now, locale, f, me, currency };
const body = (() => {
switch (variant) {
case 'timeline':
return <Timeline {...p} />;
case 'deals':
return <Deals {...p} />;
case 'notes':
return <Notes {...p} />;
case 'list-detail':
return <ListDetail {...p} />;
default:
return <Profile {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}Frequent questions
How do I install CRM contact?
With aiw add @ai-website/crm-contact. The command puts components/ui/crm-contact.tsx into your project, the code is yours from then on. It brings along avatar, button, icon, motion and sidebar. It needs no further npm packages.
Which variants does CRM contact have?
5 variants, Contact profile, Contact history, Contact deals, Contact notes and Contact list with detail. You pick one with the variant prop, for example variant="timeline".
Can I translate the texts of CRM contact?
Yes. CRM contact carries 107 texts of its own in English and German, such as “Email”, “Call” and “Meeting”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with CRM contact?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find CRM contact with aiw_components and install it with aiw_add @ai-website/crm-contact. Name the variant in your prompt, for example variant="profile", and the assistant writes your content straight into the props.