Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docker/all-in-one/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ VITE_API_URL_SERVER=http://localhost:80/api
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_123456789
VITE_APP_NAME=Hi.Events

# Optional site-wide cookie consent banner (see frontend/.env.example)
VITE_COOKIE_CONSENT_ENABLED=false
VITE_COOKIE_CONSENT_DOMAIN=
VITE_COOKIE_CONSENT_TEXT=

# Backend variables
# These values may not be suitable for production environments.
# Please refer to the documentation for more information on how to configure these values
Expand Down
3 changes: 3 additions & 0 deletions docker/all-in-one/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ services:
- VITE_API_URL_SERVER=${VITE_API_URL_SERVER}
- VITE_STRIPE_PUBLISHABLE_KEY=${VITE_STRIPE_PUBLISHABLE_KEY}
- VITE_APP_NAME=${VITE_APP_NAME}
- VITE_COOKIE_CONSENT_ENABLED=${VITE_COOKIE_CONSENT_ENABLED:-}
- VITE_COOKIE_CONSENT_DOMAIN=${VITE_COOKIE_CONSENT_DOMAIN:-}
- VITE_COOKIE_CONSENT_TEXT=${VITE_COOKIE_CONSENT_TEXT:-}
- LOG_CHANNEL=${LOG_CHANNEL}
- QUEUE_CONNECTION=${QUEUE_CONNECTION}
- APP_KEY=${APP_KEY}
Expand Down
1 change: 1 addition & 0 deletions docker/e2e/docker-compose.e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ services:
VITE_FRONTEND_URL: 'http://localhost:8123'
VITE_STRIPE_PUBLISHABLE_KEY: '${STRIPE_PUBLIC_KEY:-}'
VITE_APP_NAME: 'Hi.Events'
VITE_COOKIE_CONSENT_ENABLED: 'true'
depends_on:
backend:
condition: service_healthy
Expand Down
2 changes: 2 additions & 0 deletions e2e/fixtures/auth.fixture.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Browser, BrowserContextOptions, Page } from '@playwright/test';
import { BASE_URL, cookieDomain } from '../utils/env';
import { grantedConsentCookie } from '../utils/consent';

export function buildStorageState(token: string): BrowserContextOptions['storageState'] {
return {
Expand All @@ -14,6 +15,7 @@ export function buildStorageState(token: string): BrowserContextOptions['storage
secure: true,
sameSite: 'None',
},
grantedConsentCookie(),
],
origins: [],
};
Expand Down
2 changes: 2 additions & 0 deletions e2e/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineConfig, devices } from '@playwright/test';
import { config as loadEnv } from 'dotenv';
import { grantedConsentCookie } from './utils/consent';

loadEnv();

