CRM 1: contact and deal record
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.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: CRM 1. https://ui.ballmac.com/blocks/crm-1
"use client"
import * as React from "react"
import { CalendarClock, Check, CircleAlert, Loader2, Mail, MapPin, MessageSquareText, Phone, StickyNote, Users, UserX } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { CoverArt } from "@/components/ballmac/cover-art"
import { Input } from "@/components/ballmac/input"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { Textarea } from "@/components/ballmac/textarea"
import { cn } from "@/lib/utils"
type Crm1Kind = "note" | "call" | "email" | "meeting"
type Crm1Activity = { id: string; kind: Crm1Kind; /** ISO date. */ date: string; by: string; summary: string }
type Crm1Contact = {
id: string
name: string
title: string
company: string
email: string
phone?: string
location?: string
owner: string
/** Index into stages. */
stage: number
/** Deal value in the currency. */
value: number
tags?: string[]
/** ISO date by which to follow up. */
followUp?: string
activities: Crm1Activity[]
}
type Crm1Props = Omit<React.ComponentProps<"section">, "title"> &
StateProps & {
contact?: Crm1Contact
stages?: string[]
/** An ISO date for "today". The block never reads the clock. */
today?: string
currency?: string
locale?: string
/** Called to log a note, call, email or meeting. Return a promise; reject and it stays in the list with Retry. */
onLog?: (activity: { kind: Crm1Kind; summary: string; date: string }) => void | Promise<void>
onStageChange?: (stage: number) => void
onFollowUp?: (date: string) => void
}
const sample: Crm1Contact = {
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",
activities: [
{ 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." },
{ id: "a2", kind: "email", date: "2026-10-06", by: "Sam Okafor", summary: "Sent the proposal and the security questionnaire." },
{ 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." },
{ 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." },
],
}
const stageList = ["Lead", "Qualified", "Proposal", "Negotiation", "Won"]
const kindMeta: Record<Crm1Kind, { label: string; icon: typeof Mail }> = {
note: { label: "Note", icon: StickyNote },
call: { label: "Call", icon: Phone },
email: { label: "Email", icon: Mail },
meeting: { label: "Meeting", icon: Users },
}
const DAY = 86_400_000
const toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`)
function Skel() {
return (
<div aria-hidden="true" className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="grid gap-4">
<Skeleton className="h-40 rounded-2xl" />
<Skeleton className="h-72 rounded-2xl" />
</div>
<Skeleton className="h-96 rounded-2xl" />
</div>
)
}
/**
* 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
* 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.
* A logged item appears at once and, if saving fails, stays in the list with Not saved and Retry.
*/
function Crm1({ contact = sample, stages = stageList, today = "2026-10-18", currency = "USD", locale = "en", onLog, onStageChange, onFollowUp, state = "ready", onRetry, emptyState, errorState, className, ...props }: Crm1Props) {
const id = React.useId()
const [stage, setStage] = React.useState(contact.stage)
const [followUp, setFollowUp] = React.useState(contact.followUp)
const [acts, setActs] = React.useState<(Crm1Activity & { pending?: boolean; failed?: boolean })[]>(contact.activities)
const [kind, setKind] = React.useState<Crm1Kind>("note")
const [text, setText] = React.useState("")
const [date, setDate] = React.useState(today)
const empty = state === "empty"
const money = new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 })
const dateFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }), [locale])
const ago = (d: string) => {
const n = Math.round((toT(today) - toT(d)) / DAY)
return n <= 0 ? "today" : n === 1 ? "yesterday" : n < 14 ? `${n} days ago` : `${Math.round(n / 7)} weeks ago`
}
const last = [...acts].sort((a, b) => b.date.localeCompare(a.date))[0]
const late = followUp ? Math.round((toT(today) - toT(followUp)) / DAY) : null
const sorted = [...acts].sort((a, b) => b.date.localeCompare(a.date))
async function deliver(a: Crm1Activity) {
try {
await onLog?.({ kind: a.kind, summary: a.summary, date: a.date })
setActs((x) => x.map((y) => (y.id === a.id ? { ...y, pending: false, failed: false } : y)))
} catch {
setActs((x) => x.map((y) => (y.id === a.id ? { ...y, pending: false, failed: true } : y)))
}
}
function log(e: React.FormEvent) {
e.preventDefault()
const summary = text.trim()
if (!summary) return
const a = { id: `n${acts.length}${summary.length}${date}`, kind, date, by: "You", summary, pending: true }
setActs((x) => [a, ...x])
setText("")
void deliver(a)
}
const content = empty ? (
<div className="bg-card rounded-2xl border px-6 py-16 text-center">
<span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full">
<UserX className="size-6" />
</span>
<h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "This contact is not here"}</h2>
<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>
{emptyState?.action}
</div>
) : (
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="min-w-0 grid grid-cols-1 gap-6">
<header className="bg-card rounded-2xl border p-5 sm:p-6">
<div className="flex flex-wrap items-center gap-4">
<span aria-hidden="true" className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full border text-lg font-semibold">
<CoverArt seed={contact.name} variant="mesh" className="absolute inset-0" />
<span className="relative">{contact.name.split(" ").map((w) => w[0]).join("")}</span>
</span>
<div className="min-w-0 flex-1">
<h1 id={`${id}-title`} className="text-2xl font-semibold tracking-[-0.03em]">
{contact.name}
</h1>
<p className="text-muted-foreground text-sm">
{contact.title} at {contact.company}
</p>
</div>
<div className="text-end">
<p className="text-muted-foreground text-xs">Deal value</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{money.format(contact.value)}</p>
</div>
</div>
<ol aria-label="Pipeline stage" className="mt-6 flex gap-1.5">
{stages.map((s, i) => (
<li key={s} className="min-w-0 flex-1">
<button
type="button"
aria-current={i === stage ? "step" : undefined}
onClick={() => { setStage(i); onStageChange?.(i) }}
className={cn("focus-visible:ring-ring/50 group block w-full rounded-lg text-start outline-none focus-visible:ring-[3px]")}
>
<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")} />
<span className={cn("mt-2 block truncate text-xs", i === stage ? "font-semibold" : "text-muted-foreground")}>
{s}
<span className="sr-only">{i < stage ? ", done" : i === stage ? ", current stage" : ""}</span>
</span>
</button>
</li>
))}
</ol>
</header>
<div className="bg-card rounded-2xl border">
<h2 className="border-b px-5 py-3.5 font-semibold tracking-tight">Activity</h2>
<form onSubmit={log} className="bg-muted/30 grid grid-cols-1 gap-3 border-b p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div role="radiogroup" aria-label="Type of activity" className="bg-muted flex flex-wrap rounded-lg p-0.5">
{(Object.keys(kindMeta) as Crm1Kind[]).map((k) => (
<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")}>
{React.createElement(kindMeta[k].icon, { "aria-hidden": true, className: "size-4" })}
{kindMeta[k].label}
</button>
))}
</div>
<label className="text-muted-foreground flex items-center gap-2 text-sm">
On
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} aria-label="Date of the activity" className="h-9 w-40" />
</label>
</div>
<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()}?`} />
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={!text.trim()}>
Log {kindMeta[kind].label.toLowerCase()}
</Button>
</div>
</form>
<ol className="grid gap-0 p-5 sm:p-6">
{sorted.map((a, i) => {
const K = kindMeta[a.kind]
return (
<li key={a.id} className="relative flex gap-4 pb-6 last:pb-0">
{i < sorted.length - 1 ? <span aria-hidden="true" className="bg-border absolute start-[17px] top-9 -bottom-0 w-px" /> : null}
<span aria-hidden="true" className="bg-background relative z-10 flex size-9 shrink-0 items-center justify-center rounded-full border">
<K.icon className="size-4" />
</span>
<div className="min-w-0 flex-1 pt-1">
<p className="text-sm">
<span className="font-semibold">{K.label}</span>
<span className="text-muted-foreground"> · {a.by} · {dateFmt.format(toT(a.date))}</span>
</p>
<p className="text-muted-foreground mt-1 text-sm text-pretty">{a.summary}</p>
{a.pending ? (
<p className="mt-1.5 flex items-center gap-1.5 text-xs">
<Loader2 aria-hidden="true" className="size-3 animate-spin motion-reduce:animate-none" />
Saving
</p>
) : null}
{a.failed ? (
<p role="alert" className="mt-1.5 flex items-center gap-2 text-xs">
<CircleAlert aria-hidden="true" className="size-3.5" />
Not saved.
<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]">
Retry
</button>
</p>
) : null}
</div>
</li>
)
})}
</ol>
</div>
</div>
<aside aria-label="Contact details" className="grid gap-4 lg:sticky lg:top-6">
<div className={cn("rounded-2xl border p-5", late !== null && late > 0 ? "bg-destructive/5 border-destructive/40" : "bg-card")}>
<h2 className="flex items-center gap-2 text-sm font-semibold">
<CalendarClock aria-hidden="true" className="size-4" />
Follow-up
</h2>
<p role="status" className="mt-3 text-sm font-medium">
{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"}
{followUp ? <span className="text-muted-foreground block font-normal">{dateFmt.format(toT(followUp))}</span> : null}
</p>
<label className="mt-4 block text-xs font-medium">
<span className="text-muted-foreground mb-1.5 block">Next follow-up</span>
<Input type="date" value={followUp ?? ""} onChange={(e) => { setFollowUp(e.target.value || undefined); if (e.target.value) onFollowUp?.(e.target.value) }} />
</label>
{last ? (
<p className="text-muted-foreground mt-4 border-t pt-4 text-sm">
Last contact <span className="text-foreground font-medium">{ago(last.date)}</span>
</p>
) : null}
</div>
<div className="bg-card rounded-2xl border p-5">
<h2 className="mb-3 text-sm font-semibold">Details</h2>
<dl className="grid gap-3 text-sm">
<div className="flex items-start gap-3">
<dt className="sr-only">Email</dt>
<Mail aria-hidden="true" className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<dd className="min-w-0 truncate">
<a href={`mailto:${contact.email}`} className="focus-visible:ring-ring/50 rounded-sm underline underline-offset-4 outline-none focus-visible:ring-[3px]">
{contact.email}
</a>
</dd>
</div>
{contact.phone ? (
<div className="flex items-start gap-3">
<dt className="sr-only">Phone</dt>
<Phone aria-hidden="true" className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<dd dir="ltr">
<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]">
{contact.phone}
</a>
</dd>
</div>
) : null}
{contact.location ? (
<div className="flex items-start gap-3">
<dt className="sr-only">Location</dt>
<MapPin aria-hidden="true" className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<dd>{contact.location}</dd>
</div>
) : null}
<div className="flex items-start gap-3">
<dt className="sr-only">Owner</dt>
<MessageSquareText aria-hidden="true" className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<dd>Owned by {contact.owner}</dd>
</div>
</dl>
{contact.tags?.length ? (
<ul aria-label="Tags" className="mt-4 flex flex-wrap gap-1.5 border-t pt-4">
{contact.tags.map((t) => (
<li key={t} className="bg-muted rounded-full px-2.5 py-0.5 text-xs font-medium">
{t}
</li>
))}
</ul>
) : null}
</div>
</aside>
</div>
)
return (
<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)}>
<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 }}>
{content}
</StateView>
</section>
)
}
export { Crm1, type Crm1Props, type Crm1Contact, type Crm1Activity, type Crm1Kind }Installation
$ pnpm dlx shadcn@latest add @ballmac/crm-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/cover-art @ballmac/input @ballmac/skeleton @ballmac/state-view @ballmac/textareaCopy the source into your project.
components/ballmac/blocks/crm-1/crm-1.tsx// Ballmac UI: CRM 1. https://ui.ballmac.com/blocks/crm-1 "use client" import * as React from "react" import { CalendarClock, Check, CircleAlert, Loader2, Mail, MapPin, MessageSquareText, Phone, StickyNote, Users, UserX } from "lucide-react" import { Button } from "@/components/ballmac/button" import { CoverArt } from "@/components/ballmac/cover-art" import { Input } from "@/components/ballmac/input" import { Skeleton } from "@/components/ballmac/skeleton" import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view" import { Textarea } from "@/components/ballmac/textarea" import { cn } from "@/lib/utils" type Crm1Kind = "note" | "call" | "email" | "meeting" type Crm1Activity = { id: string; kind: Crm1Kind; /** ISO date. */ date: string; by: string; summary: string } type Crm1Contact = { id: string name: string title: string company: string email: string phone?: string location?: string owner: string /** Index into stages. */ stage: number /** Deal value in the currency. */ value: number tags?: string[] /** ISO date by which to follow up. */ followUp?: string activities: Crm1Activity[] } type Crm1Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & { contact?: Crm1Contact stages?: string[] /** An ISO date for "today". The block never reads the clock. */ today?: string currency?: string locale?: string /** Called to log a note, call, email or meeting. Return a promise; reject and it stays in the list with Retry. */ onLog?: (activity: { kind: Crm1Kind; summary: string; date: string }) => void | Promise<void> onStageChange?: (stage: number) => void onFollowUp?: (date: string) => void } const sample: Crm1Contact = { 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", activities: [ { 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." }, { id: "a2", kind: "email", date: "2026-10-06", by: "Sam Okafor", summary: "Sent the proposal and the security questionnaire." }, { 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." }, { 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." }, ], } const stageList = ["Lead", "Qualified", "Proposal", "Negotiation", "Won"] const kindMeta: Record<Crm1Kind, { label: string; icon: typeof Mail }> = { note: { label: "Note", icon: StickyNote }, call: { label: "Call", icon: Phone }, email: { label: "Email", icon: Mail }, meeting: { label: "Meeting", icon: Users }, } const DAY = 86_400_000 const toT = (iso: string) => Date.parse(`${iso}T00:00:00Z`) function Skel() { return ( <div aria-hidden="true" className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"> <div className="grid gap-4"> <Skeleton className="h-40 rounded-2xl" /> <Skeleton className="h-72 rounded-2xl" /> </div> <Skeleton className="h-96 rounded-2xl" /> </div> ) } /** * 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 * 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. * A logged item appears at once and, if saving fails, stays in the list with Not saved and Retry. */ function Crm1({ contact = sample, stages = stageList, today = "2026-10-18", currency = "USD", locale = "en", onLog, onStageChange, onFollowUp, state = "ready", onRetry, emptyState, errorState, className, ...props }: Crm1Props) { const id = React.useId() const [stage, setStage] = React.useState(contact.stage) const [followUp, setFollowUp] = React.useState(contact.followUp) const [acts, setActs] = React.useState<(Crm1Activity & { pending?: boolean; failed?: boolean })[]>(contact.activities) const [kind, setKind] = React.useState<Crm1Kind>("note") const [text, setText] = React.useState("") const [date, setDate] = React.useState(today) const empty = state === "empty" const money = new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }) const dateFmt = React.useMemo(() => new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" }), [locale]) const ago = (d: string) => { const n = Math.round((toT(today) - toT(d)) / DAY) return n <= 0 ? "today" : n === 1 ? "yesterday" : n < 14 ? `${n} days ago` : `${Math.round(n / 7)} weeks ago` } const last = [...acts].sort((a, b) => b.date.localeCompare(a.date))[0] const late = followUp ? Math.round((toT(today) - toT(followUp)) / DAY) : null const sorted = [...acts].sort((a, b) => b.date.localeCompare(a.date)) async function deliver(a: Crm1Activity) { try { await onLog?.({ kind: a.kind, summary: a.summary, date: a.date }) setActs((x) => x.map((y) => (y.id === a.id ? { ...y, pending: false, failed: false } : y))) } catch { setActs((x) => x.map((y) => (y.id === a.id ? { ...y, pending: false, failed: true } : y))) } } function log(e: React.FormEvent) { e.preventDefault() const summary = text.trim() if (!summary) return const a = { id: `n${acts.length}${summary.length}${date}`, kind, date, by: "You", summary, pending: true } setActs((x) => [a, ...x]) setText("") void deliver(a) } const content = empty ? ( <div className="bg-card rounded-2xl border px-6 py-16 text-center"> <span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full"> <UserX className="size-6" /> </span> <h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "This contact is not here"}</h2> <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> {emptyState?.action} </div> ) : ( <div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]"> <div className="min-w-0 grid grid-cols-1 gap-6"> <header className="bg-card rounded-2xl border p-5 sm:p-6"> <div className="flex flex-wrap items-center gap-4"> <span aria-hidden="true" className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full border text-lg font-semibold"> <CoverArt seed={contact.name} variant="mesh" className="absolute inset-0" /> <span className="relative">{contact.name.split(" ").map((w) => w[0]).join("")}</span> </span> <div className="min-w-0 flex-1"> <h1 id={`${id}-title`} className="text-2xl font-semibold tracking-[-0.03em]"> {contact.name} </h1> <p className="text-muted-foreground text-sm"> {contact.title} at {contact.company} </p> </div> <div className="text-end"> <p className="text-muted-foreground text-xs">Deal value</p> <p className="text-2xl font-semibold tracking-tight tabular-nums">{money.format(contact.value)}</p> </div> </div> <ol aria-label="Pipeline stage" className="mt-6 flex gap-1.5"> {stages.map((s, i) => ( <li key={s} className="min-w-0 flex-1"> <button type="button" aria-current={i === stage ? "step" : undefined} onClick={() => { setStage(i); onStageChange?.(i) }} className={cn("focus-visible:ring-ring/50 group block w-full rounded-lg text-start outline-none focus-visible:ring-[3px]")} > <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")} /> <span className={cn("mt-2 block truncate text-xs", i === stage ? "font-semibold" : "text-muted-foreground")}> {s} <span className="sr-only">{i < stage ? ", done" : i === stage ? ", current stage" : ""}</span> </span> </button> </li> ))} </ol> </header> <div className="bg-card rounded-2xl border"> <h2 className="border-b px-5 py-3.5 font-semibold tracking-tight">Activity</h2> <form onSubmit={log} className="bg-muted/30 grid grid-cols-1 gap-3 border-b p-4 sm:p-5"> <div className="flex flex-wrap items-center justify-between gap-2"> <div role="radiogroup" aria-label="Type of activity" className="bg-muted flex flex-wrap rounded-lg p-0.5"> {(Object.keys(kindMeta) as Crm1Kind[]).map((k) => ( <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")}> {React.createElement(kindMeta[k].icon, { "aria-hidden": true, className: "size-4" })} {kindMeta[k].label} </button> ))} </div> <label className="text-muted-foreground flex items-center gap-2 text-sm"> On <Input type="date" value={date} onChange={(e) => setDate(e.target.value)} aria-label="Date of the activity" className="h-9 w-40" /> </label> </div> <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()}?`} /> <div className="flex justify-end"> <Button type="submit" size="sm" disabled={!text.trim()}> Log {kindMeta[kind].label.toLowerCase()} </Button> </div> </form> <ol className="grid gap-0 p-5 sm:p-6"> {sorted.map((a, i) => { const K = kindMeta[a.kind] return ( <li key={a.id} className="relative flex gap-4 pb-6 last:pb-0"> {i < sorted.length - 1 ? <span aria-hidden="true" className="bg-border absolute start-[17px] top-9 -bottom-0 w-px" /> : null} <span aria-hidden="true" className="bg-background relative z-10 flex size-9 shrink-0 items-center justify-center rounded-full border"> <K.icon className="size-4" /> </span> <div className="min-w-0 flex-1 pt-1"> <p className="text-sm"> <span className="font-semibold">{K.label}</span> <span className="text-muted-foreground"> · {a.by} · {dateFmt.format(toT(a.date))}</span> </p> <p className="text-muted-foreground mt-1 text-sm text-pretty">{a.summary}</p> {a.pending ? ( <p className="mt-1.5 flex items-center gap-1.5 text-xs"> <Loader2 aria-hidden="true" className="size-3 animate-spin motion-reduce:animate-none" /> Saving </p> ) : null} {a.failed ? ( <p role="alert" className="mt-1.5 flex items-center gap-2 text-xs"> <CircleAlert aria-hidden="true" className="size-3.5" /> Not saved. <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]"> Retry </button> </p> ) : null} </div> </li> ) })} </ol> </div> </div> <aside aria-label="Contact details" className="grid gap-4 lg:sticky lg:top-6"> <div className={cn("rounded-2xl border p-5", late !== null && late > 0 ? "bg-destructive/5 border-destructive/40" : "bg-card")}> <h2 className="flex items-center gap-2 text-sm font-semibold"> <CalendarClock aria-hidden="true" className="size-4" /> Follow-up </h2> <p role="status" className="mt-3 text-sm font-medium"> {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"} {followUp ? <span className="text-muted-foreground block font-normal">{dateFmt.format(toT(followUp))}</span> : null} </p> <label className="mt-4 block text-xs font-medium"> <span className="text-muted-foreground mb-1.5 block">Next follow-up</span> <Input type="date" value={followUp ?? ""} onChange={(e) => { setFollowUp(e.target.value || undefined); if (e.target.value) onFollowUp?.(e.target.value) }} /> </label> {last ? ( <p className="text-muted-foreground mt-4 border-t pt-4 text-sm"> Last contact <span className="text-foreground font-medium">{ago(last.date)}</span> </p> ) : null} </div> <div className="bg-card rounded-2xl border p-5"> <h2 className="mb-3 text-sm font-semibold">Details</h2> <dl className="grid gap-3 text-sm"> <div className="flex items-start gap-3"> <dt className="sr-only">Email</dt> <Mail aria-hidden="true" className="text-muted-foreground mt-0.5 size-4 shrink-0" /> <dd className="min-w-0 truncate"> <a href={`mailto:${contact.email}`} className="focus-visible:ring-ring/50 rounded-sm underline underline-offset-4 outline-none focus-visible:ring-[3px]"> {contact.email} </a> </dd> </div> {contact.phone ? ( <div className="flex items-start gap-3"> <dt className="sr-only">Phone</dt> <Phone aria-hidden="true" className="text-muted-foreground mt-0.5 size-4 shrink-0" /> <dd dir="ltr"> <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]"> {contact.phone} </a> </dd> </div> ) : null} {contact.location ? ( <div className="flex items-start gap-3"> <dt className="sr-only">Location</dt> <MapPin aria-hidden="true" className="text-muted-foreground mt-0.5 size-4 shrink-0" /> <dd>{contact.location}</dd> </div> ) : null} <div className="flex items-start gap-3"> <dt className="sr-only">Owner</dt> <MessageSquareText aria-hidden="true" className="text-muted-foreground mt-0.5 size-4 shrink-0" /> <dd>Owned by {contact.owner}</dd> </div> </dl> {contact.tags?.length ? ( <ul aria-label="Tags" className="mt-4 flex flex-wrap gap-1.5 border-t pt-4"> {contact.tags.map((t) => ( <li key={t} className="bg-muted rounded-full px-2.5 py-0.5 text-xs font-medium"> {t} </li> ))} </ul> ) : null} </div> </aside> </div> ) return ( <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)}> <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 }}> {content} </StateView> </section> ) } export { Crm1, type Crm1Props, type Crm1Contact, type Crm1Activity, type Crm1Kind }Update the import paths to match your project setup.
Usage
"use client"
import { Crm1 } from "@/components/ballmac/blocks/crm-1/crm-1"
export default function Crm1Demo() {
return <Crm1 onLog={() => new Promise((r) => setTimeout(r, 500))} />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
A skeleton header, timeline and side panel, announced as loading.
state="loading"Contact not found
Says they may have been merged or removed; emptyState sets the copy and can add a link back.
state="empty"Could not load
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.
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 5 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (8% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
25 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by crm-1.test.tsx.
For information: Footprint
5.3 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
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. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI CRM 1: contact and deal record (@ballmac/crm-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A customer or lead record in a CRM or support tool
- An account page for sales and customer success
Not for
- A list or board of deals (use kanban-1)
- A support conversation (use tickets-1)
Registry JSON: https://ui.ballmac.com/r/crm-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.