CRM Kontaktansicht
Ein CRM-Kontakt in fünf Screens. Profil mit Reitern, Verlauf mit Filter und neuen Einträgen, Deals als Stufenleiter, Notizen mit Erwähnungen und Liste mit Detail.
Ein Kunde, alles in Reichweite
Die Kontaktansicht ist der meistgenutzte Teil eines CRM, direkt vor einem Anruf oder nach einem Termin. Diese fünf Screens legen Person, bisheriges Gespräch, offene Deals und Notizen nur einen Reiter auseinander, Mail, Anruf und Termin sind echte Aktionen.
Das „Kontaktprofil“ zeigt Avatar, Firma, die wichtigsten Felder, drei Kennzahlen und Reiter für Verlauf, Deals und Notizen. Der „Kontaktverlauf“ legt Mails, Anrufe, Termine und Notizen nach Tagen auf eine Linie, mit Filtern und Zahlen, einem Feld für neue Einträge und dem Kontakt in Zahlen. „Deals des Kontakts“ führt jeden Deal mit Wert, Chance und Abschluss, die Phase als Leiter zum Ändern, Summen für Pipeline, gewichtet und gewonnen und ein Formular für neue Deals. „Kontaktnotizen“ setzt angeheftete Notizen nach vorn, schreibt mit @-Erwähnungen, bearbeitet direkt und löscht mit Rückgängig. Die „Kontaktliste mit Detail“ lässt die Pfeiltasten durch eine gefilterte Liste wandern, das Detail zeigt Felder, offene Deals und letzte Aktivität.
Stell den nächsten Schritt für diesen Kontakt nach vorn, dann den Verlauf. Beschränk die wichtigsten Felder auf die, die dein Team wirklich pflegt, fünf gute wirken mehr als zwanzig leere. Eine gewichtete Pipeline braucht ehrliche Chancen, lass die Chance deshalb direkt neben der Phase setzen.
Variants
Details
Was CRM-Kontakt mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- CRM und Rechnungen
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/crm-contact.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 107 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- SaaS-App-Screens
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CrmContactProps aus components/ui/crm-contact.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalCrmContactVariantprofiletimelinedealsnoteslist-detail
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titleoptionalstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
currencyoptionalstringISO currency code, e.g. EUR or USD
contactoptionalCrmPersonThe person on screen (all variants but list-detail)
contactsoptionalCrmPerson[]list-detail: everybody in the list
activitiesoptionalCrmActivity[]dealsoptionalCrmDeal[]stagesoptionalCrmStage[]The pipeline in order. The last stage counts as won.
notesoptionalCrmNote[]meoptionalstringName of the person using the app, author of new entries
peopleoptionalstring[]People you can mention in notes with @
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on add, edit, stage change, pin and delete. Without it the block simulates the request.
Eingebaute Texte
Diese Texte schreibt CRM-Kontakt selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 107 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| Englisch Email | Deutsch E-Mail | |
| call | Englisch Call | Deutsch Anruf |
| meeting | Englisch Meeting | Deutsch Termin |
| note | Englisch Note | Deutsch Notiz |
| emails | Englisch Emails | Deutsch E-Mails |
| calls | Englisch Calls | Deutsch Anrufe |
| meetings | Englisch Meetings | Deutsch Termine |
| notesTab | Englisch Notes | Deutsch Notizen |
| all | Englisch All | Deutsch Alle |
| history | Englisch History | Deutsch Verlauf |
| deals | Englisch Deals | Deutsch Deals |
| details | Englisch Details | Deutsch Details |
| showDetails | Englisch Show details | Deutsch Details zeigen |
| hideDetails | Englisch Hide details | Deutsch Details ausblenden |
| sendEmail | Englisch Write email | Deutsch E-Mail schreiben |
| callNow | Englisch Call | Deutsch Anrufen |
| schedule | Englisch Book meeting | Deutsch Termin buchen |
| scheduled | Englisch Meeting request sent to {name}. | Deutsch Terminanfrage an {name} gesendet. |
| lead | Englisch Lead | Deutsch Lead |
| customer | Englisch Customer | Deutsch Kunde |
| inactive | Englisch Inactive | Deutsch Inaktiv |
| owner | Englisch Owner | Deutsch Betreut von |
| location | Englisch Location | Deutsch Ort |
| phone | Englisch Phone | Deutsch Telefon |
| Englisch Email | Deutsch E-Mail | |
| revenue | Englisch Revenue so far | Deutsch Umsatz bisher |
| openPipeline | Englisch Open deals | Deutsch Offene Deals |
| lastContact | Englisch Last contact | Deutsch Letzter Kontakt |
| nextMeeting | Englisch Next meeting | Deutsch Nächster Termin |
| none | Englisch None planned | Deutsch Noch keiner geplant |
| contactSheet | Englisch Contact | Deutsch Kontakt |
| tabs | Englisch Contact sections | Deutsch Bereiche des Kontakts |
| composer | Englisch New entry | Deutsch Neuer Eintrag |
| entryType | Englisch Type of entry | Deutsch Art des Eintrags |
| placeholderNote | Englisch What did you agree on? | Deutsch Was habt ihr vereinbart? |
| placeholderCall | Englisch What did you talk about? | Deutsch Worüber habt ihr gesprochen? |
| placeholderEmail | Englisch Subject and short summary of the email | Deutsch Betreff und kurze Zusammenfassung der E-Mail |
| placeholderMeeting | Englisch Topic and outcome of the meeting | Deutsch Thema und Ergebnis des Termins |
| add | Englisch Add entry | Deutsch Eintrag hinzufügen |
| adding | Englisch Adding… | Deutsch Wird hinzugefügt… |
| added | Englisch Entry added to the history. | Deutsch Eintrag im Verlauf gespeichert. |
| shortcut | Englisch Ctrl and Enter adds the entry | Deutsch Strg und Enter fügt den Eintrag hinzu |
| filter | Englisch Show | Deutsch Zeigen |
| today | Englisch Today | Deutsch Heute |
| yesterday | Englisch Yesterday | Deutsch Gestern |
| minutes | Englisch {n} min | Deutsch {n} Min. |
| more | Englisch Read more | Deutsch Weiterlesen |
| less | Englisch Show less | Deutsch Weniger zeigen |
| empty | Englisch Nothing here yet. Your first entry starts the history. | Deutsch Hier steht noch nichts. Dein erster Eintrag beginnt den Verlauf. |
| inNumbers | Englisch Contact in numbers | Deutsch Kontakt in Zahlen |
| pipeline | Englisch Pipeline | Deutsch Pipeline |
| weighted | Englisch Weighted | Deutsch Gewichtet |
| won | Englisch Won | Deutsch Gewonnen |
| winRate | Englisch Win rate | Deutsch Abschlussquote |
| stage | Englisch Stage of {name} | Deutsch Phase von {name} |
| stageNow | Englisch {name} is now in {stage}. | Deutsch {name} ist jetzt in {stage}. |
| nextStage | Englisch Next stage | Deutsch Nächste Phase |
| prevStage | Englisch Previous stage | Deutsch Vorige Phase |
| chance | Englisch Chance {p} | Deutsch Chance {p} |
| closes | Englisch Closes {date} | Deutsch Abschluss {date} |
| markLost | Englisch Mark as lost | Deutsch Als verloren markieren |
| reopen | Englisch Reopen deal | Deutsch Deal wieder öffnen |
| lostNow | Englisch {name} is marked as lost. | Deutsch {name} ist als verloren markiert. |
| reopened | Englisch {name} is open again. | Deutsch {name} ist wieder offen. |
| lostTag | Englisch Lost | Deutsch Verloren |
| wonTag | Englisch Won | Deutsch Gewonnen |
| newDeal | Englisch New deal | Deutsch Neuer Deal |
| dealName | Englisch Deal name | Deutsch Name des Deals |
| dealValue | Englisch Value | Deutsch Wert |
| dealStage | Englisch Stage | Deutsch Phase |
| createDeal | Englisch Create deal | Deutsch Deal anlegen |
| dealCreated | Englisch Deal {name} created. | Deutsch Deal {name} angelegt. |
| nameNeeded | Englisch Give the deal a name. | Deutsch Gib dem Deal einen Namen. |
| valueNeeded | Englisch Enter a value above zero. | Deutsch Trag einen Wert über null ein. |
| noDeals | Englisch No deals yet. Create the first one below. | Deutsch Noch keine Deals. Leg unten den ersten an. |
| notes | Englisch Notes | Deutsch Notizen |
| pinned | Englisch Pinned | Deutsch Angeheftet |
| others | Englisch More notes | Deutsch Weitere Notizen |
| writeNote | Englisch Write a note, type @ to mention someone | Deutsch Notiz schreiben, mit @ jemanden erwähnen |
| saveNote | Englisch Save note | Deutsch Notiz speichern |
| noteSaved | Englisch Note saved. | Deutsch Notiz gespeichert. |
| pin | Englisch Pin note | Deutsch Notiz anheften |
| unpin | Englisch Unpin note | Deutsch Notiz lösen |
| edit | Englisch Edit note | Deutsch Notiz bearbeiten |
| del | Englisch Delete note | Deutsch Notiz löschen |
| deleted | Englisch Note deleted. | Deutsch Notiz gelöscht. |
| undo | Englisch Undo | Deutsch Rückgängig |
| restored | Englisch Note restored. | Deutsch Notiz wiederhergestellt. |
| save | Englisch Save changes | Deutsch Änderungen speichern |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| edited | Englisch Note updated. | Deutsch Notiz aktualisiert. |
| mentions | Englisch Mentions | Deutsch Erwähnungen |
| mentionsOf | Englisch Notes mentioning {name} | Deutsch Notizen, die {name} erwähnen |
| allNotes | Englisch All notes | Deutsch Alle Notizen |
| suggestions | Englisch People to mention | Deutsch Personen zum Erwähnen |
| noNotes | Englisch No note matches. Write the first one above. | Deutsch Keine Notiz passt. Schreib oben die erste. |
| searchNotes | Englisch Search notes | Deutsch Notizen durchsuchen |
| contacts | Englisch Contacts | Deutsch Kontakte |
| search | Englisch Search contacts | Deutsch Kontakte suchen |
| noMatch | Englisch No contact matches. Try a company or a city. | Deutsch Kein Kontakt passt. Versuch es mit Firma oder Ort. |
| back | Englisch Back to contacts | Deutsch Zurück zu den Kontakten |
| recent | Englisch Recent activity | Deutsch Letzte Aktivität |
| openDeals | Englisch Open deals | Deutsch Offene Deals |
| count | Englisch {n} contacts | Deutsch {n} Kontakte |
| nDeals | Englisch {n} open | Deutsch {n} offen |
| ago | Englisch Last contact {when} | Deutsch Letzter Kontakt {when} |
| noContact | Englisch Pick a contact to see the details. | Deutsch Wähle einen Kontakt, um die Details zu sehen. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- CRM-Kontakt reagiert selbst auf die Tasten Escape, Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Tab und Leertaste.
- Setzt aria-activedescendant, aria-autocomplete, aria-checked, aria-controls, aria-describedby, aria-disabled, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen combobox, group, listbox, option, radio, radiogroup, status, tab, tablist und tabpanel.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.776 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich CRM-Kontakt?
Mit aiw add @ai-website/crm-contact. Der Befehl legt die Datei components/ui/crm-contact.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind avatar, button, icon, motion und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat CRM-Kontakt?
5 Varianten, Kontaktprofil, Kontaktverlauf, Deals des Kontakts, Kontaktnotizen und Kontaktliste mit Detail. Du wählst sie mit der Prop variant, zum Beispiel variant="timeline".
Kann ich die Texte von CRM-Kontakt übersetzen?
Ja. CRM-Kontakt bringt 107 eigene Texte auf Deutsch und Englisch mit, etwa „E-Mail“, „Anruf“ und „Termin“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent CRM-Kontakt einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini CRM-Kontakt mit aiw_components und installieren sie mit aiw_add @ai-website/crm-contact. Nenn im Prompt die Variante, etwa variant="profile", dann schreibt der Assistent deine Inhalte direkt in die Props.