Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 35 additions & 33 deletions src/hooks/useCookieConsent.ts
Original file line number Diff line number Diff line change
@@ -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<typeof consentPreferencesSchema>;

const defaultConsent: ConsentPreferences = {
essential: true, // Always true, required for app to function
Expand All @@ -23,27 +27,23 @@ const defaultConsent: ConsentPreferences = {
};

export function useCookieConsent() {
const [consent, setConsent] = useState<ConsentPreferences | null>(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<ConsentPreferences>) {
const newConsent = {
Expand All @@ -52,8 +52,7 @@ export function useCookieConsent() {
timestamp: Date.now(),
};

setConsent(newConsent);
CrossDomainStorage.setItem(CONSENT_KEY, JSON.stringify(newConsent));
setStoredConsent(newConsent);
setShowBanner(false);
}

Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions src/hooks/useLocalStorageState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
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();
});
});
10 changes: 8 additions & 2 deletions src/hooks/useLocalStorageState.ts
Original file line number Diff line number Diff line change
@@ -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<Schema extends z.ZodTypeAny>(
key: string,
schema: Schema,
defaultValue: z.infer<Schema> | (() => z.infer<Schema>),
storage: StorageLike = localStorage,
) {
type Value = z.infer<Schema>;

const [value, setValue] = useState<Value>(() => readValue());

function readValue(): Value {
const raw = localStorage.getItem(key);
const raw = storage.getItem(key);
if (raw) {
Comment on lines 19 to 21
try {
const result = schema.safeParse(JSON.parse(raw));
Expand All @@ -29,7 +35,7 @@ export function useLocalStorageState<Schema extends z.ZodTypeAny>(

function updateValue(newValue: Value) {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
storage.setItem(key, JSON.stringify(newValue));
}

return [value, updateValue] as const;
Expand Down
Loading