Company Card New
A company record with domain, firmographics, an ICP-fit ring, people and open deals, and an Enrich action that fills empty fields with sourced AI data.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/crm-company-card.json02
Or copy the source below. Dependencies: lucide-react.
Source
components/niceui/crm/CompanyCard.tsx
import { useEffect, useRef, useState } from 'react';
import { ArrowUpRight, Building2, Calendar, Check, CircleDollarSign, Globe, LoaderCircle, MapPin, Sparkles, Users } from 'lucide-react';
import { cn } from '@/lib/cn';
export type CompanyFieldKey = 'employees' | 'arr' | 'hq' | 'founded';
export type CompanyDeal = { name: string; stage: string; amount: string; tone: 'sky' | 'amber' | 'emerald' | 'violet' };
export type CompanyPerson = { name: string; initials: string; color: string };
export type Company = {
name: string;
domain: string;
industry: string;
monogram: string;
/** Firmographics; empty fields can be filled by Enrich. */
fields: Partial<Record<CompanyFieldKey, string>>;
icp: number;
people: CompanyPerson[];
deals: CompanyDeal[];
};
export type CompanyCardProps = {
company: Company;
/** Values Enrich fills in for empty fields, in order. */
enrichment?: Partial<Record<CompanyFieldKey, string>>;
sources?: string[];
/** Start enriching automatically after this many ms. */
autoEnrich?: number;
className?: string;
};
const FIELD_META: { key: CompanyFieldKey; label: string; icon: typeof Users }[] = [
{ key: 'employees', label: 'Employees', icon: Users },
{ key: 'arr', label: 'Annual revenue', icon: CircleDollarSign },
{ key: 'hq', label: 'Headquarters', icon: MapPin },
{ key: 'founded', label: 'Founded', icon: Calendar },
];
const TONE: Record<CompanyDeal['tone'], string> = {
sky: 'bg-sky-500/10 text-sky-300',
amber: 'bg-amber-500/10 text-amber-300',
emerald: 'bg-emerald-500/10 text-emerald-300',
violet: 'bg-violet-500/10 text-violet-300',
};
function FitRing({ value }: { value: number }) {
const r = 17;
const c = 2 * Math.PI * r;
const tone = value >= 75 ? '#34d399' : value >= 50 ? '#fbbf24' : '#fb7185';
return (
<span className="relative grid h-11 w-11 shrink-0 place-items-center">
<svg viewBox="0 0 40 40" className="absolute inset-0 -rotate-90">
<circle cx="20" cy="20" r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="4" />
<circle cx="20" cy="20" r={r} fill="none" stroke={tone} strokeWidth="4" strokeLinecap="round" strokeDasharray={`${(value / 100) * c} ${c}`} className="transition-[stroke-dasharray] duration-1000 ease-smooth" />
</svg>
<span className="font-mono text-xs font-semibold tabular-nums text-ink-50">{value}</span>
</span>
);
}
export function CompanyCard({ company, enrichment = {}, sources = [], autoEnrich, className }: CompanyCardProps) {
const [fields, setFields] = useState(company.fields);
const [enriched, setEnriched] = useState<Set<CompanyFieldKey>>(new Set());
const [pending, setPending] = useState<CompanyFieldKey | null>(null);
const [state, setState] = useState<'idle' | 'running' | 'done'>('idle');
const [icp, setIcp] = useState(company.icp);
const timers = useRef<number[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const missing = FIELD_META.filter((f) => !fields[f.key] && enrichment[f.key]).map((f) => f.key);
const enrich = () => {
if (state !== 'idle' || !missing.length) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
setFields((f) => ({ ...f, ...enrichment }));
setEnriched(new Set(missing));
setIcp((v) => Math.min(99, v + 9));
setState('done');
return;
}
setState('running');
missing.forEach((key, i) => {
timers.current.push(window.setTimeout(() => setPending(key), i * 520));
timers.current.push(
window.setTimeout(() => {
setFields((f) => ({ ...f, [key]: enrichment[key] }));
setEnriched((s) => new Set(s).add(key));
setPending(null);
}, i * 520 + 440),
);
});
timers.current.push(
window.setTimeout(() => {
setIcp((v) => Math.min(99, v + 9));
setState('done');
}, missing.length * 520 + 120),
);
};
const enrichRef = useRef(enrich);
enrichRef.current = enrich;
useEffect(() => {
if (autoEnrich === undefined) return;
const id = window.setTimeout(() => enrichRef.current(), autoEnrich);
return () => clearTimeout(id);
}, [autoEnrich]);
const pipeline = company.deals.reduce((s, d) => s + Number(d.amount.replace(/[^0-9.]/g, '')), 0);
return (
<div className={cn('w-full max-w-xl rounded-2xl bg-ink-900 p-4 shadow-card sm:p-5', className)}>
<div className="flex items-start gap-3">
<span className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-violet-400 to-indigo-700 text-lg font-bold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.25)]">{company.monogram}</span>
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold tracking-tight text-ink-50">{company.name}</h3>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-400">
<a href="#" onClick={(e) => e.preventDefault()} className="focus-ring inline-flex items-center gap-1 rounded text-brand-300 hover:text-brand-200">
<Globe className="h-3 w-3" /> {company.domain}
</a>
<span className="inline-flex items-center gap-1 rounded-md bg-white/[0.06] px-1.5 py-0.5 text-ink-300">
<Building2 className="h-3 w-3" /> {company.industry}
</span>
</p>
</div>
<div className="flex items-center gap-2" title="Ideal customer profile fit">
<FitRing value={icp} />
<span className="hidden text-[11px] leading-tight text-ink-500 sm:block">
ICP fit
<br />
<span className={cn(icp >= 75 ? 'text-emerald-300' : 'text-amber-300')}>{icp >= 75 ? 'Strong' : 'Medium'}</span>
</span>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
{FIELD_META.map(({ key, label, icon: Icon }) => {
const value = fields[key];
const loading = pending === key;
return (
<div key={key} className={cn('relative min-w-0 overflow-hidden rounded-xl border px-3 py-2.5 transition-colors duration-500', enriched.has(key) ? 'border-brand-400/30 bg-brand-500/[0.06]' : 'border-white/5 bg-white/[0.02]')}>
<p className="flex items-center gap-1.5 text-[11px] text-ink-500">
<Icon className="h-3 w-3" /> {label}
</p>
<p className="mt-1 flex min-w-0 items-center gap-1.5 text-sm">
{loading ? (
<span className="shimmer-text truncate font-medium">Looking up…</span>
) : value ? (
<span key={value} className="truncate font-medium text-ink-50 animate-fade-in">
{value}
</span>
) : (
<span className="text-ink-600">Empty</span>
)}
{enriched.has(key) && (
<span className="ml-auto inline-flex shrink-0 items-center gap-0.5 rounded bg-brand-500/15 px-1 py-px text-[9.5px] font-medium text-brand-200 animate-scale-in">
<Sparkles className="h-2.5 w-2.5" /> AI
</span>
)}
</p>
</div>
);
})}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={enrich}
disabled={state !== 'idle' || !missing.length}
className={cn(
'focus-ring inline-flex h-8 items-center gap-1.5 rounded-lg px-3 text-xs font-medium transition-all active:scale-95',
state === 'done' ? 'bg-emerald-500/10 text-emerald-300' : 'bg-brand-500 text-white hover:bg-brand-400 disabled:opacity-80',
)}
>
{state === 'running' ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : state === 'done' ? <Check className="h-3.5 w-3.5" /> : <Sparkles className="h-3.5 w-3.5" />}
{state === 'running' ? 'Enriching…' : state === 'done' ? `Enriched ${enriched.size} fields` : `Enrich ${missing.length} empty fields`}
</button>
{state === 'done' && sources.length > 0 && (
<span className="flex min-w-0 flex-wrap items-center gap-1 text-[11px] text-ink-500 animate-fade-in">
from
{sources.map((s) => (
<span key={s} className="rounded-md bg-white/[0.05] px-1.5 py-0.5 text-ink-300">
{s}
</span>
))}
</span>
)}
</div>
<div className="mt-4 grid gap-4 border-t border-white/5 pt-4 sm:grid-cols-[auto_minmax(0,1fr)]">
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-ink-500">People</p>
<div className="mt-2 flex items-center">
{company.people.slice(0, 4).map((p, i) => (
<span key={p.name} title={p.name} className="grid h-8 w-8 place-items-center rounded-full text-[11px] font-semibold text-white ring-2 ring-ink-900" style={{ background: p.color, marginLeft: i ? -8 : 0 }}>
{p.initials}
</span>
))}
{company.people.length > 4 && <span className="-ml-2 grid h-8 w-8 place-items-center rounded-full bg-ink-800 text-[11px] text-ink-300 ring-2 ring-ink-900">+{company.people.length - 4}</span>}
</div>
</div>
<div className="min-w-0">
<div className="flex items-center justify-between">
<p className="text-[11px] font-medium uppercase tracking-wider text-ink-500">Open deals</p>
<span className="font-mono text-[11px] tabular-nums text-ink-400">${pipeline.toLocaleString()}k pipeline</span>
</div>
<ul className="mt-2 space-y-1">
{company.deals.map((d) => (
<li key={d.name}>
<a href="#" onClick={(e) => e.preventDefault()} className="focus-ring group flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors hover:bg-white/[0.04]">
<span className="min-w-0 flex-1 truncate text-ink-100">{d.name}</span>
<span className={cn('shrink-0 rounded-md px-1.5 py-0.5 text-[10.5px] font-medium', TONE[d.tone])}>{d.stage}</span>
<span className="w-12 shrink-0 text-right font-mono text-xs tabular-nums text-ink-300">{d.amount}</span>
<ArrowUpRight className="h-3.5 w-3.5 shrink-0 text-ink-600 transition-colors group-hover:text-ink-300" />
</a>
</li>
))}
</ul>
</div>
</div>
</div>
);
}
const DEMO: Company = {
name: 'Lumenfield Analytics',
domain: 'lumenfield.io',
industry: 'Data infrastructure',
monogram: 'L',
fields: { employees: '180–250', arr: undefined, hq: undefined, founded: undefined },
icp: 74,
people: [
{ name: 'Aiden Park', initials: 'AP', color: '#0ea5e9' },
{ name: 'Noor Haddad', initials: 'NH', color: '#8b5cf6' },
{ name: 'Tomás Reyes', initials: 'TR', color: '#f59e0b' },
{ name: 'Wren Calloway', initials: 'WC', color: '#10b981' },
{ name: 'Iris Moreau', initials: 'IM', color: '#ef4444' },
{ name: 'Dev Patel', initials: 'DP', color: '#64748b' },
],
deals: [
{ name: 'Platform expansion', stage: 'Proposal', amount: '$84k', tone: 'amber' },
{ name: 'Analytics add-on', stage: 'Discovery', amount: '$22k', tone: 'sky' },
{ name: 'EU data residency', stage: 'Negotiation', amount: '$36k', tone: 'violet' },
],
};
export function CompanyCardDemo() {
return (
<CompanyCard
company={DEMO}
enrichment={{ arr: '$38M', hq: 'Austin, TX', founded: '2017' }}
sources={['Company site', 'Public filings', 'Press']}
autoEnrich={900}
/>
);
}