import Link from "next/link"; import type { ReactNode } from "react"; import { cls, dateTimeFr, relTime } from "@/lib/format"; export function SectionHeader({ eyebrow, title, description, action, id, className, num }: { eyebrow?: string; title: string; description?: ReactNode; action?: ReactNode; id?: string; className?: string; num?: string }) { return (
{(eyebrow || num) &&
{num && {num}}{eyebrow && {eyebrow}}
}

{title}

{description &&

{description}

}
{action &&
{action}
}
); } export function PageTitle({ eyebrow, title, lede, action, children }: { eyebrow?: ReactNode; title: ReactNode; lede?: ReactNode; action?: ReactNode; children?: ReactNode }) { return (
{eyebrow &&
{eyebrow}
}

{title}

{lede &&

{lede}

} {children}
{action &&
{action}
}
); } export function Card({ children, className, pad = true }: { children: ReactNode; className?: string; pad?: boolean }) { return
{children}
; } export function Metric({ label, value, sub, delta, className, size = "md", color, serif = true }: { label: string; value: ReactNode; sub?: ReactNode; delta?: ReactNode; className?: string; size?: "sm" | "md" | "lg" | "xl"; color?: string; serif?: boolean }) { const vs = { sm: "text-[22px]", md: "text-[30px]", lg: "text-[44px]", xl: "text-[72px] md:text-[96px]" }[size]; return (
{label}
{value}
{(sub || delta) && (
{delta} {sub && {sub}}
)}
); } export function MetricDelta({ value, digits = 1, suffix = " pt", invert = false }: { value: number | null | undefined; digits?: 0 | 1; suffix?: string; invert?: boolean }) { if (value === null || value === undefined) return null; const eps = digits === 1 ? 0.05 : 0.5; const zero = Math.abs(value) < eps; const up = value > 0; const good = invert ? !up : up; const txt = zero ? "0" : (up ? "+" : "−") + Math.abs(value).toFixed(digits).replace(".", ","); return ( {!zero && (up ? "▲" : "▼")} {txt}{suffix} ); } export function Badge({ children, tone = "neutral", className, title }: { children: ReactNode; tone?: "neutral" | "accent" | "live" | "ok" | "warn" | "ink"; className?: string; title?: string }) { const t = { neutral: "bg-surface-2 text-ink-2 border-border", accent: "bg-accent-soft text-accent border-transparent", live: "bg-live-soft text-live border-transparent", ok: "bg-ok-soft text-ok border-transparent", warn: "bg-warn-soft text-warn border-transparent", ink: "bg-brand text-ink-inverse border-transparent", }[tone]; return {children}; } export function LiveBadge({ label = "Direct" }: { label?: string }) { return ( {label} ); } export function ProjectionBadge({ children = "Projection QC26" }: { children?: ReactNode }) { return {children}; } export function OfficialBadge({ children = "Officiel" }: { children?: ReactNode }) { return {children}; } export function ConfidenceBadge({ value }: { value: number | null | undefined }) { if (value === null || value === undefined) return null; const tone = value >= 0.85 ? "ok" : value >= 0.6 ? "neutral" : "warn"; return Confiance {Math.round(value * 100)} %; } export function Timestamp({ iso, prefix = "Mise à jour", className, relative = false }: { iso: string | null | undefined; prefix?: string; className?: string; relative?: boolean }) { if (!iso) return null; return ( ); } /** Bouton « Source » : ouvre la provenance (URL originale, archive QC26, date de récupération, page). */ export function SourceBadge({ source }: { source: { name: string; url?: string | null; archiveUrl?: string | null; retrievedAt?: string | null; page?: number | null; detail?: string | null; tier?: string | null } }) { return (
Source
{source.name}
{source.detail &&
{source.detail}
}
{source.tier && (<>
Niveau
{source.tier}
)} {source.page && (<>
Page
{source.page}
)} {source.retrievedAt && (<>
Récupéré
{dateTimeFr(source.retrievedAt)}
)} {source.url && (<>
Original
{source.url.replace(/^https?:\/\//, "").slice(0, 44)}…
)} {source.archiveUrl && (<>
Archive
copie QC26 (SHA-256)
)}
); } export function ProbabilityBar({ p, color, label, height = 10, showValue = true, className }: { p: number; color: string; label?: string; height?: number; showValue?: boolean; className?: string }) { const v = Math.max(0, Math.min(1, p)); return (
{showValue && {v > 0.995 ? "> 99" : v < 0.005 ? "< 1" : Math.round(v * 100)} %}
); } export function Pill({ href, children, active, className }: { href: string; children: ReactNode; active?: boolean; className?: string }) { return ( {children} ); } export function Empty({ title, children }: { title: string; children?: ReactNode }) { return (
{title}
{children &&
{children}
}
); } export function Disclaimer({ text }: { text?: string }) { return (

{text ?? "QC26 est une plateforme indépendante d'analyse électorale. Les projections sont des estimations probabilistes et non des prédictions certaines. QC26 n'est affilié à aucun parti politique ni à Élections Québec."}

); } export function KV({ items, className }: { items: [ReactNode, ReactNode][]; className?: string }) { return (
{items.map(([k, v], i) => (
{k}
{v}
))}
); } /** Petite étiquette « signal » : nom de la source, fraîcheur, statut. */ export function SourceChip({ name, at, tone = "neutral" }: { name: string; at?: string | null; tone?: "neutral" | "ok" | "warn" }) { return ( {name}{at && · {relTime(at)}} ); } /** Rend une variante étroite (mobile) et une variante large (≥ md) : utile pour les SVG à viewBox fixe. */ export function Dual({ narrow, wide, className }: { narrow: ReactNode; wide: ReactNode; className?: string }) { return ( <>
{narrow}
{wide}
); } /** Rangée mobile « clé → valeur » compacte pour remplacer une ligne de tableau. */ export function StatRow({ label, value, sub, className }: { label: ReactNode; value: ReactNode; sub?: ReactNode; className?: string }) { return (
{label}
{sub &&
{sub}
}
{value}
); }