Skip to content

Commit 25e3283

Browse files
authored
Merge pull request #209 from feat/cookie-consent-banner
feat(cookie-consent): add GDPR cookie consent banner
2 parents 0b804d9 + adc4b24 commit 25e3283

14 files changed

Lines changed: 585 additions & 22 deletions

src/AppShell.jsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { BrowserRouter } from 'react-router-dom';
8+
import { useConsent } from './hooks/useConsent.js';
9+
import { PostHogProvider } from './providers/PostHogProvider.jsx';
10+
import AppRoutes from './AppRoutes.jsx';
11+
import DocumentTitle from './components/DocumentTitle.jsx';
12+
import CookieConsentBanner from './components/CookieConsentBanner.jsx';
13+
14+
export default function AppShell() {
15+
const { isAnalyticsAllowed } = useConsent();
16+
17+
return (
18+
<PostHogProvider analytics={isAnalyticsAllowed}>
19+
<BrowserRouter>
20+
<DocumentTitle />
21+
<AppRoutes />
22+
<CookieConsentBanner />
23+
</BrowserRouter>
24+
</PostHogProvider>
25+
);
26+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { motion, AnimatePresence } from 'framer-motion';
8+
import { Cookie } from '@phosphor-icons/react';
9+
import { useTranslation } from 'react-i18next';
10+
import { useConsent } from '../hooks/useConsent.js';
11+
import { Link } from 'react-router-dom';
12+
13+
/**
14+
* Fixed bottom cookie consent banner.
15+
* Renders only when consent has not yet been given.
16+
*/
17+
export default function CookieConsentBanner() {
18+
const { t } = useTranslation();
19+
const { bannerVisible, grantConsent, denyConsent } = useConsent();
20+
21+
return (
22+
<AnimatePresence>
23+
{bannerVisible && (
24+
<motion.div
25+
role="dialog"
26+
aria-label={t('consent.bannerAriaLabel')}
27+
className="fixed bottom-0 inset-x-0 z-50 p-4 sm:p-6"
28+
initial={{ y: 100, opacity: 0 }}
29+
animate={{ y: 0, opacity: 1 }}
30+
exit={{ y: 100, opacity: 0 }}
31+
transition={{ type: 'spring', stiffness: 260, damping: 25 }}
32+
>
33+
<div className="max-w-4xl mx-auto rounded-2xl border border-(--color-glass-border) bg-(--color-glass-bg) backdrop-blur-xl shadow-2xl p-5 sm:p-6">
34+
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
35+
<div className="flex items-start gap-3 flex-1 min-w-0">
36+
<Cookie
37+
size={24}
38+
weight="fill"
39+
className="shrink-0 text-amber-500 mt-0.5"
40+
/>
41+
<p className="text-sm text-text-primary leading-relaxed">
42+
{t('consent.message')}{' '}
43+
<Link
44+
to="/privacy"
45+
className="underline text-[#3b82f6] hover:text-[#60a5fa] transition-colors"
46+
>
47+
{t('consent.privacyPolicy')}
48+
</Link>
49+
</p>
50+
</div>
51+
<div className="flex items-center gap-3 shrink-0 w-full sm:w-auto">
52+
<motion.button
53+
type="button"
54+
onClick={denyConsent}
55+
className="flex-1 sm:flex-none px-4 py-2 text-sm font-medium text-text-secondary border border-(--color-glass-border) rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
56+
whileHover={{ scale: 1.02 }}
57+
whileTap={{ scale: 0.98 }}
58+
>
59+
{t('consent.declineAll')}
60+
</motion.button>
61+
<motion.button
62+
type="button"
63+
onClick={grantConsent}
64+
className="flex-1 sm:flex-none px-4 py-2 text-sm font-medium text-white bg-[#3b82f6] rounded-lg hover:bg-[#2563eb] transition-colors cursor-pointer"
65+
whileHover={{ scale: 1.02 }}
66+
whileTap={{ scale: 0.98 }}
67+
>
68+
{t('consent.acceptAll')}
69+
</motion.button>
70+
</div>
71+
</div>
72+
</div>
73+
</motion.div>
74+
)}
75+
</AnimatePresence>
76+
);
77+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { describe, it, expect, beforeEach } from 'vitest';
8+
import { render, screen, fireEvent } from '@testing-library/react';
9+
import { MemoryRouter } from 'react-router-dom';
10+
import { I18nextProvider } from 'react-i18next';
11+
import i18n from '../i18n';
12+
import { ConsentProvider } from '../contexts/ConsentContext';
13+
import CookieConsentBanner from './CookieConsentBanner';
14+
15+
const STORAGE_KEY = 'bayanflow:cookie-consent';
16+
17+
function renderBanner() {
18+
return render(
19+
<MemoryRouter>
20+
<I18nextProvider i18n={i18n}>
21+
<ConsentProvider>
22+
<CookieConsentBanner />
23+
</ConsentProvider>
24+
</I18nextProvider>
25+
</MemoryRouter>
26+
);
27+
}
28+
29+
describe('CookieConsentBanner', () => {
30+
beforeEach(async () => {
31+
localStorage.clear();
32+
await i18n.changeLanguage('en');
33+
});
34+
35+
it('renders banner when no consent stored', () => {
36+
renderBanner();
37+
expect(screen.getByRole('dialog')).toBeInTheDocument();
38+
expect(screen.getByText(i18n.t('consent.message'))).toBeInTheDocument();
39+
});
40+
41+
it('does not render banner when consent is already stored', () => {
42+
localStorage.setItem(
43+
STORAGE_KEY,
44+
JSON.stringify({ analytics: true, timestamp: Date.now() })
45+
);
46+
renderBanner();
47+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
48+
});
49+
50+
it('renders accept and decline buttons', () => {
51+
renderBanner();
52+
expect(screen.getByText(i18n.t('consent.acceptAll'))).toBeInTheDocument();
53+
expect(screen.getByText(i18n.t('consent.declineAll'))).toBeInTheDocument();
54+
});
55+
56+
it('renders privacy policy link', () => {
57+
renderBanner();
58+
const link = screen.getByText(i18n.t('consent.privacyPolicy'));
59+
expect(link).toHaveAttribute('href', '/privacy');
60+
});
61+
62+
it('hides banner after accepting', async () => {
63+
renderBanner();
64+
expect(screen.getByRole('dialog')).toBeInTheDocument();
65+
66+
fireEvent.click(screen.getByText(i18n.t('consent.acceptAll')));
67+
68+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
69+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
70+
expect(stored.analytics).toBe(true);
71+
});
72+
73+
it('hides banner after declining', async () => {
74+
renderBanner();
75+
expect(screen.getByRole('dialog')).toBeInTheDocument();
76+
77+
fireEvent.click(screen.getByText(i18n.t('consent.declineAll')));
78+
79+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
80+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY));
81+
expect(stored.analytics).toBe(false);
82+
});
83+
84+
it('banner is accessible with proper role', () => {
85+
renderBanner();
86+
const dialog = screen.getByRole('dialog');
87+
expect(dialog).toHaveAttribute('aria-label');
88+
});
89+
});

