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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bigcommerce/catalyst-core": patch
---

Gate `catalyst.visitorId`, `catalyst.visitId`, and `currencyCode` cookies behind shopper consent. The visitor and visit cookies now require measurement consent and the currency preference cookie requires functionality consent. When consent is absent, existing analytics cookies are deleted on the next request. When measurement consent is granted mid-session, a new `startVisit` server action sets the cookies and fires the server-side `visitStartedEvent` immediately rather than waiting for the next full-page navigation.
52 changes: 52 additions & 0 deletions core/components/consent-manager/_actions/start-visit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use server';

import { headers } from 'next/headers';
import { validate as isUuid, v4 as uuidv4 } from 'uuid';

import {
getVisitIdCookie,
getVisitorIdCookie,
setVisitIdCookie,
setVisitorIdCookie,
} from '~/lib/analytics/bigcommerce';
import { sendVisitStartedEvent } from '~/lib/analytics/bigcommerce/data-events';
import { getConsentCookie } from '~/lib/consent-manager/cookies/server';

// Starts an analytics visit after the shopper grants measurement consent.
// The proxy only starts visits on full-page navigations, so without this a
// visit granted mid-session wouldn't be recorded until the next hard reload.
// Idempotent: validates consent against the cookie and no-ops if a visit is
// already active (the proxy may have started one while handling this request).
export async function startVisit(): Promise<void> {
const consent = await getConsentCookie();

if (!consent?.['c.measurement']) {
return;
}

const existingVisitId = await getVisitIdCookie();

if (existingVisitId != null && isUuid(existingVisitId)) {
return;
}

const existingVisitorId = await getVisitorIdCookie();
const visitorId = existingVisitorId && isUuid(existingVisitorId) ? existingVisitorId : uuidv4();
const visitId = uuidv4();

await setVisitorIdCookie(visitorId);
await setVisitIdCookie(visitId);

const requestHeaders = await headers();

await sendVisitStartedEvent({
initiator: { visitId, visitorId },
request: {
// The action is invoked from the page the shopper accepted consent on,
// so the referer is the URL of the visit being started.
url: requestHeaders.get('referer') ?? '',
refererUrl: '',
userAgent: requestHeaders.get('user-agent') ?? '',
},
});
}
2 changes: 2 additions & 0 deletions core/components/consent-manager/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PropsWithChildren } from 'react';
import { ConsentManagerDialog } from './consent-manager-dialog';
import { type C15tScripts, ConsentManagerProvider } from './consent-providers';
import { CookieBanner } from './cookie-banner';
import { StartVisitOnConsent } from './start-visit-on-consent';

interface ConsentManagerProps extends PropsWithChildren {
scripts: C15tScripts;
Expand All @@ -18,6 +19,7 @@ export function ConsentManager({
}: ConsentManagerProps) {
return (
<ConsentManagerProvider isCookieConsentEnabled={isCookieConsentEnabled} scripts={scripts}>
<StartVisitOnConsent />
<ConsentManagerDialog />
<CookieBanner privacyPolicyUrl={privacyPolicyUrl} />
{children}
Expand Down
55 changes: 55 additions & 0 deletions core/components/consent-manager/start-visit-on-consent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
'use client';

import { useConsentManager } from '@c15t/nextjs/client';
import { useEffect, useRef } from 'react';

import { getConsentCookie } from '~/lib/consent-manager/cookies/client';

import { startVisit } from './_actions/start-visit';

export function StartVisitOnConsent() {
const { consents, hasConsented } = useConsentManager();
const isMeasurementGranted = hasConsented() && consents.measurement;
const wasMeasurementGranted = useRef(isMeasurementGranted);

useEffect(() => {
const shouldStartVisit = isMeasurementGranted && !wasMeasurementGranted.current;

wasMeasurementGranted.current = isMeasurementGranted;

if (!shouldStartVisit) {
return;
}

// c15t updates its React state before persisting the consent cookie. The
// startVisit action validates consent from the cookie server-side, so poll
// document.cookie directly — only dispatch once the cookie is readable.
const tryStartVisit = () => {
if (getConsentCookie()?.['c.measurement']) {
// eslint-disable-next-line no-console
void startVisit().catch(console.error);

return true;
}

return false;
};

if (tryStartVisit()) {
return;
}

let attempts = 0;
const interval = setInterval(() => {
attempts += 1;

if (tryStartVisit() || attempts >= 20) {
clearInterval(interval);
}
}, 100);

return () => clearInterval(interval);
}, [isMeasurementGranted]);

return null;
}
12 changes: 12 additions & 0 deletions core/lib/analytics/bigcommerce/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export async function setVisitorIdCookie(visitorId: string): Promise<void> {
});
}

export async function deleteVisitorIdCookie(): Promise<void> {
const cookieStore = await cookies();

cookieStore.delete(VISITOR_COOKIE_NAME);
}

export async function getVisitIdCookie(): Promise<string | undefined> {
const cookieStore = await cookies();

Expand All @@ -38,3 +44,9 @@ export async function setVisitIdCookie(visitId: string): Promise<void> {
maxAge: VISIT_DURATION,
});
}

