diff --git a/package-lock.json b/package-lock.json index f17ad02..92ff5ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^3.10.0", "@monaco-editor/react": "^4.7.0", + "@testing-library/dom": "^10.4.1", "@tiptap/extension-image": "^3.20.0", "@tiptap/extension-link": "^3.20.0", "@tiptap/extension-placeholder": "^3.20.0", @@ -3755,46 +3756,41 @@ "node_modules/@webassemblyjs/wasm-gen": { "version": "1.14.1", "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/@webassemblyjs/wasm-opt": { "version": "1.14.1", "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/@webassemblyjs/wasm-parser": { "version": "1.14.1", "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/@webassemblyjs/wast-printer": { "version": "1.14.1", "license": "MIT", - "peer": true, "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/@wry/caches": { @@ -8986,6 +8982,26 @@ ], "license": "MIT" }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", + "license": "MIT" + }, + "node_modules/qrcode.react": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-1.0.1.tgz", + "integrity": "sha512-8d3Tackk8IRLXTo67Y+c1rpaiXjoz/Dd2HpcMdW//62/x8J1Nbho14Kh8x974t9prsLHN6XqVgcnRiBGFptQmg==", + "license": "ISC", + "dependencies": { + "loose-envify": "^1.4.0", + "prop-types": "^15.6.0", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "^15.5.3 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "dev": true, diff --git a/package.json b/package.json index 025e086..15f0ef3 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^3.10.0", "@monaco-editor/react": "^4.7.0", + "@testing-library/dom": "^10.4.1", "@tiptap/extension-image": "^3.20.0", "@tiptap/extension-link": "^3.20.0", "@tiptap/extension-placeholder": "^3.20.0", diff --git a/src/components/CookieConsentBanner.tsx b/src/components/CookieConsentBanner.tsx new file mode 100644 index 0000000..e0fab27 --- /dev/null +++ b/src/components/CookieConsentBanner.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useEffect, useRef, useId } from 'react'; +import { Shield } from 'lucide-react'; +import { useGdprConsent } from '@/hooks/useGdprConsent'; +import { useScreenReaderAnnouncement } from '@/hooks/useAccessibility'; + +/** + * GDPR Cookie Consent Banner with full focus management: + * - Traps focus inside the banner while visible + * - Restores focus to the previously focused element on dismiss + * - Announces appearance to screen readers + * - Keyboard: Tab/Shift+Tab cycle within banner; Enter/Space activate buttons + */ +export function CookieConsentBanner() { + const { showBanner, accept, reject } = useGdprConsent(); + const bannerRef = useRef(null); + const previousFocusRef = useRef(null); + const announce = useScreenReaderAnnouncement(); + const titleId = useId(); + + // Save the element that had focus before the banner appeared, then focus the banner + useEffect(() => { + if (!showBanner) return; + + previousFocusRef.current = document.activeElement as HTMLElement; + announce('Cookie consent banner appeared. Please choose your cookie preferences.', 'assertive'); + + // Focus the first interactive element inside the banner + const raf = requestAnimationFrame(() => { + const first = bannerRef.current?.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + first?.focus(); + }); + + return () => cancelAnimationFrame(raf); + }, [showBanner, announce]); + + // Restore focus when banner is dismissed + useEffect(() => { + if (showBanner) return; + previousFocusRef.current?.focus(); + previousFocusRef.current = null; + }, [showBanner]); + + // Trap focus inside the banner while it is visible + useEffect(() => { + if (!showBanner) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab' || !bannerRef.current) return; + + const focusable = Array.from( + bannerRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ).filter((el) => !el.hasAttribute('disabled')); + + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [showBanner]); + + if (!showBanner) return null; + + return ( +
+
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/__tests__/CookieConsentBanner.test.tsx b/src/components/__tests__/CookieConsentBanner.test.tsx new file mode 100644 index 0000000..0608628 --- /dev/null +++ b/src/components/__tests__/CookieConsentBanner.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi, beforeEach } from 'vitest'; +import { CookieConsentBanner } from '../CookieConsentBanner'; + +const STORAGE_KEY = 'gdpr_consent'; + +// Mock useScreenReaderAnnouncement to avoid DOM side-effects in tests +vi.mock('@/hooks/useAccessibility', () => ({ + useScreenReaderAnnouncement: () => vi.fn(), +})); + +beforeEach(() => { + localStorage.clear(); +}); + +describe('CookieConsentBanner', () => { + it('renders the banner when no consent is stored', () => { + render(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Cookie Preferences')).toBeInTheDocument(); + }); + + it('does not render when consent is already stored', () => { + localStorage.setItem(STORAGE_KEY, 'accepted'); + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders Accept all and Reject non-essential buttons', () => { + render(); + expect(screen.getByRole('button', { name: /accept all/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reject non-essential/i })).toBeInTheDocument(); + }); + + it('hides the banner after clicking Accept all', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /accept all/i })); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBe('accepted'); + }); + + it('hides the banner after clicking Reject non-essential', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /reject non-essential/i })); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBe('rejected'); + }); + + it('has aria-modal and aria-labelledby for accessibility', () => { + render(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-labelledby'); + }); + + it('contains a Privacy Policy link', () => { + render(); + expect(screen.getByRole('link', { name: /privacy policy/i })).toBeInTheDocument(); + }); + + it('traps Tab key within the banner', () => { + render(); + const rejectBtn = screen.getByRole('button', { name: /reject non-essential/i }); + const acceptBtn = screen.getByRole('button', { name: /accept all/i }); + + // Tab from last focusable element should wrap to first + acceptBtn.focus(); + fireEvent.keyDown(document, { key: 'Tab', shiftKey: false }); + // Focus should have been redirected to first element (Privacy Policy link or reject button) + // We verify the handler doesn't throw and the banner is still present + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Shift+Tab from first focusable element should wrap to last + rejectBtn.focus(); + fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); +}); diff --git a/src/hooks/__tests__/useGdprConsent.test.ts b/src/hooks/__tests__/useGdprConsent.test.ts new file mode 100644 index 0000000..d5bf173 --- /dev/null +++ b/src/hooks/__tests__/useGdprConsent.test.ts @@ -0,0 +1,53 @@ +import { renderHook, act } from '@testing-library/react'; +import { useGdprConsent } from '../useGdprConsent'; + +const STORAGE_KEY = 'gdpr_consent'; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('useGdprConsent', () => { + it('returns null consent and showBanner=true when no stored value', () => { + const { result } = renderHook(() => useGdprConsent()); + expect(result.current.consent).toBeNull(); + expect(result.current.showBanner).toBe(true); + }); + + it('reads accepted consent from localStorage on mount', () => { + localStorage.setItem(STORAGE_KEY, 'accepted'); + const { result } = renderHook(() => useGdprConsent()); + expect(result.current.consent).toBe('accepted'); + expect(result.current.showBanner).toBe(false); + }); + + it('reads rejected consent from localStorage on mount', () => { + localStorage.setItem(STORAGE_KEY, 'rejected'); + const { result } = renderHook(() => useGdprConsent()); + expect(result.current.consent).toBe('rejected'); + expect(result.current.showBanner).toBe(false); + }); + + it('accept() sets consent to accepted and persists to localStorage', () => { + const { result } = renderHook(() => useGdprConsent()); + act(() => result.current.accept()); + expect(result.current.consent).toBe('accepted'); + expect(result.current.showBanner).toBe(false); + expect(localStorage.getItem(STORAGE_KEY)).toBe('accepted'); + }); + + it('reject() sets consent to rejected and persists to localStorage', () => { + const { result } = renderHook(() => useGdprConsent()); + act(() => result.current.reject()); + expect(result.current.consent).toBe('rejected'); + expect(result.current.showBanner).toBe(false); + expect(localStorage.getItem(STORAGE_KEY)).toBe('rejected'); + }); + + it('ignores unknown stored values and shows banner', () => { + localStorage.setItem(STORAGE_KEY, 'unknown_value'); + const { result } = renderHook(() => useGdprConsent()); + expect(result.current.consent).toBeNull(); + expect(result.current.showBanner).toBe(true); + }); +}); diff --git a/src/hooks/useGdprConsent.ts b/src/hooks/useGdprConsent.ts new file mode 100644 index 0000000..6a4ffe7 --- /dev/null +++ b/src/hooks/useGdprConsent.ts @@ -0,0 +1,61 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +export type ConsentChoice = 'accepted' | 'rejected' | null; + +const STORAGE_KEY = 'gdpr_consent'; + +function readStoredConsent(): ConsentChoice { + if (typeof window === 'undefined') return null; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === 'accepted' || raw === 'rejected') return raw; + } catch { + // localStorage unavailable (e.g. private browsing with strict settings) + } + return null; +} + +function writeConsent(choice: ConsentChoice): void { + if (typeof window === 'undefined' || choice === null) return; + try { + localStorage.setItem(STORAGE_KEY, choice); + } catch { + // ignore + } +} + +export interface UseGdprConsentReturn { + /** null = not yet decided, banner should be shown */ + consent: ConsentChoice; + /** Whether the banner should be visible */ + showBanner: boolean; + accept: () => void; + reject: () => void; +} + +export function useGdprConsent(): UseGdprConsentReturn { + const [consent, setConsent] = useState(null); + + useEffect(() => { + setConsent(readStoredConsent()); + }, []); + + const accept = useCallback(() => { + writeConsent('accepted'); + setConsent('accepted'); + }, []); + + const reject = useCallback(() => { + writeConsent('rejected'); + setConsent('rejected'); + }, []); + + return { + consent, + showBanner: consent === null, + accept, + reject, + }; +} diff --git a/src/providers/RootProviders.tsx b/src/providers/RootProviders.tsx index 61e1ae8..baf7b32 100644 --- a/src/providers/RootProviders.tsx +++ b/src/providers/RootProviders.tsx @@ -21,6 +21,7 @@ import { EnvGuard } from '@/components/shared/EnvGuard'; import { FeatureFlagProvider } from '@/components/shared/FeatureFlagProvider'; import { ToastProvider } from '@/context/ToastContext'; import { Loading } from '@/components/ui/Loading'; +import { CookieConsentBanner } from '@/components/CookieConsentBanner'; import i18n from '@/lib/i18n/config'; // Lazy load heavy/non-critical providers/components to improve initial render time