Skip to content
UI Kit
Accent color

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.

5 variantsOne of 2 components in CRM and invoicing

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

Contact profileNew in 1.2

Avatar, company, mail, call and meeting as real actions, the key fields, three numbers and tabs for history, deals and notes.

Contact historyNew in 1.2

Mails, calls, meetings and notes by day on one line, filters with counts, a composer for new entries and the contact in numbers.

Contact dealsNew in 1.2

Every deal with value, chance and close date, the stage as a ladder to change, live sums for pipeline, weighted and won, and a new deal form.

Contact notesNew in 1.2

Pinned notes first, write with @ mentions from a list, edit in place, delete with undo and filter by who is mentioned.

Contact list with detailNew in 1.2

Search and filter the list, arrow keys move through it, the detail shows fields, open deals and recent activity. Phones switch between both.

Details

What CRM contact brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/crm-contact.tsx
Brings along
utilskitavatarbuttoniconmotionsidebar
npm packages
None
Languages
English and German, 107 texts of its own
Studio
Adjust in code
In collections
SaaS app screens

Props

The CrmContactProps interface from components/ui/crm-contact.tsx, with the comments from the code.

  • variantoptional
    CrmContactVariant

    profiletimelinedealsnoteslist-detail

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titleoptional
    string
  • subtitleoptional
    string
  • nowoptional
    string

    ISO date and time that counts as now

  • currencyoptional
    string

    ISO currency code, e.g. EUR or USD

  • contactoptional
    CrmPerson

    The person on screen (all variants but list-detail)

  • contactsoptional
    CrmPerson[]

    list-detail: everybody in the list

  • activitiesoptional
    CrmActivity[]
  • dealsoptional
    CrmDeal[]
  • stagesoptional
    CrmStage[]

    The pipeline in order. The last stage counts as won.

  • notesoptional
    CrmNote[]
  • meoptional
    string

    Name of the person using the app, author of new entries

  • peopleoptional
    string[]

    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
