1"use client";23import Link from "next/link";4import { usePathname, useRouter } from "next/navigation";5import { useCallback, useEffect, useRef, useState } from "react";6import { cls, daysLabel } from "@/lib/format";7import { partyLabel } from "@/lib/parties";89const NAV = [10 { href: "/prevision", label: "Prévision" },11 { href: "/sondages", label: "Sondages" },12 { href: "/carte", label: "Carte" },13 { href: "/circonscriptions", label: "Circonscriptions" },14 { href: "/boussole", label: "Boussole" },15 { href: "/partis", label: "Partis" },16 { href: "/enjeux", label: "Enjeux" },17 { href: "/methodologie", label: "Méthodologie" },18];1920export function Wordmark({ className, size = 22 }: { className?: string; size?: number }) {21 return (22 <span className={cls("inline-flex items-baseline font-bold tracking-tight select-none", className)} style={{ fontSize: size, letterSpacing: "-0.04em" }} aria-label="QC26">23 <span>QC</span>24 <span className="mx-[3px] inline-block w-[2px] self-stretch bg-accent rounded-sm translate-y-[1px]" style={{ height: size * 0.82 }} aria-hidden />25 <span className="text-accent">26</span>26 </span>27 );28}2930export function TopNav({ daysToElection, mode }: { daysToElection: number; mode: "forecast" | "live" | "archive" }) {31 const path = usePathname();32 const [paletteOpen, setPaletteOpen] = useState(false);33 useEffect(() => {34 const onKey = (e: KeyboardEvent) => {35 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setPaletteOpen((v) => !v); }36 if (e.key === "Escape") setPaletteOpen(false);37 };38 window.addEventListener("keydown", onKey);39 return () => window.removeEventListener("keydown", onKey);40 }, []);41 const liveNav = mode !== "forecast" ? [{ href: "/live", label: mode === "live" ? "Direct" : "Résultats" }] : [];42 return (43 <>44 <header className="sticky top-0 z-40 bg-[rgba(247,248,250,.85)] backdrop-blur-md border-b border-border" style={{ height: "var(--header-h)" }}>45 <div className="mx-auto max-w-[1280px] h-full px-4 md:px-6 flex items-center gap-4">46 <Link href="/" className="shrink-0 flex items-center" aria-label="QC26 — accueil"><Wordmark /></Link>47 <nav className="hidden lg:flex items-center gap-0.5 ml-4" aria-label="Navigation principale">48 {[...liveNav, ...NAV].map((n) => {49 const active = path === n.href || (n.href !== "/" && path.startsWith(n.href));50 return (51 <Link key={n.href} href={n.href} className={cls("px-3 py-1.5 rounded-[7px] text-[13.5px] font-medium transition-colors", active ? "bg-brand text-white" : "text-ink-2 hover:text-ink hover:bg-surface-2", n.href === "/live" && !active && "text-live")}>52 {n.href === "/live" && mode === "live" ? <span className="inline-flex items-center gap-1.5"><span className="live-dot" />DIRECT</span> : n.label}53 </Link>54 );55 })}56 </nav>57 <div className="ml-auto flex items-center gap-2">58 <button onClick={() => setPaletteOpen(true)} className="hidden md:inline-flex items-center gap-2 text-[12.5px] text-ink-2 border border-border rounded-[7px] px-2.5 py-1.5 hover:bg-surface-2" aria-label="Rechercher (⌘K)">59 <span aria-hidden>⌕</span> Rechercher <kbd className="mono text-[10.5px] border border-border rounded px-1 text-ink-3">⌘K</kbd>60 </button>61 <button onClick={() => setPaletteOpen(true)} className="md:hidden w-9 h-9 inline-flex items-center justify-center rounded-[7px] border border-border" aria-label="Rechercher">⌕</button>62 <ElectionChip days={daysToElection} mode={mode} />63 </div>64 </div>65 </header>66 {paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}67 </>68 );69}7071function ElectionChip({ days, mode }: { days: number; mode: string }) {72 if (mode === "live") return <Link href="/live" className="inline-flex items-center gap-1.5 rounded-[7px] bg-live text-white px-2.5 py-1.5 text-[12px] font-bold tracking-wide"><span className="live-dot !bg-white" /> DIRECT</Link>;73 if (mode === "archive") return <span className="inline-flex items-center rounded-[7px] bg-brand text-white px-2.5 py-1.5 text-[11.5px] font-bold tracking-wide uppercase">Archive · 5 oct. 2026</span>;74 return (75 <span className="inline-flex items-center gap-2 rounded-[7px] border border-border bg-surface px-2.5 py-1.5 text-[11.5px] font-semibold tracking-wide uppercase text-ink-2">76 <span className="hidden sm:inline">Élection · 5 oct. 2026</span>77 <span className="num text-ink font-bold">{daysLabel(days)}</span>78 </span>79 );80}8182export function MobileNav({ mode }: { mode: "forecast" | "live" | "archive" }) {83 const path = usePathname();84 const items = [85 { href: "/", label: "Accueil", icon: "◉" },86 mode === "forecast" ? { href: "/prevision", label: "Prévision", icon: "◭" } : { href: "/live", label: mode === "live" ? "Direct" : "Résultats", icon: "●" },87 { href: "/carte", label: "Carte", icon: "◫" },88 { href: "/boussole", label: "Boussole", icon: "◎" },89 { href: "/plus", label: "Plus", icon: "≡" },90 ];91 return (92 <nav className="lg:hidden fixed bottom-0 inset-x-0 z-40 bg-[rgba(255,255,255,.92)] backdrop-blur-md border-t border-border safe-bottom" style={{ height: "calc(var(--bottom-nav-h) + env(safe-area-inset-bottom))" }} aria-label="Navigation mobile">93 <div className="grid grid-cols-5 h-[60px]">94 {items.map((it) => {95 const active = it.href === "/" ? path === "/" : path.startsWith(it.href);96 const live = it.href === "/live" && mode === "live";97 return (98 <Link key={it.href} href={it.href} className={cls("flex flex-col items-center justify-center gap-0.5 text-[10.5px] font-semibold", active ? "text-brand" : "text-ink-3", live && "text-live")}>99 <span className={cls("text-[17px] leading-none", live && "live-dot !w-[10px] !h-[10px] mb-1")} aria-hidden>{live ? "" : it.icon}</span>100 {it.label}101 </Link>102 );103 })}104 </div>105 </nav>106 );107}108109interface SearchResult { ridings: { code: number; name: string; slug: string; region: string }[]; candidates: { name: string; party: string; riding: number }[]; parties: { id: string; name: string; leader: string }[]; polls: { id: string; pollster: string; date: string }[]; documents: { id: number; title: string; party: string }[]; proposals: { id: number; party: string; topic: string; proposal: string; documentId: number }[]; topics: { id: string; label: string }[] }110111export function CommandPalette({ onClose }: { onClose: () => void }) {112 const [q, setQ] = useState("");113 const [res, setRes] = useState<SearchResult | null>(null);114 const [sel, setSel] = useState(0);115 const router = useRouter();116 const ref = useRef<HTMLInputElement>(null);117 useEffect(() => { ref.current?.focus(); }, []);118 useEffect(() => {119 if (q.trim().length < 2) { setRes(null); return; }120 const ctrl = new AbortController();121 const t = setTimeout(() => {122 fetch(`/api/search?q=${encodeURIComponent(q.trim())}`, { signal: ctrl.signal }).then((r) => r.json()).then(setRes).catch(() => {});123 }, 120);124 return () => { clearTimeout(t); ctrl.abort(); };125 }, [q]);126 const items: { label: string; sub?: string; href: string }[] = [];127 const shortcuts = [128 { label: "Prévision", href: "/prevision" }, { label: "Sondages", href: "/sondages" }, { label: "Carte électorale", href: "/carte" }, { label: "Boussole électorale", href: "/boussole" },129 { label: "Comparateur des partis", href: "/partis/comparateur" }, { label: "Méthodologie", href: "/methodologie" }, { label: "Statut des données", href: "/statut" },130 ];131 if (!res) items.push(...shortcuts.filter((s) => !q || s.label.toLowerCase().includes(q.toLowerCase())));132 else {133 res.ridings.forEach((r) => items.push({ label: r.name, sub: "Circonscription", href: `/circonscription/${r.slug}` }));134 res.parties.forEach((p) => items.push({ label: p.name, sub: `Parti · ${p.leader ?? ""}`, href: `/partis/${p.id}` }));135 res.candidates.forEach((c) => items.push({ label: c.name, sub: `Candidat·e · ${partyLabel(c.party)}`, href: `/circonscription/${c.riding}` }));136 res.polls.forEach((p) => items.push({ label: `${p.pollster} · ${p.date}`, sub: "Sondage", href: `/sondages/${p.id}` }));137 res.topics.forEach((t) => items.push({ label: t.label, sub: "Enjeu", href: `/enjeux/${t.id}` }));138 res.documents.forEach((d) => items.push({ label: d.title, sub: `Document · ${partyLabel(d.party)}`, href: `/documents/${d.id}` }));139 res.proposals.forEach((p) => items.push({ label: p.proposal, sub: `Proposition · ${partyLabel(p.party)}`, href: `/documents/${p.documentId}#p${p.id}` }));140 }141 const go = useCallback((href: string) => { onClose(); router.push(href); }, [onClose, router]);142 return (143 <div className="fixed inset-0 z-50 bg-[rgba(17,19,24,.35)] backdrop-blur-[2px] flex items-start justify-center pt-[10vh] px-4" onClick={onClose} role="dialog" aria-modal aria-label="Recherche">144 <div className="w-full max-w-[620px] card card-lg overflow-hidden shadow-md" onClick={(e) => e.stopPropagation()}>145 <input ref={ref} value={q} onChange={(e) => { setQ(e.target.value); setSel(0); }} placeholder="Circonscription, candidat·e, firme, enjeu, parti…" className="w-full px-4 py-3.5 text-[16px] outline-none bg-transparent border-b border-border"146 onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(items.length - 1, s + 1)); } if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(0, s - 1)); } if (e.key === "Enter" && items[sel]) go(items[sel].href); }} />147 <ul className="max-h-[50vh] overflow-auto py-1">148 {items.length === 0 && <li className="px-4 py-6 text-ink-3 text-sm text-center">Aucun résultat.</li>}149 {items.slice(0, 30).map((it, i) => (150 <li key={it.href + i}>151 <button onClick={() => go(it.href)} onMouseEnter={() => setSel(i)} className={cls("w-full text-left px-4 py-2.5 flex items-center justify-between gap-3", i === sel && "bg-surface-2")}>152 <span className="truncate text-[14px]">{it.label}</span>153 {it.sub && <span className="text-[11.5px] text-ink-3 shrink-0">{it.sub}</span>}154 </button>155 </li>156 ))}157 </ul>158 <div className="px-4 py-2 border-t border-border text-[11px] text-ink-3 flex gap-3"><span>↑↓ naviguer</span><span>↵ ouvrir</span><span>esc fermer</span></div>159 </div>160 </div>161 );162}163