diff --git a/src/hooks/useCookieConsent.ts b/src/hooks/useCookieConsent.ts index 4cf2cc32..16daf39a 100644 --- a/src/hooks/useCookieConsent.ts +++ b/src/hooks/useCookieConsent.ts @@ -1,17 +1,21 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; +import { z } from "zod"; import { CrossDomainStorage } from "@/lib/crossDomainStorage"; +import { useLocalStorageState } from "./useLocalStorageState"; const CONSENT_KEY = "gdpr-consent"; const CONSENT_VERSION = "1.0"; -export interface ConsentPreferences { - essential: boolean; - analytics: boolean; - preferences: boolean; - marketing: boolean; - version: string; - timestamp: number; -} +const consentPreferencesSchema = z.object({ + essential: z.boolean(), + analytics: z.boolean(), + preferences: z.boolean(), + marketing: z.boolean(), + version: z.string(), + timestamp: z.number(), +}); + +export type ConsentPreferences = z.infer; const defaultConsent: ConsentPreferences = { essential: true, // Always true, required for app to function @@ -23,27 +27,23 @@ const defaultConsent: ConsentPreferences = { }; export function useCookieConsent() { - const [consent, setConsent] = useState(null); - const [showBanner, setShowBanner] = useState(false); - - useEffect(() => { - const savedConsent = CrossDomainStorage.getItem(CONSENT_KEY); - if (savedConsent) { - try { - const parsed = JSON.parse(savedConsent); - if (parsed.version === CONSENT_VERSION) { - setConsent(parsed); - } else { - // Version mismatch, show banner again - setShowBanner(true); - } - } catch { - setShowBanner(true); - } - } else { - setShowBanner(true); - } - }, []); + const [storedConsent, setStoredConsent] = useLocalStorageState( + CONSENT_KEY, + consentPreferencesSchema.nullable(), + null, + CrossDomainStorage, + ); + + // A stored record from a stale CONSENT_VERSION is treated as no consent, + // reopening the banner (mirrors the version-gating useLinkWizardSkipped + // does outside useLocalStorageState — the schema itself can't enforce + // "equals this literal version"). + const consent = + storedConsent && storedConsent.version === CONSENT_VERSION + ? storedConsent + : null; + + const [showBanner, setShowBanner] = useState(() => consent === null); function saveConsent(preferences: Partial) { const newConsent = { @@ -52,8 +52,7 @@ export function useCookieConsent() { timestamp: Date.now(), }; - setConsent(newConsent); - CrossDomainStorage.setItem(CONSENT_KEY, JSON.stringify(newConsent)); + setStoredConsent(newConsent); setShowBanner(false); } @@ -82,8 +81,11 @@ export function useCookieConsent() { } function revokeConsent() { + // Order matters: setStoredConsent writes the literal string "null" back + // to storage, so remove the key after, not before, or the removal is + // immediately undone. + setStoredConsent(null); CrossDomainStorage.removeItem(CONSENT_KEY); - setConsent(null); setShowBanner(true); // Clear non-essential cookies diff --git a/src/hooks/useLocalStorageState.test.ts b/src/hooks/useLocalStorageState.test.ts index e9c3a14d..1d80dea2 100644 --- a/src/hooks/useLocalStorageState.test.ts +++ b/src/hooks/useLocalStorageState.test.ts @@ -72,4 +72,29 @@ describe("useLocalStorageState", () => { expect(result.current[0]).toEqual({ count: 7 }); }); + + it("reads from and writes to a custom storage adapter instead of localStorage", () => { + const store = new Map(); + const customStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + }; + store.set("test-key", JSON.stringify({ count: 5 })); + + const { result } = renderHook(() => + useLocalStorageState("test-key", schema, { count: 0 }, customStorage), + ); + + expect(result.current[0]).toEqual({ count: 5 }); + + act(() => { + result.current[1]({ count: 42 }); + }); + + expect(result.current[0]).toEqual({ count: 42 }); + expect(JSON.parse(store.get("test-key")!)).toEqual({ count: 42 }); + expect(localStorage.getItem("test-key")).toBeNull(); + }); }); diff --git a/src/hooks/useLocalStorageState.ts b/src/hooks/useLocalStorageState.ts index b896f391..990fe418 100644 --- a/src/hooks/useLocalStorageState.ts +++ b/src/hooks/useLocalStorageState.ts @@ -1,17 +1,23 @@ import { useState } from "react"; import type { z } from "zod"; +export interface StorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + export function useLocalStorageState( key: string, schema: Schema, defaultValue: z.infer | (() => z.infer), + storage: StorageLike = localStorage, ) { type Value = z.infer; const [value, setValue] = useState(() => readValue()); function readValue(): Value { - const raw = localStorage.getItem(key); + const raw = storage.getItem(key); if (raw) { try { const result = schema.safeParse(JSON.parse(raw)); @@ -29,7 +35,7 @@ export function useLocalStorageState( function updateValue(newValue: Value) { setValue(newValue); - localStorage.setItem(key, JSON.stringify(newValue)); + storage.setItem(key, JSON.stringify(newValue)); } return [value, updateValue] as const;