src/components/Footer.jsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import {
1515
GITHUB_REPO_OWNER,
1616
GITHUB_REPO_URL,
1717
} from '../constants/githubRepo';
18+
import { useConsent } from '../hooks/useConsent.js';
1819

1920
function Footer() {
2021
const { t } = useTranslation();
2122
const navigate = useNavigate();
23+
const { resetConsent } = useConsent();
2224
const [version, setVersion] = useState(null);
2325
const currentYear = new Date().getFullYear();
2426

@@ -312,6 +314,15 @@ function Footer() {
312314
>
313315
{t('footer.terms')}
314316
</motion.button>
317+
<motion.button
318+
type="button"
319+
onClick={resetConsent}
320+
className="text-xs text-text-secondary hover:text-[#3b82f6] transition-colors"
321+
whileHover={{ scale: 1.02 }}
322+
whileTap={{ scale: 0.98 }}
323+
>
324+
{t('footer.cookiePreferences')}
325+
</motion.button>
315326
</div>
316327
</div>
317328
</div>

src/components/Footer.test.jsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { MemoryRouter } from 'react-router-dom';
1010
import { I18nextProvider } from 'react-i18next';
1111
import Footer from './Footer';
1212
import i18n from '../i18n';
13+
import { ConsentProvider } from '../contexts/ConsentContext.jsx';
1314

1415
const mockNavigate = vi.fn();
1516

@@ -25,7 +26,9 @@ function renderFooter() {
2526
return render(
2627
<MemoryRouter>
2728
<I18nextProvider i18n={i18n}>
28-
<Footer />
29+
<ConsentProvider>
30+
<Footer />
31+
</ConsentProvider>
2932
</I18nextProvider>
3033
</MemoryRouter>
3134
);
@@ -77,4 +80,19 @@ describe('Footer', () => {
7780
expect(link).toHaveAttribute('href', '/pro');
7881
fireEvent.click(link);
7982
});
83+
84+
it('cookie preferences button resets consent and reopens banner', () => {
85+
localStorage.setItem(
86+
'bayanflow:cookie-consent',
87+
JSON.stringify({ analytics: true, timestamp: Date.now() })
88+
);
89+
renderFooter();
90+
91+
const btn = screen.getByText(i18n.t('footer.cookiePreferences'));
92+
expect(btn).toBeInTheDocument();
93+
94+
fireEvent.click(btn);
95+
96+
expect(localStorage.getItem('bayanflow:cookie-consent')).toBeNull();
97+
});
8098
});

src/content/legal/privacy.en.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const PRIVACY_POLICY_SECTIONS = [
2323
'We designed Bayan Flow to minimize data collection. Depending on how you use the Service, the following may apply:',
2424
],
2525
list: [
26-
'Website analytics (PostHog): We use PostHog, a privacy-oriented analytics and product platform, on production and development deployments. PostHog collects aggregate usage metrics such as pages visited, referrer, browser type, device type, and user interactions with features like algorithm visualizations. PostHog uses a first-party proxy (e.bayanflow.com) to avoid ad-blocker interference. PostHog may assign a distinct visitor identifier; we treat this as pseudonymous data and disclose it for transparency. Session replay is enabled to help us understand and improve the user experience; text and media inputs are masked for privacy. We do not use a cookie consent banner for PostHog because it is configured for privacy-friendly, non-advertising analytics.',
26+
'Website analytics (PostHog): We use PostHog, a privacy-oriented analytics and product platform, on production and development deployments. PostHog collects aggregate usage metrics such as pages visited, referrer, browser type, device type, and user interactions with features like algorithm visualizations. PostHog uses a first-party proxy (e.bayanflow.com) to avoid ad-blocker interference. PostHog may assign a distinct visitor identifier; we treat this as pseudonymous data and disclose it for transparency. Session replay is enabled to help us understand and improve the user experience; text and media inputs are masked for privacy. We use a cookie consent banner to give you control over analytics tracking. You can accept or decline analytics cookies at any time via Cookie Preferences in the footer.',
2727
'Server logs (Cloudflare): Our hosting provider, Cloudflare Workers, automatically records standard edge access logs, which may include your IP address, browser user agent, requested URL, and timestamp. See Cloudflare’s privacy documentation at https://www.cloudflare.com/privacypolicy/.',
2828
'Local storage on your device: Preferences such as theme, language, sound settings, Python panel layout, custom Python test cases, swipe tutorial state, and full-screen mode are stored in your browser’s localStorage. This data stays on your device and is not transmitted to our servers.',
2929
'GitHub API: The header badge and footer may request public release metadata and repository statistics from GitHub (api.github.com). Those requests are made to GitHub and may expose standard network metadata such as your IP address and user agent to GitHub.',
@@ -48,7 +48,7 @@ export const PRIVACY_POLICY_SECTIONS = [
4848
],
4949
list: [
5050
'Legitimate interests — to operate, secure, and improve the Service (for example, hosting logs and privacy-friendly analytics).',
51-
'Your consent — when you choose to play an embedded YouTube video, run Python code in the browser, or sign in with Google.',
51+
'Your consent — for analytics cookies via our cookie consent banner, and when you choose to play an embedded YouTube video, run Python code in the browser, or sign in with Google.',
5252
],
5353
},
5454
{

0 commit comments

Comments
 (0)