import type { Metadata } from 'next'; import Link from 'next/link'; import { LineChart } from '@/components/charts/line-chart'; import { Sparkline } from '@/components/charts/sparkline'; import { CompanyLogo } from '@/components/company/company-logo'; import { ComparePicker } from '@/components/company/compare-picker'; import { CountryChip, EventTypeBadge } from '@/components/ui/badges'; import { Container, Empty, Note, PageHeader, Section } from '@/components/ui/section'; import { api, safe } from '@/lib/api'; import { cn } from '@/lib/cn'; import { EVENT_TYPES } from '@/lib/event-styles'; import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format'; import { str, type SP } from '@/lib/params'; import { logoCandidates } from '@/lib/profile'; import { METRIC_LABELS, routes } from '@/lib/site'; export const metadata: Metadata = { title: 'Compare companies', description: 'Side-by-side activity, hiring momentum, product velocity, AI adoption, locations and events for 2–6 monitored companies.' }; export const revalidate = 120; const METRICS = ['activity_score', 'hiring_momentum_30d', 'product_velocity', 'ai_adoption', 'geo_expansion', 'developer_momentum', 'corporate_change_index', 'open_jobs'] as const; export default async function ComparePage({ searchParams }: { searchParams: Promise }) { const sp = await searchParams; const slugs = (str(sp.companies) ?? '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 6); const data = slugs.length >= 2 ? await safe(api.compare(slugs)) : null; const names: Record = {}; if (data) for (const c of data.companies) names[c.slug] = c.display_name; else if (slugs.length === 1) { const c = await safe(api.company(slugs[0] as string)); if (c) names[c.slug] = c.display_name; } const cols = data?.companies ?? []; return ( {slugs.length < 2 ? ( Try Stripe · Adyen · Block. ) : !data ? ( ) : ( <>
{cols.map((c) => ( ))} {METRICS.map((m) => { const vals = cols.map((c) => num(data.metrics[m]?.[c.slug] ?? c.metrics[m])); const best = m === 'hiring_momentum_30d' || m === 'open_jobs' ? Math.max(...vals.map((v) => v ?? -Infinity)) : Math.max(...vals.map((v) => v ?? -Infinity)); return ( {vals.map((v, i) => ( ))} ); })} {cols.map((c) => ( ))} {cols.map((c) => { const j = data.jobs[c.slug]; return ( ); })} {cols.map((c) => ( ))} {cols.map((c) => ( ))}
Metric {c.display_name} {c.industry_primary?.replace(/-/g, ' ')}
{METRIC_LABELS[m]} 1 && 'font-semibold text-accent', m === 'hiring_momentum_30d' && v !== null && (v > 0 ? 'text-positive' : v < 0 ? 'text-danger' : ''))}> {v === null ? '—' : m === 'hiring_momentum_30d' ? fmtPctSigned(v) : m === 'open_jobs' ? fmtInt(v) : fmtScore(v)}
Sensors · events {fmtInt(c.counts.sensors)} · {fmtInt(c.counts.events)}
Jobs (open · AI · new 30 d) {j ? `${fmtInt(j.open)} · ${fmtInt(j.ai_open)} · ${fmtInt(j.new_30d)}` : '—'}
Listed locations {fmtInt(data.locations[c.slug])}
Activity 30 d
Highlighted = highest value in the row. A dash means the metric has no inputs yet for that company — it is omitted, not zero.
data.series[c.slug]?.length).map((c) => ({ id: c.slug, label: c.display_name, points: (data.series[c.slug] ?? []).map((p) => ({ day: p.day, value: p.value })) }))} height={220} yZero />
{cols.map((c) => ( ))} {EVENT_TYPES.filter((t) => cols.some((c) => (data.events_30d[c.slug]?.[t] ?? 0) > 0)).map((t) => ( {cols.map((c) => ( ))} ))}
Type {c.display_name}
{data.events_30d[c.slug]?.[t] ? {fmtInt(data.events_30d[c.slug]?.[t])} : ·}
)}
); }