diff --git a/admin-compliance/app/sdk/layout.tsx b/admin-compliance/app/sdk/layout.tsx index e125162..b475c20 100644 --- a/admin-compliance/app/sdk/layout.tsx +++ b/admin-compliance/app/sdk/layout.tsx @@ -7,6 +7,7 @@ import { SDKSidebar } from '@/components/sdk/Sidebar/SDKSidebar' import { CommandBar } from '@/components/sdk/CommandBar' import { SDKPipelineSidebar } from '@/components/sdk/SDKPipelineSidebar' import { ComplianceAdvisorWidget } from '@/components/sdk/ComplianceAdvisorWidget' +import { CookieBannerOverlay, CookieBannerFAB } from '@/components/sdk/CookieBannerOverlay' import { useSDK } from '@/lib/sdk' // ============================================================================= @@ -208,10 +209,14 @@ function SDKInnerLayout({ children }: { children: React.ReactNode }) { {isCommandBarOpen && setCommandBarOpen(false)} />} {/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */} - {projectId && } + - {/* Compliance Advisor Widget */} - {projectId && } + {/* Compliance Advisor Widget — immer sichtbar, auch ohne Projekt */} + + + {/* Cookie Banner — opens on first visit, reopenable via FAB */} + + ) } diff --git a/admin-compliance/components/sdk/CookieBannerOverlay.tsx b/admin-compliance/components/sdk/CookieBannerOverlay.tsx new file mode 100644 index 0000000..d292f3d --- /dev/null +++ b/admin-compliance/components/sdk/CookieBannerOverlay.tsx @@ -0,0 +1,277 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' + +/** + * CookieBannerOverlay — Live cookie consent banner for the Compliance SDK. + * + * - Opens automatically on first visit (localStorage check) + * - Can be reopened via FAB button (right-[10rem]) + * - Records consent choice to localStorage + * - Fires custom event 'sdkCookieConsentUpdated' for other components + */ + +const STORAGE_KEY = 'bp-sdk-cookie-consent' + +interface ConsentState { + necessary: boolean + statistics: boolean + marketing: boolean + functional: boolean + timestamp: string +} + +function getStoredConsent(): ConsentState | null { + if (typeof window === 'undefined') return null + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + return JSON.parse(raw) + } catch { + return null + } +} + +export function CookieBannerOverlay() { + const [isOpen, setIsOpen] = useState(false) + const [showSettings, setShowSettings] = useState(false) + const [consent, setConsent] = useState({ + necessary: true, + statistics: false, + marketing: false, + functional: false, + timestamp: '', + }) + + // Check on mount if consent was already given + useEffect(() => { + const stored = getStoredConsent() + if (!stored) { + // First visit — show banner + setIsOpen(true) + } else { + setConsent(stored) + } + }, []) + + // Listen for reopen event from FAB button + useEffect(() => { + const handler = () => { + setIsOpen(true) + setShowSettings(true) + } + window.addEventListener('openCookieBanner', handler) + return () => window.removeEventListener('openCookieBanner', handler) + }, []) + + const saveConsent = useCallback((state: ConsentState) => { + const withTimestamp = { ...state, timestamp: new Date().toISOString() } + localStorage.setItem(STORAGE_KEY, JSON.stringify(withTimestamp)) + setConsent(withTimestamp) + setIsOpen(false) + setShowSettings(false) + window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withTimestamp })) + }, []) + + const handleAcceptAll = () => { + saveConsent({ necessary: true, statistics: true, marketing: true, functional: true, timestamp: '' }) + } + + const handleRejectAll = () => { + saveConsent({ necessary: true, statistics: false, marketing: false, functional: false, timestamp: '' }) + } + + const handleSaveSettings = () => { + saveConsent(consent) + } + + if (!isOpen) return null + + return ( + <> + {/* Overlay */} +
{/* Don't close on overlay click — consent is required */}} + /> + + {/* Banner */} +
+
+ {/* Header */} +
+

+ + + + Cookie-Einstellungen +

+

+ Wir verwenden Cookies und aehnliche Technologien, um Ihnen die bestmoegliche Erfahrung zu bieten. + Sie koennen Ihre Praeferenzen jederzeit aendern. +

