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>
88 lines
2.4 KiB
TypeScript
88 lines
2.4 KiB
TypeScript
"use client";
|
|
|
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
|
|
export type ConsentState = {
|
|
analytics: boolean;
|
|
chat: boolean;
|
|
};
|
|
|
|
const COOKIE_NAME = "dk0_consent_v1";
|
|
|
|
function readConsentFromCookie(): ConsentState | null {
|
|
if (typeof document === "undefined") return null;
|
|
const match = document.cookie
|
|
.split(";")
|
|
.map((c) => c.trim())
|
|
.find((c) => c.startsWith(`${COOKIE_NAME}=`));
|
|
if (!match) return null;
|
|
const value = decodeURIComponent(match.split("=").slice(1).join("="));
|
|
try {
|
|
const parsed = JSON.parse(value) as Partial<ConsentState>;
|
|
return {
|
|
analytics: !!parsed.analytics,
|
|
chat: !!parsed.chat,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeConsentCookie(value: ConsentState) {
|
|
const encoded = encodeURIComponent(JSON.stringify(value));
|
|
// 180 days
|
|
const maxAge = 60 * 60 * 24 * 180;
|
|
document.cookie = `${COOKIE_NAME}=${encoded}; path=/; max-age=${maxAge}; samesite=lax`;
|
|
}
|
|
|
|
const ConsentContext = createContext<{
|
|
consent: ConsentState | null;
|
|
ready: boolean;
|
|
setConsent: (next: ConsentState) => void;
|
|
resetConsent: () => void;
|
|
}>({
|
|
consent: null,
|
|
ready: false,
|
|
setConsent: () => {},
|
|
resetConsent: () => {},
|
|
});
|
|
|
|
export function ConsentProvider({ children }: { children: React.ReactNode }) {
|
|
// IMPORTANT:
|
|
// Don't read `document.cookie` during SSR render (document is undefined), otherwise the
|
|
// server will render the banner while the client immediately hides it -> hydration mismatch.
|
|
// We resolve consent on the client after mount and only render the banner once `ready=true`.
|
|
const [consent, setConsentState] = useState<ConsentState | null>(null);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setConsentState(readConsentFromCookie());
|
|
setReady(true);
|
|
}, []);
|
|
|
|
const setConsent = useCallback((next: ConsentState) => {
|
|
setConsentState(next);
|
|
writeConsentCookie(next);
|
|
}, []);
|
|
|
|
const resetConsent = useCallback(() => {
|
|
setConsentState(null);
|
|
// expire cookie
|
|
document.cookie = `${COOKIE_NAME}=; path=/; max-age=0; samesite=lax`;
|
|
}, []);
|
|
|
|
const value = useMemo(
|
|
() => ({ consent, ready, setConsent, resetConsent }),
|
|
[consent, ready, setConsent, resetConsent],
|
|
);
|
|
|
|
return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>;
|
|
}
|
|
|
|
export function useConsent() {
|
|
return useContext(ConsentContext);
|
|
}
|
|
|
|
export const consentCookieName = COOKIE_NAME;
|
|
|