1import Link from "next/link";2import type { ReactNode } from "react";3import { cls, dateTimeFr, relTime } from "@/lib/format";45export function SectionHeader({ eyebrow, title, description, action, id, className }: { eyebrow?: string; title: string; description?: ReactNode; action?: ReactNode; id?: string; className?: string }) {6 return (7 <div id={id} className={cls("flex flex-wrap items-end justify-between gap-3 mb-4 scroll-mt-20", className)}>8 <div className="min-w-0">9 {eyebrow && <div className="eyebrow mb-1">{eyebrow}</div>}10 <h2 className="text-[22px] md:text-[26px] font-bold tracking-tight leading-tight">{title}</h2>11 {description && <p className="text-ink-2 text-[14px] mt-1 max-w-2xl leading-relaxed">{description}</p>}12 </div>13 {action && <div className="shrink-0">{action}</div>}14 </div>15 );16}1718export function Card({ children, className, pad = true }: { children: ReactNode; className?: string; pad?: boolean }) {19 return <div className={cls("card", pad && "p-4 md:p-5", className)}>{children}</div>;20}2122export function Metric({ label, value, sub, delta, className, size = "md", color }: { label: string; value: ReactNode; sub?: ReactNode; delta?: ReactNode; className?: string; size?: "sm" | "md" | "lg" | "xl"; color?: string }) {23 const vs = { sm: "text-xl", md: "text-[28px]", lg: "text-[40px]", xl: "text-[64px] md:text-[88px]" }[size];24 return (25 <div className={cls("min-w-0", className)}>26 <div className="eyebrow">{label}</div>27 <div className={cls("num display mt-1", vs)} style={color ? { color } : undefined}>{value}</div>28 {(sub || delta) && (29 <div className="flex items-baseline gap-2 mt-1 text-[13px] text-ink-2">30 {delta}31 {sub && <span>{sub}</span>}32 </div>33 )}34 </div>35 );36}3738export function MetricDelta({ value, digits = 1, suffix = " pt", invert = false }: { value: number | null | undefined; digits?: 0 | 1; suffix?: string; invert?: boolean }) {39 if (value === null || value === undefined) return null;40 const eps = digits === 1 ? 0.05 : 0.5;41 const zero = Math.abs(value) < eps;42 const up = value > 0;43 const good = invert ? !up : up;44 const txt = zero ? "0" : (up ? "+" : "−") + Math.abs(value).toFixed(digits).replace(".", ",");45 return (46 <span className={cls("num inline-flex items-center gap-0.5 font-semibold text-[12.5px] px-1.5 py-[1px] rounded-[5px]", zero ? "text-ink-3 bg-surface-2" : good ? "text-ok bg-[rgba(21,128,61,.09)]" : "text-live bg-[rgba(220,38,38,.08)]")} aria-label={`variation ${txt}${suffix}`}>47 {!zero && (up ? "▲" : "▼")} {txt}{suffix}48 </span>49 );50}5152export function Badge({ children, tone = "neutral", className, title }: { children: ReactNode; tone?: "neutral" | "accent" | "live" | "ok" | "warn" | "ink"; className?: string; title?: string }) {53 const t = {54 neutral: "bg-surface-2 text-ink-2 border-border",55 accent: "bg-accent-soft text-accent border-transparent",56 live: "bg-[rgba(220,38,38,.08)] text-live border-transparent",57 ok: "bg-[rgba(21,128,61,.09)] text-ok border-transparent",58 warn: "bg-[rgba(180,83,9,.1)] text-warn border-transparent",59 ink: "bg-brand text-white border-transparent",60 }[tone];61 return <span title={title} className={cls("inline-flex items-center gap-1 rounded-[6px] border px-2 py-[3px] text-[11px] font-semibold tracking-wide uppercase", t, className)}>{children}</span>;62}6364export function LiveBadge({ label = "Direct" }: { label?: string }) {65 return (66 <Badge tone="live"><span className="live-dot" /> {label}</Badge>67 );68}69export function ProjectionBadge({ children = "Projection QC26" }: { children?: ReactNode }) {70 return <Badge tone="accent">{children}</Badge>;71}72export function OfficialBadge({ children = "Officiel" }: { children?: ReactNode }) {73 return <Badge tone="ok">{children}</Badge>;74}75export function ConfidenceBadge({ value }: { value: number | null | undefined }) {76 if (value === null || value === undefined) return null;77 const tone = value >= 0.85 ? "ok" : value >= 0.6 ? "neutral" : "warn";78 return <Badge tone={tone} title="Confiance de l'extraction (validation schéma + arithmétique + concordance)">Confiance {Math.round(value * 100)} %</Badge>;79}8081export function Timestamp({ iso, prefix = "Mise à jour", className, relative = false }: { iso: string | null | undefined; prefix?: string; className?: string; relative?: boolean }) {82 if (!iso) return null;83 return (84 <time dateTime={iso} className={cls("text-[12px] text-ink-3 num", className)} title={dateTimeFr(iso)}>85 {prefix} {relative ? relTime(iso) : dateTimeFr(iso)}86 </time>87 );88}8990/** Bouton « Source » : ouvre la provenance (URL originale, archive QC26, date de récupération, page). */91export function SourceBadge({ source }: { source: { name: string; url?: string | null; archiveUrl?: string | null; retrievedAt?: string | null; page?: number | null; detail?: string | null; tier?: string | null } }) {92 return (93 <details className="relative inline-block group">94 <summary className="list-none cursor-pointer inline-flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wide text-ink-2 border border-border rounded-[6px] px-2 py-[3px] hover:bg-surface-2 select-none">95 <span aria-hidden>⌕</span> Source96 </summary>97 <div className="absolute z-30 left-0 mt-2 w-[300px] max-w-[calc(100vw-2rem)] card p-3 text-[12.5px] shadow-md text-left leading-relaxed">98 <div className="font-semibold text-ink">{source.name}</div>99 {source.detail && <div className="text-ink-2 mt-1">{source.detail}</div>}100 <dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-ink-2">101 {source.tier && (<><dt>Niveau</dt><dd>{source.tier}</dd></>)}102 {source.page && (<><dt>Page</dt><dd className="num">{source.page}</dd></>)}103 {source.retrievedAt && (<><dt>Récupéré</dt><dd>{dateTimeFr(source.retrievedAt)}</dd></>)}104 {source.url && (<><dt>Original</dt><dd className="truncate"><a className="link" href={source.url} target="_blank" rel="noopener noreferrer">{source.url.replace(/^https?:\/\//, "").slice(0, 44)}…</a></dd></>)}105 {source.archiveUrl && (<><dt>Archive</dt><dd><a className="link" href={source.archiveUrl} target="_blank" rel="noopener noreferrer">copie QC26 (SHA-256)</a></dd></>)}106 </dl>107 </div>108 </details>109 );110}111112export function ProbabilityBar({ p, color, label, height = 10, showValue = true, className }: { p: number; color: string; label?: string; height?: number; showValue?: boolean; className?: string }) {113 const v = Math.max(0, Math.min(1, p));114 return (115 <div className={cls("flex items-center gap-3", className)} role="img" aria-label={`${label ?? "probabilité"} ${Math.round(v * 100)} %`}>116 <div className="flex-1 rounded-full overflow-hidden bg-surface-3" style={{ height }}>117 <div className="h-full rounded-full transition-[width] duration-500" style={{ width: `${v * 100}%`, background: color }} />118 </div>119 {showValue && <span className="num font-semibold text-[13px] w-12 text-right">{v > 0.995 ? "> 99" : v < 0.005 ? "< 1" : Math.round(v * 100)} %</span>}120 </div>121 );122}123124export function Pill({ href, children, active, className }: { href: string; children: ReactNode; active?: boolean; className?: string }) {125 return (126 <Link href={href} className={cls("inline-flex items-center rounded-full px-3 py-1.5 text-[13px] font-medium border transition-colors", active ? "bg-brand text-white border-brand" : "bg-surface border-border hover:bg-surface-2", className)}>127 {children}128 </Link>129 );130}131132export function Empty({ title, children }: { title: string; children?: ReactNode }) {133 return (134 <div className="card p-8 text-center">135 <div className="font-semibold">{title}</div>136 {children && <div className="text-ink-2 text-[14px] mt-1">{children}</div>}137 </div>138 );139}140141export function Disclaimer({ text }: { text?: string }) {142 return (143 <p className="text-[12.5px] text-ink-3 leading-relaxed max-w-3xl">144 {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."}145 </p>146 );147}148149export function KV({ items, className }: { items: [ReactNode, ReactNode][]; className?: string }) {150 return (151 <dl className={cls("grid grid-cols-[minmax(90px,auto)_1fr] gap-x-4 gap-y-1.5 text-[13.5px]", className)}>152 {items.map(([k, v], i) => (153 <div key={i} className="contents">154 <dt className="text-ink-2">{k}</dt>155 <dd className="num min-w-0 break-words">{v}</dd>156 </div>157 ))}158 </dl>159 );160}161