{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "crm-1",
  "title": "CRM 1: contact and deal record",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A contact record for a sales team: the pipeline stage you can move, deal value, last contact and an overdue follow-up worked out from the day you pass, and a timeline of notes, calls, emails and meetings with a form to log the next.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/cover-art.json",
    "https://ui.ballmac.com/r/input.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/textarea.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/crm-1/crm-1.tsx",
      "content": "// Ballmac UI: CRM 1. https://ui.ballmac.com/blocks/crm-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CalendarClock, Check, CircleAlert, Loader2, Mail, MapPin, MessageSquareText, Phone, StickyNote, Users, UserX } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { CoverArt } from \"@/components/ballmac/cover-art\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Textarea } from \"@/components/ballmac/textarea\"\nimport { cn } from \"@/lib/utils\"\n\ntype Crm1Kind = \"note\" | \"call\" | \"email\" | \"meeting\"\ntype Crm1Activity = { id: string; kind: Crm1Kind; /** ISO date. */ date: string; by: string; summary: string }\n\ntype Crm1Contact = {\n  id: string\n  name: string\n  title: string\n  company: string\n  email: string\n  phone?: string\n  location?: string\n  owner: string\n  /** Index into stages. */\n  stage: number\n  /** Deal value in the currency. */\n  value: number\n  tags?: string[]\n  /** ISO date by which to follow up. */\n  followUp?: string\n  activities: Crm1Activity[]\n}\n\ntype Crm1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    contact?: Crm1Contact\n    stages?: string[]\n    /** An ISO date for \"today\". The block never reads the clock. */\n    today?: string\n    currency?: string\n    locale?: string\n    /** Called to log a note, call, email or meeting. Return a promise; reject and it stays in the list with Retry. */\n    onLog?: (activity: { kind: Crm1Kind; summary: string; date: string }) => void | Promise<void>\n    onStageChange?: (stage: number) => void\n    onFollowUp?: (date: string) => void\n  }\n\nconst sample: Crm1Contact = {\n  id: \"c1\", name: \"Elena Marsh\", title: \"VP Operations\", company: \"Northwind Logistics\", email: \"elena@northwind.com\", phone: \"+1 212 555 0148\", location: \"New York\", owner: \"Sam Okafor\", stage: 2, value: 48000, tags: [\"Enterprise\", \"Warm\", \"Referral\"], followUp: \"2026-10-16\",\n  activities: [\n    { id: \"a1\", kind: \"meeting\", date: \"2026-10-09\", by: \"Sam Okafor\", summary: \"Demo for the operations team. Elena asked about approvals and SSO; two of her leads joined and liked the timeline view.\" },\n    { id: \"a2\", kind: \"email\", date: \"2026-10-06\", by: \"Sam Okafor\", summary: \"Sent the proposal and the security questionnaire.\" },\n    { id: \"a3\", kind: \"call\", date: \"2026-09-29\", by: \"Sam Okafor\", summary: \"Thirty minute call. Budget confirmed for this quarter; decision by the end of October.\" },\n    { id: \"a4\", kind: \"note\", date: \"2026-09-21\", by: \"Mia Laurent\", summary: \"Met at the logistics summit. Interested in replacing three spreadsheets. Introduced by Kofi.\" },\n  ],\n}\n\nconst stageList = [\"Lead\", \"Qualified\", \"Proposal\", \"Negotiation\", \"Won\"]\nconst kindMeta: Record<Crm1Kind, { label: string; icon: typeof Mail }> = {\n  note: { label: \"Note\", icon: StickyNote },\n  call: { label: \"Call\", icon: Phone },\n  email: { label: \"Email\", icon: Mail },\n  meeting: { label: \"Meeting\", icon: Users },\n}\nconst DAY = 86_400_000\nconst toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`)\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]\">\n      <div className=\"grid gap-4\">\n        <Skeleton className=\"h-40 rounded-2xl\" />\n        <Skeleton className=\"h-72 rounded-2xl\" />\n      </div>\n      <Skeleton className=\"h-96 rounded-2xl\" />\n    </div>\n  )\n}\n\n/**\n * A contact record for a sales team: who they are, where the deal stands on a pipeline you can move, the value, when you last spoke and whether a\n * follow-up is overdue (worked out from the day you pass), and a timeline of notes, calls, emails and meetings with a form to log the next one.\n * A logged item appears at once and, if saving fails, stays in the list with Not saved and Retry.\n */\nfunction Crm1({ contact = sample, stages = stageList, today = \"2026-10-18\", currency = \"USD\", locale = \"en\", onLog, onStageChange, onFollowUp, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Crm1Props) {\n  const id = React.useId()\n  const [stage, setStage] = React.useState(contact.stage)\n  const [followUp, setFollowUp] = React.useState(contact.followUp)\n  const [acts, setActs] = React.useState<(Crm1Activity & { pending?: boolean; failed?: boolean })[]>(contact.activities)\n  const [kind, setKind] = React.useState<Crm1Kind>(\"note\")\n  const [text, setText] = React.useState(\"\")\n  const [date, setDate] = React.useState(today)\n  const empty = state === \"empty\"\n  const money = new Intl.NumberFormat(locale, { style: \"currency\", currency, maximumFractionDigits: 0 })\n  const dateFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"long\", year: \"numeric\", timeZone: \"UTC\" }), [locale])\n\n  const ago = (d: string) => {\n    const n = Math.round((toT(today) - toT(d)) / DAY)\n    return n <= 0 ? \"today\" : n === 1 ? \"yesterday\" : n < 14 ? `${n} days ago` : `${Math.round(n / 7)} weeks ago`\n  }\n  const last = [...acts].sort((a, b) => b.date.localeCompare(a.date))[0]\n  const late = followUp ? Math.round((toT(today) - toT(followUp)) / DAY) : null\n  const sorted = [...acts].sort((a, b) => b.date.localeCompare(a.date))\n\n  async function deliver(a: Crm1Activity) {\n    try {\n      await onLog?.({ kind: a.kind, summary: a.summary, date: a.date })\n      setActs((x) => x.map((y) => (y.id === a.id ? { ...y, pending: false, failed: false } : y)))\n    } catch {\n      setActs((x) => x.map((y) => (y.id === a.id ? { ...y, pending: false, failed: true } : y)))\n    }\n  }\n  function log(e: React.FormEvent) {\n    e.preventDefault()\n    const summary = text.trim()\n    if (!summary) return\n    const a = { id: `n${acts.length}${summary.length}${date}`, kind, date, by: \"You\", summary, pending: true }\n    setActs((x) => [a, ...x])\n    setText(\"\")\n    void deliver(a)\n  }\n\n  const content = empty ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <UserX className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"This contact is not here\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"They may have been merged with another record or removed.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]\">\n      <div className=\"min-w-0 grid grid-cols-1 gap-6\">\n        <header className=\"bg-card rounded-2xl border p-5 sm:p-6\">\n          <div className=\"flex flex-wrap items-center gap-4\">\n            <span aria-hidden=\"true\" className=\"relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full border text-lg font-semibold\">\n              <CoverArt seed={contact.name} variant=\"mesh\" className=\"absolute inset-0\" />\n              <span className=\"relative\">{contact.name.split(\" \").map((w) => w[0]).join(\"\")}</span>\n            </span>\n            <div className=\"min-w-0 flex-1\">\n              <h1 id={`${id}-title`} className=\"text-2xl font-semibold tracking-[-0.03em]\">\n                {contact.name}\n              </h1>\n              <p className=\"text-muted-foreground text-sm\">\n                {contact.title} at {contact.company}\n              </p>\n            </div>\n            <div className=\"text-end\">\n              <p className=\"text-muted-foreground text-xs\">Deal value</p>\n              <p className=\"text-2xl font-semibold tracking-tight tabular-nums\">{money.format(contact.value)}</p>\n            </div>\n          </div>\n\n          <ol aria-label=\"Pipeline stage\" className=\"mt-6 flex gap-1.5\">\n            {stages.map((s, i) => (\n              <li key={s} className=\"min-w-0 flex-1\">\n                <button\n                  type=\"button\"\n                  aria-current={i === stage ? \"step\" : undefined}\n                  onClick={() => { setStage(i); onStageChange?.(i) }}\n                  className={cn(\"focus-visible:ring-ring/50 group block w-full rounded-lg text-start outline-none focus-visible:ring-[3px]\")}\n                >\n                  <span aria-hidden=\"true\" className={cn(\"block h-2 rounded-full transition-colors motion-reduce:transition-none\", i <= stage ? (stage === stages.length - 1 ? \"bg-chart-2\" : \"bg-foreground\") : \"bg-muted group-hover:bg-muted-foreground/30\")} />\n                  <span className={cn(\"mt-2 block truncate text-xs\", i === stage ? \"font-semibold\" : \"text-muted-foreground\")}>\n                    {s}\n                    <span className=\"sr-only\">{i < stage ? \", done\" : i === stage ? \", current stage\" : \"\"}</span>\n                  </span>\n                </button>\n              </li>\n            ))}\n          </ol>\n        </header>\n\n        <div className=\"bg-card rounded-2xl border\">\n          <h2 className=\"border-b px-5 py-3.5 font-semibold tracking-tight\">Activity</h2>\n          <form onSubmit={log} className=\"bg-muted/30 grid grid-cols-1 gap-3 border-b p-4 sm:p-5\">\n            <div className=\"flex flex-wrap items-center justify-between gap-2\">\n              <div role=\"radiogroup\" aria-label=\"Type of activity\" className=\"bg-muted flex flex-wrap rounded-lg p-0.5\">\n                {(Object.keys(kindMeta) as Crm1Kind[]).map((k) => (\n                  <button key={k} type=\"button\" role=\"radio\" aria-checked={kind === k} onClick={() => setKind(k)} className={cn(\"focus-visible:ring-ring/50 flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium outline-none focus-visible:ring-[3px]\", kind === k ? \"bg-background shadow-sm\" : \"text-muted-foreground\")}>\n                    {React.createElement(kindMeta[k].icon, { \"aria-hidden\": true, className: \"size-4\" })}\n                    {kindMeta[k].label}\n                  </button>\n                ))}\n              </div>\n              <label className=\"text-muted-foreground flex items-center gap-2 text-sm\">\n                On\n                <Input type=\"date\" value={date} onChange={(e) => setDate(e.target.value)} aria-label=\"Date of the activity\" className=\"h-9 w-40\" />\n              </label>\n            </div>\n            <Textarea aria-label={`Write the ${kindMeta[kind].label.toLowerCase()}`} value={text} onChange={(e) => setText(e.target.value)} rows={3} placeholder={kind === \"note\" ? \"What should the team remember?\" : `What was said in the ${kindMeta[kind].label.toLowerCase()}?`} />\n            <div className=\"flex justify-end\">\n              <Button type=\"submit\" size=\"sm\" disabled={!text.trim()}>\n                Log {kindMeta[kind].label.toLowerCase()}\n              </Button>\n            </div>\n          </form>\n          <ol className=\"grid gap-0 p-5 sm:p-6\">\n            {sorted.map((a, i) => {\n              const K = kindMeta[a.kind]\n              return (\n                <li key={a.id} className=\"relative flex gap-4 pb-6 last:pb-0\">\n                  {i < sorted.length - 1 ? <span aria-hidden=\"true\" className=\"bg-border absolute start-[17px] top-9 -bottom-0 w-px\" /> : null}\n                  <span aria-hidden=\"true\" className=\"bg-background relative z-10 flex size-9 shrink-0 items-center justify-center rounded-full border\">\n                    <K.icon className=\"size-4\" />\n                  </span>\n                  <div className=\"min-w-0 flex-1 pt-1\">\n                    <p className=\"text-sm\">\n                      <span className=\"font-semibold\">{K.label}</span>\n                      <span className=\"text-muted-foreground\"> · {a.by} · {dateFmt.format(toT(a.date))}</span>\n                    </p>\n                    <p className=\"text-muted-foreground mt-1 text-sm text-pretty\">{a.summary}</p>\n                    {a.pending ? (\n                      <p className=\"mt-1.5 flex items-center gap-1.5 text-xs\">\n                        <Loader2 aria-hidden=\"true\" className=\"size-3 animate-spin motion-reduce:animate-none\" />\n                        Saving\n                      </p>\n                    ) : null}\n                    {a.failed ? (\n                      <p role=\"alert\" className=\"mt-1.5 flex items-center gap-2 text-xs\">\n                        <CircleAlert aria-hidden=\"true\" className=\"size-3.5\" />\n                        Not saved.\n                        <button type=\"button\" onClick={() => { setActs((x) => x.map((y) => (y.id === a.id ? { ...y, pending: true, failed: false } : y))); void deliver(a) }} className=\"focus-visible:ring-ring/50 rounded-sm font-semibold underline underline-offset-2 outline-none focus-visible:ring-[3px]\">\n                          Retry\n                        </button>\n                      </p>\n                    ) : null}\n                  </div>\n                </li>\n              )\n            })}\n          </ol>\n        </div>\n      </div>\n\n      <aside aria-label=\"Contact details\" className=\"grid gap-4 lg:sticky lg:top-6\">\n        <div className={cn(\"rounded-2xl border p-5\", late !== null && late > 0 ? \"bg-destructive/5 border-destructive/40\" : \"bg-card\")}>\n          <h2 className=\"flex items-center gap-2 text-sm font-semibold\">\n            <CalendarClock aria-hidden=\"true\" className=\"size-4\" />\n            Follow-up\n          </h2>\n          <p role=\"status\" className=\"mt-3 text-sm font-medium\">\n            {followUp ? (late! > 0 ? `Overdue by ${late} ${late === 1 ? \"day\" : \"days\"}` : late === 0 ? \"Due today\" : `Due in ${-late!} ${-late! === 1 ? \"day\" : \"days\"}`) : \"No follow-up planned\"}\n            {followUp ? <span className=\"text-muted-foreground block font-normal\">{dateFmt.format(toT(followUp))}</span> : null}\n          </p>\n          <label className=\"mt-4 block text-xs font-medium\">\n            <span className=\"text-muted-foreground mb-1.5 block\">Next follow-up</span>\n            <Input type=\"date\" value={followUp ?? \"\"} onChange={(e) => { setFollowUp(e.target.value || undefined); if (e.target.value) onFollowUp?.(e.target.value) }} />\n          </label>\n          {last ? (\n            <p className=\"text-muted-foreground mt-4 border-t pt-4 text-sm\">\n              Last contact <span className=\"text-foreground font-medium\">{ago(last.date)}</span>\n            </p>\n          ) : null}\n        </div>\n\n        <div className=\"bg-card rounded-2xl border p-5\">\n          <h2 className=\"mb-3 text-sm font-semibold\">Details</h2>\n          <dl className=\"grid gap-3 text-sm\">\n            <div className=\"flex items-start gap-3\">\n              <dt className=\"sr-only\">Email</dt>\n              <Mail aria-hidden=\"true\" className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" />\n              <dd className=\"min-w-0 truncate\">\n                <a href={`mailto:${contact.email}`} className=\"focus-visible:ring-ring/50 rounded-sm underline underline-offset-4 outline-none focus-visible:ring-[3px]\">\n                  {contact.email}\n                </a>\n              </dd>\n            </div>\n            {contact.phone ? (\n              <div className=\"flex items-start gap-3\">\n                <dt className=\"sr-only\">Phone</dt>\n                <Phone aria-hidden=\"true\" className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" />\n                <dd dir=\"ltr\">\n                  <a href={`tel:${contact.phone.replace(/[^+\\d]/g, \"\")}`} className=\"focus-visible:ring-ring/50 rounded-sm underline underline-offset-4 outline-none focus-visible:ring-[3px]\">\n                    {contact.phone}\n                  </a>\n                </dd>\n              </div>\n            ) : null}\n            {contact.location ? (\n              <div className=\"flex items-start gap-3\">\n                <dt className=\"sr-only\">Location</dt>\n                <MapPin aria-hidden=\"true\" className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" />\n                <dd>{contact.location}</dd>\n              </div>\n            ) : null}\n            <div className=\"flex items-start gap-3\">\n              <dt className=\"sr-only\">Owner</dt>\n              <MessageSquareText aria-hidden=\"true\" className=\"text-muted-foreground mt-0.5 size-4 shrink-0\" />\n              <dd>Owned by {contact.owner}</dd>\n            </div>\n          </dl>\n          {contact.tags?.length ? (\n            <ul aria-label=\"Tags\" className=\"mt-4 flex flex-wrap gap-1.5 border-t pt-4\">\n              {contact.tags.map((t) => (\n                <li key={t} className=\"bg-muted rounded-full px-2.5 py-0.5 text-xs font-medium\">\n                  {t}\n                </li>\n              ))}\n            </ul>\n          ) : null}\n        </div>\n      </aside>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"crm-1\" data-state={state} aria-label={empty ? \"Contact\" : contact.name} className={cn(\"mx-auto w-full max-w-6xl px-4 py-8 sm:px-6\", className)} {...frameProps(props)}>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the contact\" loading={<Skel />} error={{ title: errorState?.title ?? \"The contact could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Crm1, type Crm1Props, type Crm1Contact, type Crm1Activity, type Crm1Kind }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/crm-1/crm-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "crm",
      "contact",
      "deal",
      "pipeline",
      "sales",
      "leads",
      "activity",
      "follow-up",
      "customer record",
      "account"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A CRM contact record. <Crm1 contact={{ id, name, title, company, email, phone, location, owner, stage: 2, value: 48000, tags, followUp: '2026-10-16', activities: [{ id, kind: 'note' | 'call' | 'email' | 'meeting', date: '2026-10-09', by, summary }] }} stages={['Lead', 'Qualified', 'Proposal', 'Negotiation', 'Won']} today='2026-10-18' currency locale onLog={({ kind, summary, date }) => Promise} onStageChange={(index) => ...} onFollowUp={(date) => ...} />. Last contact and the follow-up status (Due in N days, Due today, Overdue by N days) are worked out from today; pass it yourself, the block never reads the clock. A logged item appears at once; reject onLog and it stays with Not saved and Retry.",
      "whenToUse": [
        "A customer or lead record in a CRM or support tool",
        "An account page for sales and customer success"
      ],
      "whenNotToUse": [
        "A list or board of deals (use kanban-1)",
        "A support conversation (use tickets-1)"
      ],
      "composesWith": [
        "directory-1",
        "tickets-1",
        "invoice-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter / Space",
          "action": "Each pipeline stage is a button that says done, current stage or nothing; the activity type is a radio group and the form fields have labels"
        },
        {
          "keys": "Follow-up",
          "action": "'Overdue by 2 days' is written in words with the date, and the card is tinted as well as worded"
        },
        {
          "keys": "Logging",
          "action": "A new activity shows Saving, and a failure is an alert with a Retry button"
        }
      ],
      "customization": [
        "stages is a plain list of names, so any pipeline works",
        "Value, dates and relative times follow currency, locale and today",
        "The timeline line and stages mirror in right-to-left"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "crm-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton header, timeline and side panel, announced as loading."
      },
      {
        "id": "empty",
        "title": "Contact not found",
        "example": "crm-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says they may have been merged or removed; emptyState sets the copy and can add a link back."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "crm-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A logged activity that fails to save stays in the timeline with Not saved and Retry."
      }
    ],
    "examples": [
      "crm-1-demo",
      "crm-1-won",
      "crm-1-loading",
      "crm-1-empty",
      "crm-1-error"
    ],
    "url": "https://ui.ballmac.com/components/crm-1"
  },
  "categories": [
    "blocks",
    "crm"
  ],
  "type": "registry:block"
}