Skip to content

Commit 5a7a44f

Browse files
chanceaclarkclaude
andcommitted
feat(core): TRAC-813 Gate analytics cookies behind shopper consent
When the store's cookie consent setting is enabled, the catalyst.visitorId and catalyst.visitId analytics cookies are no longer set or refreshed until the shopper grants measurement consent, and leftover cookies are deleted once consent is withdrawn. The currencyCode preference cookie now requires functionality consent. With no consent decision recorded yet, cookies are only allowed when the store does not require cookie consent. The proxy only starts visits on full page navigations, so granting consent mid-session now triggers a startVisit server action that sets the cookies and fires the server-side visitStartedEvent immediately. Server action POSTs no longer start visits in the proxy, which would otherwise duplicate the event fired by the action. Fixes TRAC-813 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3cec674 commit 5a7a44f

9 files changed

Lines changed: 234 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst-core": patch
3+
---
4+
5+
Gate Catalyst cookies behind shopper consent when the store's cookie consent setting is enabled. The `catalyst.visitorId` and `catalyst.visitId` analytics cookies are no longer set (and are removed if previously set) until the shopper grants measurement consent, and the `currencyCode` preference cookie requires functionality consent. When measurement consent is granted mid-session, a new `startVisit` server action immediately starts the visit and fires the server-side `visitStartedEvent`. Stores with cookie consent disabled are unaffected.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use server';
2+
3+
import { headers } from 'next/headers';
4+
import { validate as isUuid, v4 as uuidv4 } from 'uuid';
5+
6+
import {
7+
getVisitIdCookie,
8+
getVisitorIdCookie,
9+
setVisitIdCookie,
10+
setVisitorIdCookie,
11+
} from '~/lib/analytics/bigcommerce';
12+
import { sendVisitStartedEvent } from '~/lib/analytics/bigcommerce/data-events';
13+
import { getConsentCookie } from '~/lib/consent-manager/cookies/server';
14+
15+
// Starts an analytics visit after the shopper grants measurement consent.
16+
// The proxy only starts visits on full-page navigations, so without this a
17+
// visit granted mid-session wouldn't be recorded until the next hard reload.
18+
// Idempotent: validates consent against the cookie and no-ops if a visit is
19+
// already active (the proxy may have started one while handling this request).
20+
export async function startVisit(): Promise<void> {
21+
const consent = await getConsentCookie();
22+
23+
if (!consent?.['c.measurement']) {
24+
return;
25+
}
26+
27+
const existingVisitId = await getVisitIdCookie();
28+
29+
if (existingVisitId != null && isUuid(existingVisitId)) {
30+
return;
31+
}
32+
33+
const existingVisitorId = await getVisitorIdCookie();
34+
const visitorId = existingVisitorId && isUuid(existingVisitorId) ? existingVisitorId : uuidv4();
35+
const visitId = uuidv4();
36+
37+
await setVisitorIdCookie(visitorId);
38+
await setVisitIdCookie(visitId);
39+
40+
const requestHeaders = await headers();
41+
42+
await sendVisitStartedEvent({
43+
initiator: { visitId, visitorId },
44+
request: {
45+
// The action is invoked from the page the shopper accepted consent on,
46+
// so the referer is the URL of the visit being started.
47+
url: requestHeaders.get('referer') ?? '',
48+
refererUrl: '',
49+
userAgent: requestHeaders.get('user-agent') ?? '',
50+
},
51+
});
52+
}

core/components/consent-manager/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { PropsWithChildren } from 'react';
33
import { ConsentManagerDialog } from './consent-manager-dialog';
44
import { type C15tScripts, ConsentManagerProvider } from './consent-providers';
55
import { CookieBanner } from './cookie-banner';
6+
import { StartVisitOnConsent } from './start-visit-on-consent';
67

