import type { ReactNode } from 'react' import { cn } from '@sim/emcn' import type { CompetitorProfile } from '@/lib/compare/data' import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections' import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile' import { FactValue } from '@/app/(landing)/comparison/components/fact-value' export interface ComparisonTableProps { sim: CompetitorProfile competitor: CompetitorProfile } /** * Pins the row-label column during horizontal scroll on genuinely spacious * viewports (the standard pattern for responsive data tables, e.g. * Stripe/GitHub/Notion comparison tables) so a reader keeps row context while * scrolling to see the Sim/competitor values. Below `lg` (this page's own * tablet-and-below tier, per `.claude/rules` for this route group) the table * switches to a stacked layout instead (see `MOBILE_STACK_*`) rather than * relying on horizontal scroll at a width too narrow to render a 3-column * table comfortably, so sticky positioning is scoped to `lg:` only. The * shadow is a permanent CSS-only affordance (no scroll-position JS) so this * stays a zero-hydration server component. */ const STICKY_LABEL_COL = 'lg:sticky lg:left-0 lg:z-10 lg:shadow-[2px_0_4px_-2px_rgba(0,0,0,0.08)]' /** * Below `lg` (1024px) a 3-column grid doesn't reliably have room to be * legible, so each fact instead stacks as label -> Sim's value -> the * competitor's value, with a small name tag on each value since the column * headers are no longer directly above. Applied to the label cell. */ const MOBILE_STACK_LABEL = 'max-lg:border-r-0 max-lg:border-b-0 max-lg:pt-3 max-lg:pb-1' /** * Applied to a value cell (Sim's or the competitor's) in the stacked mobile * layout. `items-stretch` overrides the cell's base `items-center` (which * would otherwise shrink-wrap and center each child horizontally in a * flex-col): stretching gives the name tag and the value their own * full-width box to left-align and truncate within, instead of a * content-sized box with no boundary to clip against. */ const MOBILE_STACK_VALUE = 'max-lg:flex-col max-lg:items-stretch max-lg:gap-0.5 max-lg:border-r-0 max-lg:px-4' function ColumnHeader({ name, iconTile, isSim, }: { name: string iconTile: ReactNode isSim: boolean }) { return (
{iconTile} {name}
) } /** * Two-column "Sim vs {Competitor}" fact table, styled after the billing * upgrade-page comparison table (same border/hairline rhythm and section * headers) but data-driven off {@link CompetitorProfile.facts} instead of the * fixed 4-tier plan schema. Data cells share one neutral surface for both * columns. The Sim column is called out only in the header row (a bottom * accent border), so the table reads as one clean grid rather than a * checkerboard. Pure server component: every value is plain server-rendered * text so crawlers and AI answer engines read the full comparison without * any client-side hydration. */ export function ComparisonTable({ sim, competitor }: ComparisonTableProps) { return (
Compare {sim.name} vs {competitor.name}
} isSim /> ) : null } isSim={false} />
{COMPARISON_SECTIONS.map((section, sectionIdx) => { const simGroupFacts = getFactGroup(sim, section.group) const competitorGroupFacts = getFactGroup(competitor, section.group) return (
0 && 'border-[var(--border-1)] border-t' )} > {section.title}
0 && 'border-[var(--border-1)] border-t' )} />
{section.rows.map((row, rowIdx) => { const simFact = simGroupFacts[row.key] const competitorFact = competitorGroupFacts[row.key] const isNotLastRow = rowIdx < section.rows.length - 1 return (
{row.label}
{sim.name}
{competitor.name}
) })}
) })}
) }