Do not decide consent during SSR. Read consent cookie after mount and only render the banner once consent is loaded, avoiding both hydration errors and the brief banner flash on reload. Co-authored-by: dennis <dennis@konkol.net>
115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { useConsent, type ConsentState } from "./ConsentProvider";
|
|
import { useTranslations } from "next-intl";
|
|
|
|
export default function ConsentBanner() {
|
|
const { consent, ready, setConsent } = useConsent();
|
|
const [draft, setDraft] = useState<ConsentState>({ analytics: false, chat: false });
|
|
const [minimized, setMinimized] = useState(false);
|
|
const t = useTranslations("consent");
|
|
|
|
// Avoid hydration mismatch + avoid "flash then disappear":
|
|
// Only decide whether to show the banner after consent has been read client-side.
|
|
const shouldShow = ready && consent === null;
|
|
if (!shouldShow) return null;
|
|
|
|
const s = {
|
|
title: t("title"),
|
|
description: t("description"),
|
|
essential: t("essential"),
|
|
analytics: t("analytics"),
|
|
chat: t("chat"),
|
|
acceptAll: t("acceptAll"),
|
|
acceptSelected: t("acceptSelected"),
|
|
rejectAll: t("rejectAll"),
|
|
};
|
|
|
|
if (minimized) {
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-[60]">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMinimized(false)}
|
|
className="px-4 py-2 rounded-full bg-white/80 backdrop-blur-xl border border-white/60 shadow-lg text-stone-800 font-semibold hover:bg-white transition-colors"
|
|
aria-label="Open privacy settings"
|
|
>
|
|
{s.title}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-[60] max-w-[calc(100vw-2rem)]">
|
|
<div className="w-[360px] max-w-full bg-white/85 backdrop-blur-xl border border-white/60 rounded-2xl shadow-[0_12px_40px_rgba(41,37,36,0.14)] p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-base font-bold text-stone-900">{s.title}</div>
|
|
<p className="text-xs text-stone-600 mt-1 leading-snug">{s.description}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMinimized(true)}
|
|
className="shrink-0 text-xs text-stone-500 hover:text-stone-900 transition-colors"
|
|
aria-label="Minimize privacy banner"
|
|
title="Minimize"
|
|
>
|
|
Hide
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-3 space-y-2">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="text-xs font-semibold text-stone-800">{s.essential}</div>
|
|
<div className="text-[11px] text-stone-500">Always on</div>
|
|
</div>
|
|
|
|
<label className="flex items-center justify-between gap-3 py-1">
|
|
<span className="text-sm font-semibold text-stone-800">{s.analytics}</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.analytics}
|
|
onChange={(e) => setDraft((p) => ({ ...p, analytics: e.target.checked }))}
|
|
className="w-4 h-4 accent-stone-900"
|
|
/>
|
|
</label>
|
|
|
|
<label className="flex items-center justify-between gap-3 py-1">
|
|
<span className="text-sm font-semibold text-stone-800">{s.chat}</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.chat}
|
|
onChange={(e) => setDraft((p) => ({ ...p, chat: e.target.checked }))}
|
|
className="w-4 h-4 accent-stone-900"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-col gap-2">
|
|
<button
|
|
onClick={() => setConsent({ analytics: true, chat: true })}
|
|
className="px-4 py-2 rounded-xl bg-stone-900 text-stone-50 font-semibold hover:bg-stone-800 transition-colors"
|
|
>
|
|
{s.acceptAll}
|
|
</button>
|
|
<button
|
|
onClick={() => setConsent(draft)}
|
|
className="px-4 py-2 rounded-xl bg-white border border-stone-200 text-stone-800 font-semibold hover:bg-stone-50 transition-colors"
|
|
>
|
|
{s.acceptSelected}
|
|
</button>
|
|
<button
|
|
onClick={() => setConsent({ analytics: false, chat: false })}
|
|
className="px-4 py-2 rounded-xl bg-transparent text-stone-600 font-semibold hover:text-stone-900 transition-colors"
|
|
>
|
|
{s.rejectAll}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|