SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
15.8 KB · 351 lines tsx
Raw Blame History
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 &amp; 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