78
interface ConsentManagerProps extends PropsWithChildren {
89
scripts: C15tScripts;
@@ -18,6 +19,7 @@ export function ConsentManager({
1819
}: ConsentManagerProps) {
1920
return (
2021
<ConsentManagerProvider isCookieConsentEnabled={isCookieConsentEnabled} scripts={scripts}>
22+
<StartVisitOnConsent />
2123
<ConsentManagerDialog />
2224
<CookieBanner privacyPolicyUrl={privacyPolicyUrl} />
2325
{children}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use client';
2+
3+
import { useConsentManager } from '@c15t/nextjs/client';
4+
import { useEffect, useRef } from 'react';
5+
6+
import { getConsentCookie } from '~/lib/consent-manager/cookies/client';
7+
8+
import { startVisit } from './_actions/start-visit';
9+
10+
export function StartVisitOnConsent() {
11+
const { consents, hasConsented } = useConsentManager();
12+
const isMeasurementGranted = hasConsented() && consents.measurement;
13+
const wasMeasurementGranted = useRef(isMeasurementGranted);
14+
15+
useEffect(() => {
16+
const shouldStartVisit = isMeasurementGranted && !wasMeasurementGranted.current;
17+
18+
wasMeasurementGranted.current = isMeasurementGranted;
19+
20+
if (!shouldStartVisit) {
21+
return;
22+
}
23+
24+
// c15t updates its React state before persisting the consent cookie. The
25+
// startVisit action validates consent from the cookie server-side, so poll
26+
// document.cookie directly — only dispatch once the cookie is readable.
27+
const tryStartVisit = () => {
28+
if (getConsentCookie()?.['c.measurement']) {
29+
void startVisit();
30+
31+
return true;
32+
}
33+
34+
return false;
35+
};
36+
37+
if (tryStartVisit()) {
38+
return;
39+
}
40+
41+
let attempts = 0;
42+
const interval = setInterval(() => {
43+
attempts += 1;
44+
45+
if (tryStartVisit() || attempts >= 20) {
46+
clearInterval(interval);
47+
}
48+
}, 100);
49+
50+
return () => clearInterval(interval);
51+
}, [isMeasurementGranted]);
52+
53+
return null;
54+
}

core/lib/analytics/bigcommerce/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export async function setVisitorIdCookie(visitorId: string): Promise<void> {
2222
});
2323
}
2424

25+
export async function deleteVisitorIdCookie(): Promise<void> {
26+
const cookieStore = await cookies();
27+
28+
cookieStore.delete(VISITOR_COOKIE_NAME);
29+
}
30+
2531
export async function getVisitIdCookie(): Promise<string | undefined> {
2632
const cookieStore = await cookies();
2733

@@ -38,3 +44,9 @@ export async function setVisitIdCookie(visitId: string): Promise<void> {
3844
maxAge: VISIT_DURATION,
3945
});
4046
}
47+
48+
export async function deleteVisitIdCookie(): Promise<void> {
49+
const cookieStore = await cookies();
50+
51+
cookieStore.delete(VISIT_COOKIE_NAME);
52+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { getConsentCookie } from './cookies/server';
2+
3+
type ConsentCategory = 'functionality' | 'marketing' | 'measurement';
4+
5+
// Server-side check for whether cookies of a given consent category may be stored.
6+
// The shopper's consent cookie is the source of truth. Absent a consent cookie,
7+
// treats consent as not given — the client-side consent manager will write the
8+
// cookie once the shopper decides (or c15t auto-grants when consent is disabled),
9+
// and startVisit handles recording the visit at that point.
10+
export async function hasConsentFor(category: ConsentCategory) {
11+
const consent = await getConsentCookie();
12+
13+
if (consent) {
14+
return consent[`c.${category}`];
15+
}
16+
17+
return false;
18+
}

core/lib/currency.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { cookies } from 'next/headers';
44

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

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

2122
export async function setPreferredCurrencyCode(currencyCode: CurrencyCode): Promise<void> {
23+
// The currency preference is a functionality cookie; without consent the
24+
// selected currency still applies to the cart but the preference isn't stored.
25+
if (!(await hasConsentFor('functionality'))) {
26+
return;
27+
}
28+
2229
const cookieStore = await cookies();
2330

2431
cookieStore.set('currencyCode', currencyCode, {

core/proxies/with-analytics-cookies.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { validate as isUuid, v4 as uuidv4 } from 'uuid';
22

33
import {
4+
deleteVisitIdCookie,
5+
deleteVisitorIdCookie,
46
getVisitIdCookie,
57
getVisitorIdCookie,
68
setVisitIdCookie,
79
setVisitorIdCookie,
810
} from '~/lib/analytics/bigcommerce';
911
import { sendVisitStartedEvent } from '~/lib/analytics/bigcommerce/data-events';
12+
import { hasConsentFor } from '~/lib/consent-manager/has-consent-for';
1013

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

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

21+
if (!(await hasConsentFor('measurement'))) {
22+
// No measurement consent: never set or refresh analytics cookies, and
23+
// remove any left over from before consent was withdrawn. Once the
24+
// shopper grants consent, the startVisit server action (triggered by the
25+
// consent manager) creates the cookies and fires the visit event.
26+
if (existingVisitorId != null) {
27+
await deleteVisitorIdCookie();
28+
}
29+
30+
if (existingVisitId != null) {
31+
await deleteVisitIdCookie();
32+
}
33+
34+
return next(request, event);
35+
}
36+
1837
const isPrefetch = request.headers.get('Next-Router-Prefetch') === '1';
1938
const isRSC = request.headers.get('RSC') === '1';
39+
const isServerAction = request.headers.get('Next-Action') !== null;
2040

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

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

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

4062
return next(request, event);
4163
};

core/tests/ui/e2e/analytics-session.spec.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod';
22

3+
import { testEnv } from '~/tests/environment';
34
import { expect, test } from '~/tests/fixtures';
45

56
const CookieSchema = z.object({
@@ -8,8 +9,25 @@ const CookieSchema = z.object({
89
expires: z.number().optional(),
910
});
1011

12+
// The consent cookie uses c15t's compact format; only granted categories are present.
13+
const acceptedConsentCookie = () => ({
14+
name: 'c15t-consent',
15+
value: `i.t:${Date.now()},c.necessary:1,c.functionality:1,c.marketing:1,c.measurement:1`,
16+
url: testEnv.PLAYWRIGHT_TEST_BASE_URL,
17+
});
18+
19+
const declinedConsentCookie = () => ({
20+
name: 'c15t-consent',
21+
value: `i.t:${Date.now()},c.necessary:1`,
22+
url: testEnv.PLAYWRIGHT_TEST_BASE_URL,
23+
});
24+
1125
test.describe('Analytics cookies proxy', () => {
12-
test('sets visitorId and visitId cookies on first visit', async ({ page, context }) => {
26+
test('sets visitorId and visitId cookies on first visit with measurement consent', async ({
27+
page,
28+
context,
29+
}) => {
30+
await context.addCookies([acceptedConsentCookie()]);
1331
await page.goto('/');
1432

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

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

2847
const cookies = await context.cookies();
29-
const visitId = cookies.find((c) => c.name === 'visitId');
48+
const visitId = cookies.find((c) => c.name === 'catalyst.visitId');
3049
const parsed = CookieSchema.safeParse(visitId);
3150

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

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

4464
const cookies = await context.cookies();
45-
const visitorId = cookies.find((c) => c.name === 'visitorId');
65+
const visitorId = cookies.find((c) => c.name === 'catalyst.visitorId');
4666
const parsed = CookieSchema.safeParse(visitorId);
4767

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

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

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

6384
// Simulate expiry by clearing the visitId cookie
6485
await context.clearCookies();
86+
await context.addCookies([acceptedConsentCookie()]);
6587
await page.reload();
6688

6789
cookies = await context.cookies();
@@ -71,4 +93,38 @@ test.describe('Analytics cookies proxy', () => {
7193
expect(newVisitId).toBeDefined();
7294
expect(newVisitId).not.toBe(oldVisitId);
7395
});
96+
97+
test('does not set analytics cookies when measurement consent is declined', async ({
98+
page,
99+
context,
100+
}) => {
101+
await context.addCookies([declinedConsentCookie()]);
102+
await page.goto('/');
103+
104+
const cookies = await context.cookies();
105+
106+
expect(cookies.find((c) => c.name === 'catalyst.visitorId')).toBeUndefined();
107+
expect(cookies.find((c) => c.name === 'catalyst.visitId')).toBeUndefined();
108+
});
109+
110+
test('removes analytics cookies when measurement consent is withdrawn', async ({
111+
page,
112+
context,
113+
}) => {
114+
await context.addCookies([acceptedConsentCookie()]);
115+
await page.goto('/');
116+
117+
let cookies = await context.cookies();
118+
119+
expect(cookies.find((c) => c.name === 'catalyst.visitorId')).toBeDefined();
120+
expect(cookies.find((c) => c.name === 'catalyst.visitId')).toBeDefined();
121+
122+
await context.addCookies([declinedConsentCookie()]);
123+
await page.reload();
124+
125+
cookies = await context.cookies();
126+
127+
expect(cookies.find((c) => c.name === 'catalyst.visitorId')).toBeUndefined();
128+
expect(cookies.find((c) => c.name === 'catalyst.visitId')).toBeUndefined();
129+
});
74130
});

0 commit comments

Comments
 (0)