spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1import { ArrowUpRight, ChevronDown, MapPin } from 'lucide-react';2import Link from 'next/link';3import type { ReactNode } from 'react';4import { Chip } from '@/components/ui/badges';5import { Empty, Note } from '@/components/ui/section';6import { cn } from '@/lib/cn';7import { countryName } from '@/lib/countries';8import { fmtDate, fmtDateTime, fmtInt, fmtMoney, plural } from '@/lib/format';9import { descriptionOf, groupRelationships, logoCandidates, relationshipName, sourceFor, sourceLabel } from '@/lib/profile';10import { routes } from '@/lib/site';11import type { CompanyCard, CompanyDetail, CompanyFact, MoneyFact, Relationship } from '@/lib/types';12import { CompanyLogo } from './company-logo';1314/* ------------------------------------------------------------------------------------------------------- source chip */15/** Small provenance chip; `title` carries the retrieval time so every fact is auditable on hover / long-press. */16export function SourceChip({ source, url, retrievedAt, className }: { source: string | null | undefined; url?: string | null; retrievedAt?: string | null; className?: string }) {17 if (!source) return null;18 const label = sourceLabel(source);19 const title = retrievedAt ? `${label} · retrieved ${fmtDateTime(retrievedAt)}` : label;20 const llm = source.toLowerCase() === 'llm';21 const chip = (22 <Chip tone={llm ? 'warning' : 'outline'} title={title} className={cn('font-normal', className)}>23 {label}24 </Chip>25 );26 if (!url) return chip;27 return (28 <a href={url} target="_blank" rel="noopener nofollow noreferrer" className="inline-flex hover:opacity-80" aria-label={`${label} source (opens in a new tab)`}>29 {chip}30 </a>31 );32}3334/* ------------------------------------------------------------------------------------------------ description credit */35/** Attribution line under the description — the wording depends on where the paragraph came from. */36export function DescriptionAttribution({ c, className }: { c: CompanyCard; className?: string }) {37 const d = descriptionOf(c);38 if (!d || !d.source) return null;39 const ext = { target: '_blank', rel: 'noopener nofollow noreferrer' } as const;40 if (d.source === 'wikipedia') {41 const license = d.license ?? 'CC BY-SA 4.0';42 return (43 <p className={cn('text-[11px] text-ink-3', className)}>44 Source:{' '}45 {d.url ? (46 <a href={d.url} {...ext} className="hover:text-accent">47 Wikipedia48 </a>49 ) : (50 'Wikipedia'51 )}{' '}52 · {license}53 </p>54 );55 }56 if (d.source === 'wikidata') {57 return (58 <p className={cn('text-[11px] text-ink-3', className)}>59 Source:{' '}60 {d.url ? (61 <a href={d.url} {...ext} className="hover:text-accent">62 Wikidata63 </a>64 ) : (65 'Wikidata'66 )}67 {d.license ? ` · ${d.license}` : ' · CC0'}68 </p>69 );70 }71 if (d.source === 'homepage') {72 return (73 <p className={cn('text-[11px] text-ink-3', className)}>74 {d.url ? (75 <a href={d.url} {...ext} className="hover:text-accent">76 From the company’s website77 </a>78 ) : (79 'From the company’s website'80 )}81 </p>82 );83 }84 return (85 <p className={cn('flex flex-wrap items-center gap-1.5 text-[11px] text-ink-3', className)}>86 <Chip tone="warning" className="font-normal">87 Generated from the company’s public pages88 </Chip>89 <span>Model-written summary of observed first-party text — not a statement by the company.</span>90 </p>91 );92}9394/* ----------------------------------------------------------------------------------------------------- key facts */95type FactRow = { key: string; label: ReactNode; value: ReactNode; source: string | null; url: string | null; retrievedAt: string | null };9697const MONEY_LABEL: Record<'revenue' | 'net_income' | 'total_assets', string> = { revenue: 'Revenue', net_income: 'Net income', total_assets: 'Total assets' };9899function money(m: MoneyFact | null): ReactNode {100 if (!m || typeof m.value !== 'number') return null;101 return (102 <span className="tnum">103 {fmtMoney(m.value, m.currency)} <span className="text-ink-3">· FY{m.year}</span>104 </span>105 );106}107108/** Deterministic public registry links for identifiers (no lookup, no inference). */109const EDGAR = (cik: string) => `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${encodeURIComponent(cik)}`;110const GLEIF = (lei: string) => `https://search.gleif.org/#/record/${encodeURIComponent(lei)}`;111112/** Builds the sourced fact rows: structured profile fields first (typed formatting), then any extra `facts[]` entries. */113export function buildFactRows(c: CompanyDetail): FactRow[] {114 const p = c.profile ?? null;115 const rows: FactRow[] = [];116 const covered = new Set<string>();117 const push = (key: string, label: ReactNode, value: ReactNode, fields: string[] = [key]) => {118 if (value === null || value === undefined || value === '' || value === false) return;119 const s = sourceFor(p, ...fields);120 const f = (c.facts ?? []).find((x) => x.key === key || fields.includes(x.key));121 rows.push({ key, label, value, source: s?.source ?? f?.source ?? null, url: s?.url ?? f?.url ?? null, retrievedAt: s?.retrieved_at ?? f?.retrieved_at ?? null });122 covered.add(key);123 fields.forEach((x) => covered.add(x));124 };125126 if (p) {127 if (p.founded_year) push('founded_year', 'Founded', <span className="tnum">{p.founded_year}</span>, ['founded_year', 'founded', 'inception']);128 const hqParts = [p.hq?.city, p.hq?.region, p.hq?.country ? countryName(p.hq.country) : null].filter(Boolean) as string[];129 if (hqParts.length)130 push(131 'hq',132 'Headquarters',133 <span className="inline-flex flex-wrap items-center gap-x-1.5">134 <span>{hqParts.join(', ')}</span>135 <Link href={routes.company(c.slug, 'locations')} scroll={false} className="inline-flex items-center gap-0.5 text-[11px] text-ink-3 hover:text-accent" aria-label="Open the Locations tab">136 <MapPin className="size-3" aria-hidden /> map137 </Link>138 </span>,139 ['hq', 'headquarters', 'hq_city', 'hq_country'],140 );141 if (typeof p.employees === 'number')142 push(143 'employees',144 'Employees',145 <span className="tnum">146 {fmtInt(p.employees)}147 {p.employees_year ? <span className="text-ink-3"> · {p.employees_year}</span> : null}148 </span>,149 ['employees', 'employees_year'],150 );151 for (const k of ['revenue', 'net_income', 'total_assets'] as const) push(k, MONEY_LABEL[k], money(p[k]));152 if (p.legal_form) push('legal_form', 'Legal form', p.legal_form);153 if (p.public_company && (p.ticker || c.ticker))154 push(155 'listing',156 'Listing',157 <span className="mono text-xs">158 {p.ticker ?? c.ticker}159 {(p.exchange ?? c.exchange) && <span className="text-ink-3"> · {p.exchange ?? c.exchange}</span>}160 </span>,161 ['ticker', 'exchange', 'listing'],162 );163 if (p.isin) push('isin', 'ISIN', <span className="mono text-xs">{p.isin}</span>);164 if (p.lei)165 push(166 'lei',167 'LEI',168 <a href={GLEIF(p.lei)} target="_blank" rel="noopener nofollow noreferrer" className="mono inline-flex items-center gap-0.5 text-xs hover:text-accent">169 {p.lei} <ArrowUpRight className="size-3 text-ink-3" aria-hidden />170 </a>,171 );172 if (p.sec_cik)173 push(174 'sec_cik',175 'SEC CIK',176 <a href={EDGAR(p.sec_cik)} target="_blank" rel="noopener nofollow noreferrer" className="mono inline-flex items-center gap-0.5 text-xs hover:text-accent">177 {p.sec_cik} <ArrowUpRight className="size-3 text-ink-3" aria-hidden />178 </a>,179 ['sec_cik', 'cik'],180 );181 if (p.industry_labels?.length) push('industry_labels', 'Industry (Wikidata)', p.industry_labels.join(', '), ['industry_labels', 'industries']);182 if (p.phone) push('phone', 'Phone', <span className="mono text-xs">{p.phone}</span>);183 if (p.wikipedia_url || p.wikidata_url)184 push(185 'references',186 'References',187 <span className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs">188 {p.wikipedia_url && (189 <a href={p.wikipedia_url} target="_blank" rel="noopener nofollow noreferrer" className="link inline-flex items-center gap-0.5">190 Wikipedia <ArrowUpRight className="size-3" aria-hidden />191 </a>192 )}193 {p.wikidata_url && (194 <a href={p.wikidata_url} target="_blank" rel="noopener nofollow noreferrer" className="link inline-flex items-center gap-0.5">195 Wikidata <ArrowUpRight className="size-3" aria-hidden />196 </a>197 )}198 </span>,199 ['wikipedia_url', 'wikidata_url'],200 );201 }202 // extra flattened facts not already rendered from the structured profile203 for (const f of c.facts ?? []) {204 if (covered.has(f.key) || !f.value) continue;205 rows.push({ key: `fact:${f.key}`, label: f.label || f.key, value: f.value, source: f.source, url: f.url, retrievedAt: f.retrieved_at });206 covered.add(f.key);207 }208 return rows;209}210211/** Overview → first block. Hides null facts; each row carries a source chip whose tooltip is the retrieval time. */212export function KeyFactsPanel({ c, className }: { c: CompanyDetail; className?: string }) {213 const rows = buildFactRows(c);214 const enriched = c.profile?.enriched_at ?? null;215 return (216 <section className={className} data-key-facts>217 <div className="mb-2 flex items-baseline justify-between gap-2">218 <p className="eyebrow">Key facts</p>219 {enriched && <span className="text-[11px] text-ink-3">enriched {fmtDate(enriched)}</span>}220 </div>221 {rows.length === 0 ? (222 <Empty compact title="No sourced facts yet — enrichment pending.">223 Facts appear here once Wikidata, Wikipedia or registry lookups have been reconciled for this company.224 </Empty>225 ) : (226 <dl className="border-y border-rule text-sm">227 {rows.map((r) => (228 <div key={r.key} className="grid grid-cols-[6.5rem_minmax(0,1fr)_auto] items-baseline gap-x-3 border-b border-rule py-1.5 last:border-b-0 sm:grid-cols-[7.5rem_minmax(0,1fr)_auto]">229 <dt className="text-[13px] text-ink-3">{r.label}</dt>230 <dd className="min-w-0 break-words text-ink">{r.value}</dd>231 <dd className="shrink-0 self-center">232 <SourceChip source={r.source} url={r.url} retrievedAt={r.retrievedAt} />233 </dd>234 </div>235 ))}236 </dl>237 )}238 {rows.length > 0 && <Note className="mt-2">Facts are quoted from their source as retrieved; figures are not converted or adjusted, and a missing fact is simply not shown.</Note>}239 </section>240 );241}242243/* ------------------------------------------------------------------------------------------- corporate structure */244function validity(r: Relationship): string | null {245 if (r.valid_from && r.valid_to) return `${fmtDate(r.valid_from)} – ${fmtDate(r.valid_to)}`;246 if (r.valid_from) return `since ${fmtDate(r.valid_from)}`;247 if (r.valid_to) return `until ${fmtDate(r.valid_to)}`;248 return null;249}250251function RelationshipRow({ r, showKind = false }: { r: Relationship; showKind?: boolean }) {252 const name = relationshipName(r);253 const v = validity(r);254 const ended = !!r.valid_to;255 return (256 <li className={cn('flex flex-wrap items-center gap-x-2 gap-y-1 py-1.5 text-sm', ended && 'text-ink-2')}>257 <CompanyLogo name={name} candidates={r.company ? logoCandidates({ logo_url: r.company.logo_url ?? null }) : []} size={20} rounded="sm" />258 {r.company ? (259 <Link href={routes.company(r.company.slug)} className="min-w-0 truncate font-medium text-ink hover:text-accent">260 {name}261 </Link>262 ) : (263 <span className="min-w-0 truncate" title="Not an atlas company">264 {name}265 </span>266 )}267 {showKind && <Chip>{(r.kind ?? '').toLowerCase().replace(/_/g, ' ')}</Chip>}268 {ended && <Chip>former</Chip>}269 <span className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-ink-3">270 {v && <span className="tnum">{v}</span>}271 {typeof r.confidence === 'number' && (272 <span className="tnum" title="relationship confidence">273 {Math.round(r.confidence * 100)} %274 </span>275 )}276 <SourceChip source={r.provenance?.source} retrievedAt={null} />277 </span>278 </li>279 );280}281282/** Parent / owners / subsidiaries / holdings / acquisitions, grouped; collapsible when there are more than 8 rows. */283export function CorporateStructurePanel({ relationships, className, collapseAfter = 8 }: { relationships: Relationship[]; className?: string; collapseAfter?: number }) {284 const rels = relationships ?? [];285 const groups = groupRelationships(rels);286 const total = rels.length;287 const body = (288 <div className="space-y-4">289 {groups.map(({ group, rows }) => (290 <div key={group.id}>291 <p className="mb-0.5 flex items-baseline gap-2 text-[11px] font-semibold uppercase tracking-[0.1em] text-ink-3" title={group.hint}>292 {group.label} <span className="tnum font-normal normal-case tracking-normal">({rows.length})</span>293 </p>294 <ul className="divide-y divide-rule border-y border-rule">295 {rows.map((r, i) => (296 <RelationshipRow key={`${r.kind}:${r.company?.slug ?? r.to_name ?? i}:${r.valid_from ?? ''}`} r={r} showKind={group.id === 'other'} />297 ))}298 </ul>299 </div>300 ))}301 <Note>Structure as recorded by the cited sources (Wikidata statements, registries, first-party pages). “Former” marks a relationship with a recorded end date; the platform does not infer ownership changes.</Note>302 </div>303 );304 return (305 <section className={className} data-corporate-structure>306 <p className="eyebrow mb-2">Corporate structure</p>307 {total === 0 ? (308 <Empty compact title="No corporate relationships recorded yet." />309 ) : total > collapseAfter ? (310 <details className="group">311 <summary className="flex cursor-pointer list-none items-center gap-2 border-y border-rule py-2 text-sm text-ink-2 hover:text-ink [&::-webkit-details-marker]:hidden">312 <ChevronDown className="size-4 transition-transform group-open:rotate-180" aria-hidden />313 <span>314 {fmtInt(total)} recorded {plural(total, 'relationship')} · {groups.map((g) => `${g.rows.length} ${g.group.label.toLowerCase()}`).join(' · ')}315 </span>316 </summary>317 <div className="pt-3">{body}</div>318 </details>319 ) : (320 body321 )}322 </section>323 );324}325326/* ---------------------------------------------------------------------------------------------- products (Wikidata) */327/** Fallback for the Products tab when no catalogue surface has been reconciled: products/services as stated on Wikidata. */328export function WikidataProducts({ products, source, className }: { products: string[]; source?: { url: string | null; retrieved_at: string } | null; className?: string }) {329 if (!products?.length) return null;330 return (331 <section className={className} data-wikidata-products>332 <p className="eyebrow mb-2 flex flex-wrap items-center gap-2">333 Products & services listed on Wikidata <span className="tnum normal-case tracking-normal">({products.length})</span>334 <SourceChip source="wikidata" url={source?.url ?? null} retrievedAt={source?.retrieved_at ?? null} />335 </p>336 <ul className="flex flex-wrap gap-1.5">337 {products.map((p) => (338 <li key={p}>339 <Chip tone="neutral" className="font-normal">340 {p}341 </Chip>342 </li>343 ))}344 </ul>345 <Note className="mt-2">These names come from Wikidata “product or material produced” statements — not from a monitored catalogue page, so they carry no first-seen / last-seen dates.</Note>346 </section>347 );348}349350export type { CompanyFact };351