grantforge-api / frontend-react /src /components /common /CookieConsentBanner.tsx
GrantForge Bot
Deploy to Hugging Face
afd56bc
import { useState, useEffect } from 'react';
import { X, Cookie, Shield } from 'lucide-react';
interface ConsentSettings {
essential: true; // niezmienne β€” wymagane do dziaΕ‚ania
analytics: boolean; // opcjonalne
marketing: boolean; // opcjonalne
}
const CONSENT_KEY = 'grantforge_cookie_consent';
const CONSENT_VERSION = '1.0'; // zmieΕ„ przy aktualizacji polityki β†’ ponowny baner
/**
* Cookie Consent Banner β€” zgodny z RODO Art. 7 i DyrektywΔ… ePrivacy.
*
* Zachowanie:
* - Pojawia siΔ™ na dole ekranu przy pierwszej wizycie (lub po zmianie wersji polityki)
* - "Akceptuj wszystkie" β†’ zapisuje zgodΔ™ w localStorage
* - "Tylko niezbΔ™dne" β†’ tylko essential=true
* - "Ustawienia" β†’ rozwijane opcje szczegΓ³Ε‚owe
* - Nie blokuje korzystania z aplikacji
*
* Integracja z backendem:
* Po zapisaniu zgody wywoΕ‚aj POST /api/user/consent z payload { settings }.
*/
export function CookieConsentBanner() {
const [visible, setVisible] = useState(false);
const [expanded, setExpanded] = useState(false);
const [settings, setSettings] = useState<ConsentSettings>({
essential: true,
analytics: false,
marketing: false,
});
useEffect(() => {
try {
const stored = localStorage.getItem(CONSENT_KEY);
if (stored) {
const parsed = JSON.parse(stored);
// Pokazuj ponownie jeΕ›li wersja siΔ™ zmieniΕ‚a
if (parsed.version !== CONSENT_VERSION) {
setVisible(true);
}
} else {
// Pierwsze wejΕ›cie
setVisible(true);
}
} catch {
setVisible(true);
}
}, []);
const saveConsent = (chosen: ConsentSettings) => {
const record = { version: CONSENT_VERSION, ...chosen, timestamp: new Date().toISOString() };
localStorage.setItem(CONSENT_KEY, JSON.stringify(record));
setVisible(false);
// Opcjonalnie: wyΕ›lij do backendu
try {
fetch('/api/user/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consent: record }),
}).catch(() => {}); // fire-and-forget
} catch {}
};
if (!visible) return null;
return (
<div
role="dialog"
aria-modal="false"
aria-label="Zgoda na pliki cookie"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
padding: '1rem',
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<div
style={{
background: 'rgba(15, 15, 25, 0.97)',
backdropFilter: 'blur(16px)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '16px',
padding: '1.5rem',
maxWidth: '780px',
width: '100%',
boxShadow: '0 -4px 40px rgba(0,0,0,0.5)',
pointerEvents: 'auto',
animation: 'slideUp 0.3s ease-out',
}}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{
width: 36, height: 36, borderRadius: '8px',
background: 'rgba(139,92,246,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<Cookie size={18} color="#a78bfa" />
</div>
<div style={{ flex: 1 }}>
<h3 style={{ margin: 0, color: '#f1f5f9', fontSize: '0.95rem', fontWeight: 700 }}>
Twoja prywatnoΕ›Δ‡ ma znaczenie
</h3>
<p style={{ margin: '0.25rem 0 0', color: '#94a3b8', fontSize: '0.8rem', lineHeight: 1.5 }}>
Używamy plików cookie, aby zapewnić właściwe działanie platformy. Możesz wybrać,
ktΓ³re kategorie akceptujesz.{' '}
<a href="/polityka-prywatnosci" style={{ color: '#a78bfa', textDecoration: 'none' }}>
Polityka prywatnoΕ›ci
</a>
</p>
</div>
<button
onClick={() => saveConsent({ essential: true, analytics: false, marketing: false })}
aria-label="Zamknij (tylko niezbΔ™dne)"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', padding: 4 }}
>
<X size={16} />
</button>
</div>
{/* SzczegΓ³Ε‚y rozwijane */}
{expanded && (
<div style={{
borderTop: '1px solid rgba(255,255,255,0.06)',
paddingTop: '1rem',
marginBottom: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.6rem',
}}>
{/* Essential β€” zawsze wΕ‚Δ…czone */}
<ConsentRow
label="NiezbΔ™dne"
description="Sesja, uwierzytelnianie, bezpieczeΕ„stwo CSRF. Wymagane do dziaΕ‚ania platformy."
icon={<Shield size={13} color="#34d399" />}
checked={true}
disabled={true}
onChange={() => {}}
/>
{/* Analytics */}
<ConsentRow
label="Analityczne"
description="Anonimowe statystyki uΕΌytkowania (np. LangSmith tracing β€” bez danych osobowych)."
icon={<span style={{ fontSize: 13 }}>πŸ“Š</span>}
checked={settings.analytics}
disabled={false}
onChange={(v) => setSettings(s => ({ ...s, analytics: v }))}
/>
{/* Marketing */}
<ConsentRow
label="Marketingowe"
description="Personalizacja oferty i powiadomieΕ„ o nowych funkcjach. Opcjonalne."
icon={<span style={{ fontSize: 13 }}>πŸ“£</span>}
checked={settings.marketing}
disabled={false}
onChange={(v) => setSettings(s => ({ ...s, marketing: v }))}
/>
</div>
)}
{/* Przyciski */}
<div style={{
display: 'flex',
gap: '0.6rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
}}>
<button
onClick={() => setExpanded(e => !e)}
style={{
background: 'transparent',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px',
padding: '0.5rem 1rem',
color: '#94a3b8',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
}}
>
{expanded ? 'Ukryj ustawienia' : 'Ustawienia'}
</button>
<button
onClick={() => saveConsent({ essential: true, analytics: false, marketing: false })}
style={{
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '8px',
padding: '0.5rem 1rem',
color: '#cbd5e1',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
}}
>
Tylko niezbΔ™dne
</button>
<button
onClick={() => saveConsent({ essential: true, analytics: true, marketing: true })}
style={{
background: 'linear-gradient(135deg, #7c3aed, #2563eb)',
border: 'none',
borderRadius: '8px',
padding: '0.5rem 1.25rem',
color: '#fff',
cursor: 'pointer',
fontSize: '0.8rem',
fontWeight: 600,
}}
>
Akceptuj wszystkie
</button>
</div>
</div>
<style>{`
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
</div>
);
}
// ── Pomocniczy wiersz ustawienia ─────────────────────────────────────────────
function ConsentRow({
label, description, icon, checked, disabled, onChange
}: {
label: string;
description: string;
icon: React.ReactNode;
checked: boolean;
disabled: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: '0.75rem',
padding: '0.6rem 0.75rem',
borderRadius: '8px',
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.05)',
}}>
<div style={{ marginTop: 2 }}>{icon}</div>
<div style={{ flex: 1 }}>
<div style={{ color: '#e2e8f0', fontSize: '0.82rem', fontWeight: 600 }}>{label}</div>
<div style={{ color: '#64748b', fontSize: '0.75rem', marginTop: 2 }}>{description}</div>
</div>
<label style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', cursor: disabled ? 'not-allowed' : 'pointer' }}>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={e => onChange(e.target.checked)}
style={{ opacity: 0, width: 0, height: 0 }}
/>
<span style={{
display: 'inline-block',
width: 36,
height: 20,
borderRadius: 10,
background: checked ? '#7c3aed' : 'rgba(255,255,255,0.1)',
position: 'relative',
transition: 'background 0.2s',
opacity: disabled ? 0.5 : 1,
}}>
<span style={{
position: 'absolute',
top: 2,
left: checked ? 18 : 2,
width: 16,
height: 16,
borderRadius: '50%',
background: '#fff',
transition: 'left 0.2s',
}} />
</span>
</label>
</div>
);
}
export default CookieConsentBanner;