+ +
+ + {/* Category Settings (expandable) */} + {showSettings && ( +
+ {/* Necessary — always on */} + {}} + /> + setConsent(prev => ({ ...prev, statistics: v }))} + /> + setConsent(prev => ({ ...prev, marketing: v }))} + /> + setConsent(prev => ({ ...prev, functional: v }))} + /> +
+ )} + + {/* Buttons */} +
+ {!showSettings ? ( + <> + + + + + ) : ( + <> + + + + + )} +
+
+
+ + ) +} + + +/** + * FAB button to reopen the cookie banner settings. + * Positioned next to ComplianceAdvisor and PipelineSidebar. + */ +export function CookieBannerFAB() { + const [hasConsent, setHasConsent] = useState(false) + + useEffect(() => { + setHasConsent(!!getStoredConsent()) + const handler = () => setHasConsent(true) + window.addEventListener('sdkCookieConsentUpdated', handler) + return () => window.removeEventListener('sdkCookieConsentUpdated', handler) + }, []) + + // Only show FAB after consent was given (banner is closed) + if (!hasConsent) return null + + return ( + + ) +} + + +function CategoryToggle({ + label, + description, + checked, + disabled, + onChange, +}: { + label: string + description: string + checked: boolean + disabled?: boolean + onChange: (v: boolean) => void +}) { + return ( +
+
+
{label}
+
{description}
+
+ +
+ ) +} diff --git a/admin-compliance/e2e/specs/cookie-banner-overlay.spec.ts b/admin-compliance/e2e/specs/cookie-banner-overlay.spec.ts new file mode 100644 index 0000000..453af08 --- /dev/null +++ b/admin-compliance/e2e/specs/cookie-banner-overlay.spec.ts @@ -0,0 +1,256 @@ +import { test, expect } from '@playwright/test' + +/** + * Cookie Banner Overlay E2E Tests + * + * Tests the live cookie consent banner in the Compliance SDK: + * - Auto-opens on first visit + * - Consent choices (accept all, reject all, custom settings) + * - FAB button to reopen after consent + * - Persistence in localStorage + * - Correct toggle behavior for categories + */ + +const SDK_URL = '/sdk' +const STORAGE_KEY = 'bp-sdk-cookie-consent' + +test.describe('Cookie Banner — First Visit', () => { + test.beforeEach(async ({ page }) => { + // Clear localStorage to simulate first visit + await page.goto(SDK_URL) + await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent')) + await page.reload() + await page.waitForLoadState('networkidle') + }) + + test('should show banner on first visit', async ({ page }) => { + // Banner should be visible + await expect(page.getByText('Cookie-Einstellungen')).toBeVisible({ timeout: 10000 }) + await expect(page.getByText('Wir verwenden Cookies')).toBeVisible() + }) + + test('should show three buttons: accept all, reject, settings', async ({ page }) => { + await page.waitForTimeout(500) + await expect(page.getByRole('button', { name: 'Alle akzeptieren' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Nur notwendige' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Einstellungen' })).toBeVisible() + }) + + test('should have Datenschutzerklaerung and Impressum links', async ({ page }) => { + await page.waitForTimeout(500) + await expect(page.getByText('Datenschutzerklaerung')).toBeVisible() + await expect(page.getByText('Impressum')).toBeVisible() + }) + + test('should show overlay backdrop', async ({ page }) => { + await page.waitForTimeout(500) + // Check for the dark overlay behind the banner + const overlay = page.locator('.bg-black\\/40') + await expect(overlay).toBeVisible() + }) +}) + + +test.describe('Cookie Banner — Accept All', () => { + test('should close banner and save consent on "Alle akzeptieren"', async ({ page }) => { + await page.goto(SDK_URL) + await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent')) + await page.reload() + await page.waitForLoadState('networkidle') + + // Click accept all + await page.getByRole('button', { name: 'Alle akzeptieren' }).click() + + // Banner should close + await expect(page.getByText('Cookie-Einstellungen').first()).not.toBeVisible({ timeout: 5000 }) + + // Verify localStorage + const consent = await page.evaluate(() => { + const raw = localStorage.getItem('bp-sdk-cookie-consent') + return raw ? JSON.parse(raw) : null + }) + expect(consent).toBeTruthy() + expect(consent.necessary).toBe(true) + expect(consent.statistics).toBe(true) + expect(consent.marketing).toBe(true) + expect(consent.functional).toBe(true) + expect(consent.timestamp).toBeTruthy() + }) +}) + + +test.describe('Cookie Banner — Reject All', () => { + test('should save only necessary on "Nur notwendige"', async ({ page }) => { + await page.goto(SDK_URL) + await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent')) + await page.reload() + await page.waitForLoadState('networkidle') + + // Click reject all + await page.getByRole('button', { name: 'Nur notwendige' }).click() + + // Banner should close + await expect(page.getByText('Cookie-Einstellungen').first()).not.toBeVisible({ timeout: 5000 }) + + // Verify localStorage — only necessary enabled + const consent = await page.evaluate(() => { + const raw = localStorage.getItem('bp-sdk-cookie-consent') + return raw ? JSON.parse(raw) : null + }) + expect(consent).toBeTruthy() + expect(consent.necessary).toBe(true) + expect(consent.statistics).toBe(false) + expect(consent.marketing).toBe(false) + expect(consent.functional).toBe(false) + }) +}) + + +test.describe('Cookie Banner — Custom Settings', () => { + test('should expand category toggles on "Einstellungen"', async ({ page }) => { + await page.goto(SDK_URL) + await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent')) + await page.reload() + await page.waitForLoadState('networkidle') + + // Click settings + await page.getByRole('button', { name: 'Einstellungen' }).click() + + // Category toggles should appear + await expect(page.getByText('Notwendig')).toBeVisible() + await expect(page.getByText('Statistik')).toBeVisible() + await expect(page.getByText('Marketing')).toBeVisible() + await expect(page.getByText('Funktional')).toBeVisible() + }) + + test('should have "Auswahl speichern" button in settings view', async ({ page }) => { + await page.goto(SDK_URL) + await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent')) + await page.reload() + await page.waitForLoadState('networkidle') + + await page.getByRole('button', { name: 'Einstellungen' }).click() + await expect(page.getByRole('button', { name: 'Auswahl speichern' })).toBeVisible() + }) + + test('should save custom selection', async ({ page }) => { + await page.goto(SDK_URL) + await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent')) + await page.reload() + await page.waitForLoadState('networkidle') + + // Open settings + await page.getByRole('button', { name: 'Einstellungen' }).click() + await page.waitForTimeout(300) + + // Toggle statistics on (click the toggle next to "Statistik") + const statistikRow = page.locator('text=Statistik').locator('..') + const toggle = statistikRow.locator('button').last() + await toggle.click() + + // Save + await page.getByRole('button', { name: 'Auswahl speichern' }).click() + + // Verify + const consent = await page.evaluate(() => { + const raw = localStorage.getItem('bp-sdk-cookie-consent') + return raw ? JSON.parse(raw) : null + }) + expect(consent).toBeTruthy() + expect(consent.necessary).toBe(true) + expect(consent.statistics).toBe(true) + expect(consent.marketing).toBe(false) + }) + + test('necessary toggle should be disabled', async ({ page }) => { + await page.goto(SDK_URL) + await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent')) + await page.reload() + await page.waitForLoadState('networkidle') + + await page.getByRole('button', { name: 'Einstellungen' }).click() + await page.waitForTimeout(300) + + // The "Notwendig" toggle should be disabled (can't be turned off) + const necessaryRow = page.locator('text=Notwendig').locator('..') + const toggle = necessaryRow.locator('button[disabled]') + await expect(toggle).toBeVisible() + }) +}) + + +test.describe('Cookie Banner — Persistence', () => { + test('should NOT show banner on subsequent visits', async ({ page }) => { + await page.goto(SDK_URL) + // Set consent in localStorage + await page.evaluate(() => { + localStorage.setItem('bp-sdk-cookie-consent', JSON.stringify({ + necessary: true, statistics: true, marketing: false, functional: false, + timestamp: new Date().toISOString(), + })) + }) + await page.reload() + await page.waitForLoadState('networkidle') + await page.waitForTimeout(1000) + + // Banner should NOT appear + const bannerVisible = await page.getByText('Wir verwenden Cookies').isVisible().catch(() => false) + expect(bannerVisible).toBe(false) + }) +}) + + +test.describe('Cookie Banner — FAB Reopen Button', () => { + test('should show cookie FAB after consent is given', async ({ page }) => { + await page.goto(SDK_URL) + await page.evaluate(() => { + localStorage.setItem('bp-sdk-cookie-consent', JSON.stringify({ + necessary: true, statistics: true, marketing: false, functional: false, + timestamp: new Date().toISOString(), + })) + }) + await page.reload() + await page.waitForLoadState('networkidle') + await page.waitForTimeout(1000) + + // FAB button should be visible (shield icon) + const fab = page.locator('button[aria-label="Cookie-Einstellungen oeffnen"]') + await expect(fab).toBeVisible({ timeout: 5000 }) + }) + + test('should reopen banner when FAB is clicked', async ({ page }) => { + await page.goto(SDK_URL) + await page.evaluate(() => { + localStorage.setItem('bp-sdk-cookie-consent', JSON.stringify({ + necessary: true, statistics: true, marketing: false, functional: false, + timestamp: new Date().toISOString(), + })) + }) + await page.reload() + await page.waitForLoadState('networkidle') + await page.waitForTimeout(1000) + + // Click FAB + const fab = page.locator('button[aria-label="Cookie-Einstellungen oeffnen"]') + await fab.click() + + // Banner should reopen with settings expanded + await expect(page.getByText('Cookie-Einstellungen')).toBeVisible({ timeout: 5000 }) + // Settings should be shown (since reopening goes straight to settings) + await expect(page.getByText('Statistik')).toBeVisible() + await expect(page.getByText('Marketing')).toBeVisible() + }) + + test('should NOT show FAB before consent is given', async ({ page }) => { + await page.goto(SDK_URL) + await page.evaluate(() => localStorage.removeItem('bp-sdk-cookie-consent')) + await page.reload() + await page.waitForLoadState('networkidle') + await page.waitForTimeout(1000) + + // FAB should NOT be visible (banner is showing instead) + const fab = page.locator('button[aria-label="Cookie-Einstellungen oeffnen"]') + const isVisible = await fab.isVisible().catch(() => false) + expect(isVisible).toBe(false) + }) +})