Expand All @@ -24,6 +25,7 @@ export default defineConfig({
video: 'retain-on-failure',
screenshot: 'only-on-failure',
testIdAttribute: 'data-testid',
storageState: { cookies: [grantedConsentCookie()], origins: [] },
},
projects: [
{
Expand Down
39 changes: 39 additions & 0 deletions e2e/tests/events/cookie-consent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test, expect } from '../../fixtures';
import { PublicEventPage } from '../../pages/public-event.page';
import { createLiveEventWithFreeTicket } from '../../api/factory';
import { CONSENT_COOKIE, noStoredConsent } from '../../utils/consent';

test.use({ storageState: noStoredConsent });

test.describe('cookie consent banner', () => {
test('a visitor saves category choices and is not asked again', async ({ page, api, account }) => {
const event = await createLiveEventWithFreeTicket(api, account.organizerId);
const publicEvent = new PublicEventPage(page);
await publicEvent.goto(event.eventId, event.slug);

const banner = page.getByRole('dialog', { name: 'Cookie settings' });
await expect(banner).toBeVisible();
await banner.getByTestId('cookie-consent-more-choices').click();

await expect(banner.getByRole('switch', { name: 'Essential' })).toBeDisabled();
const advertising = banner.getByRole('switch', { name: 'Advertising' });
await advertising.click({ force: true });
await expect(advertising).toBeChecked();
await expect(banner.getByRole('switch', { name: 'Analytics' })).not.toBeChecked();

await banner.getByTestId('cookie-consent-save').click();
await expect(banner).toBeHidden();

const consent = (await page.context().cookies()).find((cookie) => cookie.name === CONSENT_COOKIE);
expect(consent?.value).toBe('analytics=0&advertising=1');

await publicEvent.goto(event.eventId, event.slug);
await expect(page.getByText(event.title).first()).toBeVisible();
await expect(banner).toBeHidden();

await page.getByRole('button', { name: 'Cookie settings' }).click();
await expect(banner).toBeVisible();
await expect(banner.getByRole('switch', { name: 'Advertising' })).toBeChecked();
await expect(banner.getByRole('switch', { name: 'Analytics' })).not.toBeChecked();
});
});
20 changes: 20 additions & 0 deletions e2e/utils/consent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { BrowserContextOptions } from '@playwright/test';
import { cookieDomain } from './env';

type StorageState = Exclude<BrowserContextOptions['storageState'], string | undefined>;
type StorageCookie = StorageState['cookies'][number];

export const CONSENT_COOKIE = 'hi_cookie_consent';

export const grantedConsentCookie = (): StorageCookie => ({
name: CONSENT_COOKIE,
value: 'analytics=1&advertising=1',
domain: cookieDomain(),
path: '/',
expires: -1,
httpOnly: false,
secure: false,
sameSite: 'Lax',
});

export const noStoredConsent: StorageState = { cookies: [], origins: [] };
9 changes: 9 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,12 @@ SENTRY_ENABLE_LOGS=false
SENTRY_LOG_LEVEL=info
SENTRY_RELEASE=
SENTRY_TRACES_SAMPLE_RATE=0

VITE_GOOGLE_ADS_CONVERSION_ID=
VITE_GOOGLE_ADS_CONVERSION_LABELS=

VITE_COOKIE_CONSENT_ENABLED=false
VITE_COOKIE_CONSENT_DOMAIN=
VITE_COOKIE_CONSENT_TEXT=

VITE_FATHOM_SITE_ID=
22 changes: 21 additions & 1 deletion frontend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ async function main() {
app.use(base, sirv(path.join(__dirname, "./dist/client"), { extensions: [] }));
}

const googleConsentDefaults = (consentCookie) => {
const consent = new URLSearchParams(typeof consentCookie === 'string' ? consentCookie : '');
if (!consent.has('analytics') && !consent.has('advertising')) {
return "ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied',analytics_storage:'denied',wait_for_update:500";
}
const advertising = consent.get('advertising') === '1' ? 'granted' : 'denied';
const analytics = consent.get('analytics') === '1' ? 'granted' : 'denied';
return `ad_storage:'${advertising}',ad_user_data:'${advertising}',ad_personalization:'${advertising}',analytics_storage:'${analytics}'`;
};

const getViteEnvironmentVariables = () => {
const envVars = {};
for (const key in process.env) {
Expand Down Expand Up @@ -160,9 +170,19 @@ Sitemap: ${frontendUrl}/sitemap.xml
const envVariablesHtml = `<script>window.hievents = ${getViteEnvironmentVariables()};</script>`;

const headSnippets = [];
if (process.env.VITE_COOKIE_CONSENT_ENABLED === 'true') {
headSnippets.push(`<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('consent','default',{${googleConsentDefaults(req.cookies?.hi_cookie_consent)}});</script>`);
}
if (process.env.VITE_GOOGLE_ADS_CONVERSION_ID) {
const conversionId = encodeURIComponent(process.env.VITE_GOOGLE_ADS_CONVERSION_ID);
headSnippets.push(`
<script async src="https://www.googletagmanager.com/gtag/js?id=${conversionId}"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${conversionId}');</script>
`);
}
if (process.env.VITE_FATHOM_SITE_ID) {
headSnippets.push(`
<script src="https://cdn.usefathom.com/script.js" data-spa="auto" data-site="${process.env.VITE_FATHOM_SITE_ID}" defer></script>
<script src="https://cdn.usefathom.com/script.js" data-spa="auto" data-site="${encodeURIComponent(process.env.VITE_FATHOM_SITE_ID)}" defer></script>
`);
}

Expand Down
17 changes: 3 additions & 14 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {FC, PropsWithChildren, useCallback, useEffect} from "react";
import React, {FC, PropsWithChildren, useEffect} from "react";
import {MantineProvider, v8CssVariablesResolver} from "@mantine/core";
import {Notifications} from "@mantine/notifications";
import {i18n} from "@lingui/core";
Expand All @@ -22,7 +22,7 @@ import {StartupChecks} from "./StartupChecks.tsx";
import {ThirdPartyScripts} from "./components/common/ThirdPartyScripts";
import {getConfig} from "./utilites/config.ts";
import {CookieConsentBanner} from "./components/common/CookieConsentBanner";
import {isConsentPending, setConsentState, updateGoogleConsentMode} from "./utilites/trackingPixels/consent";
import {isConsentBannerEnabled} from "./utilites/cookieConsent";

declare global {
interface Window {
Expand All @@ -40,15 +40,6 @@ export const App: FC<
}>
> = (props) => {
const [isLoadedOnBrowser, setIsLoadedOnBrowser] = React.useState(false);
const showGlobalConsentBanner = getConfig('VITE_COOKIE_CONSENT_ENABLED') === 'true'
&& !isSsr() && isConsentPending();

const handleGlobalConsent = useCallback((granted: boolean) => {
setConsentState(granted ? 'granted' : 'denied');
updateGoogleConsentMode(granted);
window.dispatchEvent(new CustomEvent('hi_consent_change', {detail: {granted}}));
}, []);

useEffect(() => {
setIsLoadedOnBrowser(!isSsr());
}, []);
Expand Down Expand Up @@ -100,9 +91,7 @@ export const App: FC<
{props.children}
</ModalsProvider>
<Notifications pauseResetOnHover="notification"/>
{showGlobalConsentBanner && (
<CookieConsentBanner onConsent={handleGlobalConsent}/>
)}
{isConsentBannerEnabled() && <CookieConsentBanner/>}
</HydrationBoundary>
</QueryClientProvider>
</DatesProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
position: fixed;
bottom: 20px;
right: 20px;
z-index: 900;
max-width: 340px;
z-index: 199;
width: calc(100vw - 40px);
max-width: 360px;
padding: 16px;
border-radius: 12px;
background: var(--event-content-bg-color, var(--organizer-content-bg-color, rgba(255, 255, 255, 0.97)));
Expand All @@ -16,10 +17,15 @@
bottom: 12px;
right: 12px;
left: 12px;
width: auto;
max-width: none;
}
}

.expanded {
max-width: 440px;
}

@keyframes slideUp {
from {
opacity: 0;
Expand All @@ -31,34 +37,11 @@
}
}

.closeButton {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--event-secondary-color, var(--organizer-secondary-color, #6b7280));
cursor: pointer;
transition: background 0.15s;

&:hover {
background: var(--event-accent-soft, var(--organizer-accent-soft, rgba(0, 0, 0, 0.05)));
}
}

.content {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 12px;
padding-right: 16px;
}

.iconWrapper {
Expand All @@ -83,15 +66,50 @@
}
}

.categories {
display: flex;
flex-direction: column;
margin-bottom: 12px;
border-top: 1px solid var(--event-border-color, var(--organizer-border-color, rgba(0, 0, 0, 0.08)));
}

.category {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--event-border-color, var(--organizer-border-color, rgba(0, 0, 0, 0.08)));
}

.categoryText {
display: flex;
flex-direction: column;
gap: 2px;
}

.categoryLabel {
font-size: 13px;
font-weight: 600;
color: var(--event-primary-text-color, var(--organizer-primary-text-color, #111827));
}

.categoryDescription {
font-size: 12px;
line-height: 1.4;
color: var(--event-secondary-color, var(--organizer-secondary-color, #6b7280));
}

.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}

.acceptButton,
.declineButton {
padding: 6px 14px;
.primaryButton,
.ghostButton {
padding: 7px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
Expand All @@ -104,12 +122,12 @@
}
}

.acceptButton {
.primaryButton {
background: var(--event-primary-color, var(--organizer-primary-color, #8b5cf6));
color: var(--event-accent-contrast, var(--organizer-accent-contrast, #ffffff));
}

.declineButton {
.ghostButton {
background: transparent;
color: var(--event-secondary-color, var(--organizer-secondary-color, #6b7280));
border: 1px solid var(--event-border-color, var(--organizer-border-color, rgba(0, 0, 0, 0.1)));
Expand Down
Loading
Loading