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 (
{prefix} {relative ? relTime(iso) : dateTimeFr(iso)}
);
}
/** 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
);
}
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 (
);
}