Skip to content

Commit 626f6e5

Browse files
authored
fix(consent): enforce consent-aware analytics (#7049)
* fix(consent): enforce consent-aware analytics * fix(consent): preserve script config literals * fix(consent): adapt readonly scripts for provider
1 parent 0a5b380 commit 626f6e5

40 files changed

Lines changed: 1107 additions & 522 deletions

apps/sim/app/(auth)/signup/signup-form.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
55
import { createLogger } from '@sim/logger'
66
import { useRouter, useSearchParams } from 'next/navigation'
77
import { usePostHog } from 'posthog-js/react'
8+
import { trackGoogleEvent } from '@/lib/analytics/google'
89
import { client, useSession } from '@/lib/auth/auth-client'
10+
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
911
import { getEnv, isFalsy } from '@/lib/core/config/env'
1012
import { isSsoEnabled } from '@/lib/core/config/env-flags'
1113
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
@@ -107,6 +109,7 @@ function SignupFormContent({
107109
const searchParams = useSearchParams()
108110
const { refetch: refetchSession } = useSession()
109111
const posthog = usePostHog()
112+
const { measurement } = useTrackingConsent()
110113
const [isLoading, setIsLoading] = useState(false)
111114

112115
useEffect(() => {
@@ -344,6 +347,8 @@ function SignupFormContent({
344347
return
345348
}
346349

350+
if (measurement) trackGoogleEvent('sign_up', { method: 'email' })
351+
347352
try {
348353
await refetchSession()
349354
logger.info('Session refreshed after successful signup')

apps/sim/app/(landing)/components/footer/footer.tsx

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Link from 'next/link'
2+
import { ConsentPreferencesTrigger } from '@/app/_shell/consent/consent-preferences-trigger'
23
import { ALL_COMPETITORS } from '@/app/(landing)/comparisons/utils'
34
import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark'
45
import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
@@ -19,14 +20,25 @@ import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
1920
*/
2021

2122
const LINK_CLASS =
22-
'text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
23+
'text-left text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
2324

24-
interface FooterItem {
25+
interface FooterLinkItem {
2526
label: string
2627
href: string
2728
external?: boolean
2829
}
2930

31+
interface FooterConsentItem {
32+
label: string
33+
consentPreferences: true
34+
}
35+
36+
type FooterItem = FooterLinkItem | FooterConsentItem
37+
38+
interface FooterProps {
39+
showConsentPreferences?: boolean
40+
}
41+
3042
/**
3143
* Platform modules link to their local landing pages (internal link equity
3244
* stays on the ranking pages); docs-only surfaces (MCP, API, Self Hosting)
@@ -108,27 +120,37 @@ const SOCIAL_LINKS: FooterItem[] = [
108120
const LEGAL_LINKS: FooterItem[] = [
109121
{ label: 'Terms of Service', href: '/terms' },
110122
{ label: 'Privacy Policy', href: '/privacy' },
123+
{ label: 'Cookie Policy', href: '/cookie-policy' },
111124
]
112125

126+
const CONSENT_PREFERENCES_LINK: FooterConsentItem = {
127+
label: 'Cookie preferences',
128+
consentPreferences: true,
129+
}
130+
113131
function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
114132
return (
115133
<div>
116134
<h3 className='mb-4 text-[var(--text-primary)] text-sm'>{title}</h3>
117135
<div className='flex flex-col gap-2.5'>
118-
{items.map(({ label, href, external }) =>
119-
external ? (
136+
{items.map((item) =>
137+
'consentPreferences' in item ? (
138+
<ConsentPreferencesTrigger key={item.label} className={LINK_CLASS}>
139+
{item.label}
140+
</ConsentPreferencesTrigger>
141+
) : item.external ? (
120142
<a
121-
key={label}
122-
href={href}
143+
key={item.label}
144+
href={item.href}
123145
target='_blank'
124146
rel='noopener noreferrer'
125147
className={LINK_CLASS}
126148
>
127-
{label}
149+
{item.label}
128150
</a>
129151
) : (
130-
<Link key={label} href={href} className={LINK_CLASS}>
131-
{label}
152+
<Link key={item.label} href={item.href} className={LINK_CLASS}>
153+
{item.label}
132154
</Link>
133155
)
134156
)}
@@ -137,7 +159,7 @@ function FooterColumn({ title, items }: { title: string; items: FooterItem[] })
137159
)
138160
}
139161

140-
export function Footer() {
162+
export function Footer({ showConsentPreferences = false }: FooterProps) {
141163
return (
142164
<footer className='mt-[120px] w-full border-[var(--border)] border-t max-sm:mt-16 max-lg:mt-[88px]'>
143165
<div className='mx-auto w-full max-w-[1460px] px-20 pt-16 pb-16 max-sm:px-5 max-lg:px-8 max-lg:pt-12 max-lg:pb-12'>
@@ -161,7 +183,12 @@ export function Footer() {
161183
<FooterColumn title='Integrations' items={INTEGRATION_LINKS} />
162184
<FooterColumn title='Models' items={MODEL_LINKS} />
163185
<FooterColumn title='Socials' items={SOCIAL_LINKS} />
164-
<FooterColumn title='Legal' items={LEGAL_LINKS} />
186+
<FooterColumn
187+
title='Legal'
188+
items={
189+
showConsentPreferences ? [...LEGAL_LINKS, CONSENT_PREFERENCES_LINK] : LEGAL_LINKS
190+
}
191+
/>
165192
</nav>
166193

167194
<p className='mt-16 text-[var(--text-muted)] text-sm'>© 2026 Sim. All rights reserved.</p>

apps/sim/app/(landing)/components/landing-shell/landing-shell.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ReactNode } from 'react'
2+
import { isHosted } from '@/lib/core/config/env-flags'
23
import { getGitHubStars } from '@/lib/github/stars'
34
import { Footer } from '@/app/(landing)/components/footer/footer'
45
import { Navbar } from '@/app/(landing)/components/navbar/navbar'
@@ -46,7 +47,7 @@ export async function LandingShell({ children }: LandingShellProps) {
4647
</a>
4748
<Navbar stars={stars} />
4849
{children}
49-
<Footer />
50+
<Footer showConsentPreferences={isHosted} />
5051
</div>
5152
)
5253
}
Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import type { ReactNode } from 'react'
4-
import { OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants'
4+
import { ConsentPreferencesTrigger } from '@/app/_shell/consent/consent-preferences-trigger'
55
import { PROSE_TYPE } from '@/app/(landing)/components/prose-page/constants'
66

77
interface ConsentPreferencesLinkProps {
@@ -13,19 +13,12 @@ interface ConsentPreferencesLinkProps {
1313
* expanded, so a recorded choice can be withdrawn or changed. Wearing the
1414
* prose link chrome, it reads as part of the sentence it sits in.
1515
*
16-
* Only rendered where the consent runtime is mounted — see the call site. On a
17-
* self-hosted deployment nothing would listen for the event, so the Cookie
18-
* Policy renders the phrase as plain text rather than a control that does
19-
* nothing when clicked.
16+
* Only rendered where the consent runtime is mounted — see the call site. The
17+
* Cookie Policy renders plain text on self-hosted deployments, where there is
18+
* no preferences dialog to open.
2019
*/
2120
export function ConsentPreferencesLink({ children }: ConsentPreferencesLinkProps) {
2221
return (
23-
<button
24-
type='button'
25-
className={PROSE_TYPE.link}
26-
onClick={() => window.dispatchEvent(new Event(OPEN_CONSENT_PREFERENCES_EVENT))}
27-
>
28-
{children}
29-
</button>
22+
<ConsentPreferencesTrigger className={PROSE_TYPE.link}>{children}</ConsentPreferencesTrigger>
3023
)
3124
}

apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@ import {
88
import { PROSE_TABLE_WIDTHS } from '@/app/(landing)/components/prose-page/constants'
99
import { ConsentPreferencesLink } from '@/app/(landing)/cookie-policy/consent-preferences-link'
1010

11-
/**
12-
* One cookie-inventory table per consent category. The three share a header and
13-
* a column layout, so they are built from one shape rather than repeated.
14-
*/
1511
/**
1612
* The withdrawal control, or the bare phrase on a self-hosted deployment. The
1713
* consent runtime is hosted-only, so there the button would have no listener
@@ -41,15 +37,14 @@ function cookieTable(caption: string, rows: ReactNode[][]): LegalBlock {
4137
*
4238
* The tables describe what Sim and its providers actually set, grouped by the
4339
* three categories the banner offers. Keep them in step with the banner's
44-
* categories (`lib/consent/constants`) and with the tags configured in Google
45-
* Tag Manager: naming a cookie the site no longer sets is as wrong as omitting
46-
* one it does.
40+
* categories and consent-managed scripts: naming a cookie the site no longer
41+
* sets is as wrong as omitting one it does.
4742
*/
4843
export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
4944
title: 'Cookie Policy',
5045
description:
5146
'What cookies Sim sets, why, how long they last, and how to change your choice at any time.',
52-
lastUpdated: 'August 18, 2026',
47+
lastUpdated: 'August 24, 2026',
5348
intro: [
5449
{
5550
kind: 'paragraph',
@@ -120,7 +115,9 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
120115
<>
121116
<strong>Analytics</strong> — how many people use Sim, which pages and features they
122117
reach, and where errors happen, so we can improve the product. Measurement only; we do
123-
not use these to target advertising.
118+
not use these to target advertising. Google Analytics loads with analytics storage
119+
denied and cannot set analytics cookies until this category is allowed; before then,
120+
it may send limited cookieless consent and measurement signals.
124121
</>,
125122
<>
126123
<strong>Marketing</strong> — measuring which campaigns bring builders to Sim and
@@ -182,6 +179,24 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
182179
['hubspotutk', 'HubSpot', 'Identifies a visitor across form submissions.', '6 months'],
183180
['__hssc', 'HubSpot', 'Tracks the current session.', '30 minutes'],
184181
['__hssrc', 'HubSpot', 'Detects whether the visitor restarted their browser.', 'Session'],
182+
[
183+
'ph_*_posthog',
184+
'PostHog',
185+
'Stores analytics identity and durable session state after analytics consent is granted.',
186+
'1 year',
187+
],
188+
[
189+
'__ph_opt_in_out_*',
190+
'PostHog',
191+
'Records PostHog’s local capture state, synchronized from your Sim analytics choice.',
192+
'Until you change your choice',
193+
],
194+
[
195+
'ph_*_window_id / ph_*_primary_window_exists',
196+
'PostHog',
197+
'Coordinates analytics state for the current browser tab.',
198+
'Session',
199+
],
185200
]),
186201
cookieTable('Marketing', [
187202
[
@@ -199,7 +214,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
199214
],
200215
['personalization_id', 'X (Twitter)', 'Personalizes the ads shown on X.', '13 months'],
201216
['muc_ads', 'X (Twitter)', 'Measures ad conversions across X domains.', '13 months'],
202-
['_gcl_*', 'Google Ads', 'Attributes a sign-up to the ad that led to it.', '90 days'],
203217
]),
204218
],
205219
},
@@ -224,7 +238,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
224238
},
225239
{
226240
kind: 'paragraph',
227-
content: `We honor Global Privacy Control (GPC). If your browser or an extension sends a GPC signal, we treat it as an instruction to opt out of analytics and marketing cookies without your having to use the banner.`,
241+
content: `We honor Global Privacy Control (GPC). Where the applicable privacy policy provides an opt-out right, the consent service applies that signal to the covered optional categories without requiring you to use the banner.`,
228242
},
229243
{
230244
kind: 'paragraph',
@@ -234,9 +248,9 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
234248
<ProseLink href='https://tools.google.com/dlpage/gaoptout'>
235249
Google Analytics
236250
</ProseLink>
237-
, <ProseLink href='https://myadcenter.google.com'>Google Ads</ProseLink>,{' '}
238-
<ProseLink href='https://x.com/settings/privacy_and_safety'>X (Twitter)</ProseLink>,
239-
and <ProseLink href='https://legal.hubspot.com/privacy-policy'>HubSpot</ProseLink>.
251+
, <ProseLink href='https://x.com/settings/privacy_and_safety'>X (Twitter)</ProseLink>,{' '}
252+
<ProseLink href='https://legal.hubspot.com/privacy-policy'>HubSpot</ProseLink>, and{' '}
253+
<ProseLink href='https://posthog.com/privacy'>PostHog</ProseLink>.
240254
</>
241255
),
242256
},
@@ -256,10 +270,11 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
256270
<>
257271
The providers currently in use are{' '}
258272
<ProseLink href='https://policies.google.com/technologies/cookies'>Google</ProseLink>{' '}
259-
(Analytics, Tag Manager, and Ads),{' '}
273+
(Analytics),{' '}
260274
<ProseLink href='https://legal.hubspot.com/privacy-policy'>HubSpot</ProseLink>,{' '}
261275
<ProseLink href='https://x.com/en/privacy'>X (Twitter)</ProseLink>,{' '}
262-
<ProseLink href='https://ahrefs.com/privacy'>Ahrefs</ProseLink>, and{' '}
276+
<ProseLink href='https://ahrefs.com/privacy'>Ahrefs</ProseLink>,{' '}
277+
<ProseLink href='https://posthog.com/privacy'>PostHog</ProseLink>, and{' '}
263278
<ProseLink href='https://www.cloudflare.com/privacypolicy/'>Cloudflare</ProseLink>.
264279
</>
265280
),

apps/sim/app/(landing)/demo/components/demo-scheduler/demo-scheduler.tsx

Lines changed: 20 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import { useEffect } from 'react'
44
import Cal, { getCalApi } from '@calcom/embed-react'
5-
import { isHosted } from '@/lib/core/config/env-flags'
5+
import { trackGoogleEvent } from '@/lib/analytics/google'
6+
import { X_DEMO_BOOKED_EVENT_ID } from '@/lib/consent/scripts'
7+
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
68
import type { DemoLead } from '@/app/(landing)/demo/components/demo-form'
79

810
/** The Cal.com event the demo books - set `NEXT_PUBLIC_CAL_LINK` to override. */
@@ -15,35 +17,13 @@ const CAL_LINK = process.env.NEXT_PUBLIC_CAL_LINK ?? 'team/sim/demo'
1517
*/
1618
const CAL_BRAND_COLOR = '#6f3dfa'
1719

18-
/**
19-
* X (Twitter) conversion event fired when a demo is actually booked, so ad
20-
* delivery optimizes toward bookings rather than form submits.
21-
*/
22-
const X_DEMO_BOOKED_EVENT_ID = 'tw-q5xbl-q5xbn'
23-
2420
interface DemoSchedulerProps {
2521
/** The captured lead used to prefill the Cal.com booking. */
2622
lead: DemoLead
2723
}
2824

2925
let calEmbedPreloaded = false
3026

31-
/**
32-
* Fires the X conversion once the Cal.com booking is confirmed. There is no
33-
* standalone confirmation page to drop the pixel snippet into — Cal renders the
34-
* "you're booked" state inside its cross-origin iframe — so the embed's
35-
* `bookingSuccessfulV2` event is the confirmation.
36-
*
37-
* Module-scope so the same function identity can be handed to both `on` and
38-
* `off`. `window.twq` is only defined where {@link LandingLayout} renders the
39-
* pixel base code, so the optional call is a second guard for the window
40-
* between mount and `uwt.js` finishing — the stub `twq` queues calls made
41-
* before the script loads and replays them.
42-
*/
43-
function trackDemoBooked(): void {
44-
window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {})
45-
}
46-
4727
/**
4828
* Warm the Cal.com embed before the scheduler mounts. Loads `embed.js` and
4929
* issues the embed's `preload` instruction, which fetches the booker in a
@@ -77,8 +57,20 @@ export function preloadCalEmbed(): void {
7757
* card stays the same height across the form→calendar transition.
7858
*/
7959
export function DemoScheduler({ lead }: DemoSchedulerProps) {
60+
const { marketing, measurement } = useTrackingConsent()
61+
8062
useEffect(() => {
8163
let cancelled = false
64+
const trackDemoBooked = () => {
65+
if (measurement) {
66+
trackGoogleEvent('get_a_demo', {
67+
page_path: '/demo',
68+
form_name: 'sim_demo',
69+
booking_status: 'scheduled',
70+
})
71+
}
72+
if (marketing) window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {})
73+
}
8274
const api = getCalApi({ namespace: CAL_NAMESPACE })
8375
api
8476
.then((cal) => {
@@ -87,19 +79,19 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
8779
hideEventTypeDetails: true,
8880
styles: { branding: { brandColor: CAL_BRAND_COLOR } },
8981
})
90-
// Matches the layout's pixel gating - a self-hosted deployment loads no
91-
// base pixel, so it must not subscribe an ad-tracking callback either.
92-
if (isHosted) cal('on', { action: 'bookingSuccessfulV2', callback: trackDemoBooked })
82+
if (measurement || marketing) {
83+
cal('on', { action: 'bookingSuccessfulV2', callback: trackDemoBooked })
84+
}
9385
})
9486
.catch(() => {})
9587
return () => {
9688
cancelled = true
97-
if (!isHosted) return
89+
if (!measurement && !marketing) return
9890
api
9991
.then((cal) => cal('off', { action: 'bookingSuccessfulV2', callback: trackDemoBooked }))
10092
.catch(() => {})
10193
}
102-
}, [])
94+
}, [marketing, measurement])
10395

10496
return (
10597
<div className='flex h-full min-w-0 flex-col p-6 max-sm:p-5'>

0 commit comments

Comments
 (0)