emailEnglish EmailGerman E-Mail
callEnglish CallGerman Anruf
meetingEnglish MeetingGerman Termin
noteEnglish NoteGerman Notiz
emailsEnglish EmailsGerman E-Mails
callsEnglish CallsGerman Anrufe
meetingsEnglish MeetingsGerman Termine
notesTabEnglish NotesGerman Notizen
allEnglish AllGerman Alle
historyEnglish HistoryGerman Verlauf
dealsEnglish DealsGerman Deals
detailsEnglish DetailsGerman Details
showDetailsEnglish Show detailsGerman Details zeigen
hideDetailsEnglish Hide detailsGerman Details ausblenden
sendEmailEnglish Write emailGerman E-Mail schreiben
callNowEnglish CallGerman Anrufen
scheduleEnglish Book meetingGerman Termin buchen
scheduledEnglish Meeting request sent to {name}.German Terminanfrage an {name} gesendet.
leadEnglish LeadGerman Lead
customerEnglish CustomerGerman Kunde
inactiveEnglish InactiveGerman Inaktiv
ownerEnglish OwnerGerman Betreut von
locationEnglish LocationGerman Ort
phoneEnglish PhoneGerman Telefon
mailEnglish EmailGerman E-Mail
revenueEnglish Revenue so farGerman Umsatz bisher
openPipelineEnglish Open dealsGerman Offene Deals
lastContactEnglish Last contactGerman Letzter Kontakt
nextMeetingEnglish Next meetingGerman Nächster Termin
noneEnglish None plannedGerman Noch keiner geplant
contactSheetEnglish ContactGerman Kontakt
tabsEnglish Contact sectionsGerman Bereiche des Kontakts
composerEnglish New entryGerman Neuer Eintrag
entryTypeEnglish Type of entryGerman Art des Eintrags
placeholderNoteEnglish What did you agree on?German Was habt ihr vereinbart?
placeholderCallEnglish What did you talk about?German Worüber habt ihr gesprochen?
placeholderEmailEnglish Subject and short summary of the emailGerman Betreff und kurze Zusammenfassung der E-Mail
placeholderMeetingEnglish Topic and outcome of the meetingGerman Thema und Ergebnis des Termins
addEnglish Add entryGerman Eintrag hinzufügen
addingEnglish Adding…German Wird hinzugefügt…
addedEnglish Entry added to the history.German Eintrag im Verlauf gespeichert.
shortcutEnglish Ctrl and Enter adds the entryGerman Strg und Enter fügt den Eintrag hinzu
filterEnglish ShowGerman Zeigen
todayEnglish TodayGerman Heute
yesterdayEnglish YesterdayGerman Gestern
minutesEnglish {n} minGerman {n} Min.
moreEnglish Read moreGerman Weiterlesen
lessEnglish Show lessGerman Weniger zeigen
emptyEnglish Nothing here yet. Your first entry starts the history.German Hier steht noch nichts. Dein erster Eintrag beginnt den Verlauf.
inNumbersEnglish Contact in numbersGerman Kontakt in Zahlen
pipelineEnglish PipelineGerman Pipeline
weightedEnglish WeightedGerman Gewichtet
wonEnglish WonGerman Gewonnen
winRateEnglish Win rateGerman Abschlussquote
stageEnglish Stage of {name}German Phase von {name}
stageNowEnglish {name} is now in {stage}.German {name} ist jetzt in {stage}.
nextStageEnglish Next stageGerman Nächste Phase
prevStageEnglish Previous stageGerman Vorige Phase
chanceEnglish Chance {p}German Chance {p}
closesEnglish Closes {date}German Abschluss {date}
markLostEnglish Mark as lostGerman Als verloren markieren
reopenEnglish Reopen dealGerman Deal wieder öffnen
lostNowEnglish {name} is marked as lost.German {name} ist als verloren markiert.
reopenedEnglish {name} is open again.German {name} ist wieder offen.
lostTagEnglish LostGerman Verloren
wonTagEnglish WonGerman Gewonnen
newDealEnglish New dealGerman Neuer Deal
dealNameEnglish Deal nameGerman Name des Deals
dealValueEnglish ValueGerman Wert
dealStageEnglish StageGerman Phase
createDealEnglish Create dealGerman Deal anlegen
dealCreatedEnglish Deal {name} created.German Deal {name} angelegt.
nameNeededEnglish Give the deal a name.German Gib dem Deal einen Namen.
valueNeededEnglish Enter a value above zero.German Trag einen Wert über null ein.
noDealsEnglish No deals yet. Create the first one below.German Noch keine Deals. Leg unten den ersten an.
notesEnglish NotesGerman Notizen
pinnedEnglish PinnedGerman Angeheftet
othersEnglish More notesGerman Weitere Notizen
writeNoteEnglish Write a note, type @ to mention someoneGerman Notiz schreiben, mit @ jemanden erwähnen
saveNoteEnglish Save noteGerman Notiz speichern
noteSavedEnglish Note saved.German Notiz gespeichert.
pinEnglish Pin noteGerman Notiz anheften
unpinEnglish Unpin noteGerman Notiz lösen
editEnglish Edit noteGerman Notiz bearbeiten
delEnglish Delete noteGerman Notiz löschen
deletedEnglish Note deleted.German Notiz gelöscht.
undoEnglish UndoGerman Rückgängig
restoredEnglish Note restored.German Notiz wiederhergestellt.
saveEnglish Save changesGerman Änderungen speichern
cancelEnglish CancelGerman Abbrechen
editedEnglish Note updated.German Notiz aktualisiert.
mentionsEnglish MentionsGerman Erwähnungen
mentionsOfEnglish Notes mentioning {name}German Notizen, die {name} erwähnen
allNotesEnglish All notesGerman Alle Notizen
suggestionsEnglish People to mentionGerman Personen zum Erwähnen
noNotesEnglish No note matches. Write the first one above.German Keine Notiz passt. Schreib oben die erste.
searchNotesEnglish Search notesGerman Notizen durchsuchen
contactsEnglish ContactsGerman Kontakte
searchEnglish Search contactsGerman Kontakte suchen
noMatchEnglish No contact matches. Try a company or a city.German Kein Kontakt passt. Versuch es mit Firma oder Ort.
backEnglish Back to contactsGerman Zurück zu den Kontakten
recentEnglish Recent activityGerman Letzte Aktivität
openDealsEnglish Open dealsGerman Offene Deals
countEnglish {n} contactsGerman {n} Kontakte
nDealsEnglish {n} openGerman {n} offen
agoEnglish Last contact {when}German Letzter Kontakt {when}
noContactEnglish 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
components/ui/crm-contact.tsx
'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.

Often searched

Jump to

Copied to the clipboard