146 lines
6.1 KiB
TypeScript
146 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
// =============================================================================
|
|
// CookieBanner
|
|
// =============================================================================
|
|
//
|
|
// A small, non-blocking cookie / consent banner shown on first visit.
|
|
// Two buttons: ACK (accept) / RST (reject) — network-engineer humour.
|
|
//
|
|
// Behaviour:
|
|
// - On first load, if no consent decision is stored, the banner appears.
|
|
// - "ACK" stores `novarix-cookie-consent = "ack"` in localStorage and the
|
|
// banner closes. The site then behaves as before (intro animation
|
|
// remembers it has played using sessionStorage).
|
|
// - "RST" stores `novarix-cookie-consent = "rst"` in localStorage and the
|
|
// banner closes. We also clear the intro-seen flag so we don't keep any
|
|
// non-consent storage around.
|
|
// - The footer "Cookie preferences" link dispatches a window event that
|
|
// re-opens this banner so users can change their mind.
|
|
//
|
|
// All visible text comes from `/content.ts` -> site.cookies.
|
|
// =============================================================================
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { site } from "@/content";
|
|
|
|
const CONSENT_KEY = "novarix-cookie-consent";
|
|
const INTRO_SEEN_KEY = "novarix-intro-seen";
|
|
const REOPEN_EVENT = "novarix:open-cookie-banner";
|
|
const INTRO_EVENT = "novarix:start-intro";
|
|
|
|
type Consent = "unknown" | "ack" | "rst";
|
|
|
|
export default function CookieBanner() {
|
|
// Start from the pre-hydration decision made in layout.tsx so the banner
|
|
// can appear immediately on first visit without waiting for a delayed effect.
|
|
const [visible, setVisible] = useState(() => {
|
|
if (typeof document === "undefined") return false;
|
|
return document.documentElement.dataset.showCookieBanner === "1";
|
|
});
|
|
|
|
// Read the stored consent on mount and decide whether to show the banner.
|
|
useEffect(() => {
|
|
try {
|
|
const stored = window.localStorage.getItem(CONSENT_KEY) as Consent | null;
|
|
if (stored !== "ack" && stored !== "rst") {
|
|
document.documentElement.dataset.showCookieBanner = "1";
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setVisible(true);
|
|
} else {
|
|
document.documentElement.dataset.showCookieBanner = "0";
|
|
}
|
|
} catch {
|
|
// localStorage unavailable (private mode, blocked, etc.) — show the
|
|
// banner so the user is still told. Sync-once-on-mount is a legitimate
|
|
// use of setState in an effect.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
document.documentElement.dataset.showCookieBanner = "1";
|
|
setVisible(true);
|
|
}
|
|
}, []);
|
|
|
|
// Listen for the "re-open" event from the footer "Cookie preferences" link.
|
|
useEffect(() => {
|
|
const handler = () => setVisible(true);
|
|
window.addEventListener(REOPEN_EVENT, handler);
|
|
return () => window.removeEventListener(REOPEN_EVENT, handler);
|
|
}, []);
|
|
|
|
function decide(choice: "ack" | "rst") {
|
|
try {
|
|
window.localStorage.setItem(CONSENT_KEY, choice);
|
|
if (choice === "rst") {
|
|
// Honour the rejection by clearing any non-consent storage.
|
|
window.sessionStorage.removeItem(INTRO_SEEN_KEY);
|
|
} else {
|
|
window.dispatchEvent(new Event(INTRO_EVENT));
|
|
}
|
|
} catch {
|
|
/* storage unavailable — nothing to do */
|
|
}
|
|
document.documentElement.dataset.showCookieBanner = "0";
|
|
setVisible(false);
|
|
}
|
|
|
|
if (!visible) return null;
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-live="polite"
|
|
aria-label="Cookie consent"
|
|
className="fixed inset-x-0 bottom-0 z-[2500] flex justify-center p-3 sm:p-5"
|
|
>
|
|
<div className="pointer-events-auto w-full max-w-3xl rounded-2xl border border-[var(--border-strong)] bg-[var(--surface-strong)] p-4 shadow-[0_24px_60px_-20px_oklch(0_0_0/0.45)] backdrop-blur-xl sm:p-5">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
|
|
{/* Message + policy link */}
|
|
<p className="text-sm leading-relaxed text-[var(--text)]">
|
|
{site.cookies.message}{" "}
|
|
<Link
|
|
href={site.cookies.policyHref}
|
|
className="font-medium text-[var(--accent)] underline-offset-4 hover:underline"
|
|
>
|
|
{site.cookies.policyLabel} →
|
|
</Link>
|
|
</p>
|
|
|
|
{/* Buttons */}
|
|
<div className="flex flex-shrink-0 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => decide("rst")}
|
|
aria-label={site.cookies.reject.ariaLabel}
|
|
title={site.cookies.reject.tooltip}
|
|
className="group inline-flex h-11 min-w-[5.5rem] flex-col items-center justify-center rounded-xl border border-[var(--border-strong)] bg-transparent px-4 font-mono text-sm font-semibold text-[var(--text)] transition-all hover:-translate-y-0.5 hover:border-[var(--text)]"
|
|
>
|
|
<span className="leading-none">{site.cookies.reject.label}</span>
|
|
<span className="mt-0.5 text-[0.65rem] font-normal tracking-wide text-[var(--text-soft)] uppercase">
|
|
{site.cookies.reject.subtitle}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => decide("ack")}
|
|
aria-label={site.cookies.accept.ariaLabel}
|
|
title={site.cookies.accept.tooltip}
|
|
className="group inline-flex h-11 min-w-[5.5rem] flex-col items-center justify-center rounded-xl bg-[var(--button-bg)] px-4 font-mono text-sm font-semibold text-[var(--button-fg)] shadow-sm transition-all hover:-translate-y-0.5 hover:bg-[var(--button-hover)] hover:shadow-md"
|
|
>
|
|
<span className="leading-none">{site.cookies.accept.label}</span>
|
|
<span className="mt-0.5 text-[0.65rem] font-normal tracking-wide opacity-70 uppercase">
|
|
{site.cookies.accept.subtitle}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Helper used by the footer "Cookie preferences" link to re-open the banner.
|
|
export function openCookieBanner() {
|
|
window.dispatchEvent(new Event(REOPEN_EVENT));
|
|
}
|