211 lines
6.9 KiB
TypeScript
211 lines
6.9 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { useConsent, ConsentPreferences } from '@/contexts/consent';
|
|
|
|
const SKIP_BANNER_STORAGE_KEY = 'fotospiel.consent.skip';
|
|
|
|
const CookieBanner: React.FC = () => {
|
|
const { t } = useTranslation('common');
|
|
const {
|
|
showBanner,
|
|
acceptAll,
|
|
rejectAll,
|
|
preferences,
|
|
savePreferences,
|
|
isPreferencesOpen,
|
|
openPreferences,
|
|
closePreferences,
|
|
} = useConsent();
|
|
|
|
const [draftPreferences, setDraftPreferences] = useState<ConsentPreferences>(preferences);
|
|
|
|
const shouldSkipBanner = useMemo(() => {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
if (!import.meta.env.DEV) {
|
|
return false;
|
|
}
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
const hasSkipParam = params.get('consent') === 'skip' || params.get('cookieBanner') === 'off';
|
|
|
|
if (hasSkipParam) {
|
|
try {
|
|
window.localStorage.setItem(SKIP_BANNER_STORAGE_KEY, '1');
|
|
} catch (error) {
|
|
console.warn('[Consent] Failed to persist skip flag', error);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
return window.localStorage.getItem(SKIP_BANNER_STORAGE_KEY) === '1';
|
|
} catch (error) {
|
|
console.warn('[Consent] Failed to read skip flag', error);
|
|
return false;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isPreferencesOpen) {
|
|
setDraftPreferences(preferences);
|
|
}
|
|
}, [isPreferencesOpen, preferences]);
|
|
|
|
const analyticsDescription = useMemo(
|
|
() => t('consent.modal.analytics_desc'),
|
|
[t],
|
|
);
|
|
|
|
const handleSave = () => {
|
|
savePreferences({ analytics: draftPreferences.analytics });
|
|
};
|
|
|
|
const handleOpenChange = (open: boolean) => {
|
|
if (open) {
|
|
openPreferences();
|
|
return;
|
|
}
|
|
closePreferences();
|
|
};
|
|
|
|
if (shouldSkipBanner) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{showBanner && !isPreferencesOpen && (
|
|
<div
|
|
className="fixed inset-x-0 bottom-0 z-40 px-4 pb-6 sm:px-6"
|
|
role="region"
|
|
aria-label={t('consent.accessibility.banner_label')}
|
|
>
|
|
<div className="mx-auto max-w-5xl rounded-3xl border border-gray-200 bg-white/95 p-6 shadow-2xl backdrop-blur-md dark:border-gray-700 dark:bg-gray-900/95">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="space-y-2">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{t('consent.banner.title')}
|
|
</h2>
|
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
{t('consent.banner.body')}
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
<Button
|
|
variant="outline"
|
|
className="min-w-[140px]"
|
|
onClick={rejectAll}
|
|
>
|
|
{t('consent.banner.reject')}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
className="min-w-[140px]"
|
|
onClick={openPreferences}
|
|
>
|
|
{t('consent.banner.customize')}
|
|
</Button>
|
|
<Button className="min-w-[140px]" onClick={acceptAll}>
|
|
{t('consent.banner.accept')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Dialog open={isPreferencesOpen} onOpenChange={handleOpenChange}>
|
|
<DialogContent className="sm:max-w-xl md:max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('consent.modal.title')}</DialogTitle>
|
|
<DialogDescription className="text-sm text-muted-foreground">
|
|
{t('consent.modal.description')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-800/60">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t('consent.modal.functional')}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('consent.modal.functional_desc')}
|
|
</p>
|
|
</div>
|
|
<span className="rounded-full bg-gray-200 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
|
{t('consent.modal.required')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-gray-200 p-4 dark:border-gray-700">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t('consent.modal.analytics')}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">{analyticsDescription}</p>
|
|
</div>
|
|
<Switch
|
|
checked={draftPreferences.analytics}
|
|
onCheckedChange={(checked) =>
|
|
setDraftPreferences((prev) => ({
|
|
...prev,
|
|
analytics: checked,
|
|
functional: true,
|
|
}))
|
|
}
|
|
aria-label={t('consent.modal.analytics')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<DialogFooter className="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex w-full flex-wrap gap-2 sm:w-auto">
|
|
<Button type="button" variant="outline" onClick={rejectAll}>
|
|
{t('consent.modal.reject_all')}
|
|
</Button>
|
|
<Button type="button" variant="secondary" onClick={acceptAll}>
|
|
{t('consent.modal.accept_all')}
|
|
</Button>
|
|
</div>
|
|
<div className="flex w-full flex-wrap gap-2 sm:w-auto sm:justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => closePreferences()}
|
|
>
|
|
{t('consent.modal.cancel')}
|
|
</Button>
|
|
<Button type="button" onClick={handleSave}>
|
|
{t('consent.modal.save')}
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default CookieBanner;
|