export async function deleteVisitIdCookie(): Promise<void> {
const cookieStore = await cookies();

cookieStore.delete(VISIT_COOKIE_NAME);
}
18 changes: 18 additions & 0 deletions core/lib/consent-manager/has-consent-for.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getConsentCookie } from './cookies/server';

type ConsentCategory = 'functionality' | 'marketing' | 'measurement';

// Server-side check for whether cookies of a given consent category may be stored.
// The shopper's consent cookie is the source of truth. Absent a consent cookie,
// treats consent as not given — the client-side consent manager will write the
// cookie once the shopper decides (or c15t auto-grants when consent is disabled),
// and startVisit handles recording the visit at that point.
export async function hasConsentFor(category: ConsentCategory) {
const consent = await getConsentCookie();

if (consent) {
return consent[`c.${category}`];
}

return false;
}
7 changes: 7 additions & 0 deletions core/lib/currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cookies } from 'next/headers';

import type { CurrencyCode } from '~/components/header/fragment';
import { CurrencyCodeSchema } from '~/components/header/schema';
import { hasConsentFor } from '~/lib/consent-manager/has-consent-for';

export async function getPreferredCurrencyCode(): Promise<CurrencyCode | undefined> {
const cookieStore = await cookies();
Expand All @@ -19,6 +20,12 @@ export async function getPreferredCurrencyCode(): Promise<CurrencyCode | undefin
}

export async function setPreferredCurrencyCode(currencyCode: CurrencyCode): Promise<void> {
// The currency preference is a functionality cookie; without consent the
// selected currency still applies to the cart but the preference isn't stored.
if (!(await hasConsentFor('functionality'))) {
return;
}

const cookieStore = await cookies();

cookieStore.set('currencyCode', currencyCode, {
Expand Down
28 changes: 25 additions & 3 deletions core/proxies/with-analytics-cookies.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { validate as isUuid, v4 as uuidv4 } from 'uuid';

import {
deleteVisitIdCookie,
deleteVisitorIdCookie,
getVisitIdCookie,
getVisitorIdCookie,
setVisitIdCookie,
setVisitorIdCookie,
} from '~/lib/analytics/bigcommerce';
import { sendVisitStartedEvent } from '~/lib/analytics/bigcommerce/data-events';
import { hasConsentFor } from '~/lib/consent-manager/has-consent-for';

import { ProxyFactory } from './compose-proxies';

Expand All @@ -15,8 +18,25 @@ export const withAnalyticsCookies: ProxyFactory = (next) => {
const existingVisitorId = await getVisitorIdCookie();
const existingVisitId = await getVisitIdCookie();

if (!(await hasConsentFor('measurement'))) {
// No measurement consent: never set or refresh analytics cookies, and
// remove any left over from before consent was withdrawn. Once the
// shopper grants consent, the startVisit server action (triggered by the
// consent manager) creates the cookies and fires the visit event.
if (existingVisitorId != null) {
await deleteVisitorIdCookie();
}

if (existingVisitId != null) {
await deleteVisitIdCookie();
}

return next(request, event);
}

const isPrefetch = request.headers.get('Next-Router-Prefetch') === '1';
const isRSC = request.headers.get('RSC') === '1';
const isServerAction = request.headers.get('Next-Action') !== null;

const visitorId = existingVisitorId && isUuid(existingVisitorId) ? existingVisitorId : uuidv4();

Expand All @@ -27,15 +47,17 @@ export const withAnalyticsCookies: ProxyFactory = (next) => {
if (hasValidVisit) {
// Sliding window: refresh the TTL on every request
await setVisitIdCookie(existingVisitId);
} else if (!isPrefetch && !isRSC) {
} else if (!isPrefetch && !isRSC && !isServerAction) {
// New visit on a real navigation: create cookie and fire event
const visitId = uuidv4();

await setVisitIdCookie(visitId);
event.waitUntil(recordNewVisit(request, visitorId, visitId));
}
// Prefetch/RSC with no valid visit: skip entirely so the
// subsequent real navigation properly detects a new visit.
// Prefetch/RSC/server-action with no valid visit: skip entirely so the
// subsequent real navigation properly detects a new visit. Server actions
// must not start visits: cookies set here aren't visible to the action
// handler, so the startVisit action would otherwise fire a duplicate event.

return next(request, event);
};
Expand Down
62 changes: 59 additions & 3 deletions core/tests/ui/e2e/analytics-session.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';

import { testEnv } from '~/tests/environment';
import { expect, test } from '~/tests/fixtures';

const CookieSchema = z.object({
Expand All @@ -8,8 +9,25 @@ const CookieSchema = z.object({
expires: z.number().optional(),
});

// The consent cookie uses c15t's compact format; only granted categories are present.
const acceptedConsentCookie = () => ({
name: 'c15t-consent',
value: `i.t:${Date.now()},c.necessary:1,c.functionality:1,c.marketing:1,c.measurement:1`,
url: testEnv.PLAYWRIGHT_TEST_BASE_URL,
});

const declinedConsentCookie = () => ({
name: 'c15t-consent',
value: `i.t:${Date.now()},c.necessary:1`,
url: testEnv.PLAYWRIGHT_TEST_BASE_URL,
});

test.describe('Analytics cookies proxy', () => {
test('sets visitorId and visitId cookies on first visit', async ({ page, context }) => {
test('sets visitorId and visitId cookies on first visit with measurement consent', async ({
page,
context,
}) => {
await context.addCookies([acceptedConsentCookie()]);
await page.goto('/');

const cookies = await context.cookies();
Expand All @@ -23,10 +41,11 @@ test.describe('Analytics cookies proxy', () => {
});

test('visitId cookie has correct expiry', async ({ page, context }) => {
await context.addCookies([acceptedConsentCookie()]);
await page.goto('/');

const cookies = await context.cookies();
const visitId = cookies.find((c) => c.name === 'visitId');
const visitId = cookies.find((c) => c.name === 'catalyst.visitId');
const parsed = CookieSchema.safeParse(visitId);

if (parsed.success && parsed.data.expires) {
Expand All @@ -39,10 +58,11 @@ test.describe('Analytics cookies proxy', () => {
});

test('visitorId cookie has correct expiry', async ({ page, context }) => {
await context.addCookies([acceptedConsentCookie()]);
await page.goto('/');

const cookies = await context.cookies();
const visitorId = cookies.find((c) => c.name === 'visitorId');
const visitorId = cookies.find((c) => c.name === 'catalyst.visitorId');
const parsed = CookieSchema.safeParse(visitorId);

if (parsed.success && parsed.data.expires) {
Expand All @@ -55,13 +75,15 @@ test.describe('Analytics cookies proxy', () => {
});

test('creates a new visitId after expiry', async ({ page, context }) => {
await context.addCookies([acceptedConsentCookie()]);
await page.goto('/');

let cookies = await context.cookies();
const oldVisitId = cookies.find((c) => c.name === 'catalyst.visitId')?.value;

// Simulate expiry by clearing the visitId cookie
await context.clearCookies();
await context.addCookies([acceptedConsentCookie()]);
await page.reload();

cookies = await context.cookies();
Expand All @@ -71,4 +93,38 @@ test.describe('Analytics cookies proxy', () => {
expect(newVisitId).toBeDefined();
expect(newVisitId).not.toBe(oldVisitId);
});

test('does not set analytics cookies when measurement consent is declined', async ({
page,
context,
}) => {
await context.addCookies([declinedConsentCookie()]);
await page.goto('/');

const cookies = await context.cookies();

expect(cookies.find((c) => c.name === 'catalyst.visitorId')).toBeUndefined();
expect(cookies.find((c) => c.name === 'catalyst.visitId')).toBeUndefined();
});

test('removes analytics cookies when measurement consent is withdrawn', async ({
page,
context,
}) => {
await context.addCookies([acceptedConsentCookie()]);
await page.goto('/');

let cookies = await context.cookies();

expect(cookies.find((c) => c.name === 'catalyst.visitorId')).toBeDefined();
expect(cookies.find((c) => c.name === 'catalyst.visitId')).toBeDefined();

await context.addCookies([declinedConsentCookie()]);
await page.reload();

cookies = await context.cookies();

expect(cookies.find((c) => c.name === 'catalyst.visitorId')).toBeUndefined();
expect(cookies.find((c) => c.name === 'catalyst.visitId')).toBeUndefined();
});
});
Loading