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 apps/sim/app/(auth)/signup/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { trackGoogleEvent } from '@/lib/analytics/google'
import { client, useSession } from '@/lib/auth/auth-client'
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
import { getEnv, isFalsy } from '@/lib/core/config/env'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
Expand Down Expand Up @@ -107,6 +109,7 @@ function SignupFormContent({
const searchParams = useSearchParams()
const { refetch: refetchSession } = useSession()
const posthog = usePostHog()
const { measurement } = useTrackingConsent()
const [isLoading, setIsLoading] = useState(false)

useEffect(() => {
Expand Down Expand Up @@ -344,6 +347,8 @@ function SignupFormContent({
return
}

if (measurement) trackGoogleEvent('sign_up', { method: 'email' })

try {
await refetchSession()
logger.info('Session refreshed after successful signup')
Expand Down
49 changes: 38 additions & 11 deletions apps/sim/app/(landing)/components/footer/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Link from 'next/link'
import { ConsentPreferencesTrigger } from '@/app/_shell/consent/consent-preferences-trigger'
import { ALL_COMPETITORS } from '@/app/(landing)/comparisons/utils'
import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark'
import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
Expand All @@ -19,14 +20,25 @@ import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
*/

const LINK_CLASS =
'text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
'text-left text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'

interface FooterItem {
interface FooterLinkItem {
label: string
href: string
external?: boolean
}

interface FooterConsentItem {
label: string
consentPreferences: true
}

type FooterItem = FooterLinkItem | FooterConsentItem

interface FooterProps {
showConsentPreferences?: boolean
}

/**
* Platform modules link to their local landing pages (internal link equity
* stays on the ranking pages); docs-only surfaces (MCP, API, Self Hosting)
Expand Down Expand Up @@ -108,27 +120,37 @@ const SOCIAL_LINKS: FooterItem[] = [
const LEGAL_LINKS: FooterItem[] = [
{ label: 'Terms of Service', href: '/terms' },
{ label: 'Privacy Policy', href: '/privacy' },
{ label: 'Cookie Policy', href: '/cookie-policy' },
]

const CONSENT_PREFERENCES_LINK: FooterConsentItem = {
label: 'Cookie preferences',
consentPreferences: true,
}

function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) {
return (
<div>
<h3 className='mb-4 text-[var(--text-primary)] text-sm'>{title}</h3>
<div className='flex flex-col gap-2.5'>
{items.map(({ label, href, external }) =>
external ? (
{items.map((item) =>
'consentPreferences' in item ? (
<ConsentPreferencesTrigger key={item.label} className={LINK_CLASS}>
{item.label}
</ConsentPreferencesTrigger>
) : item.external ? (
<a
key={label}
href={href}
key={item.label}
href={item.href}
target='_blank'
rel='noopener noreferrer'
className={LINK_CLASS}
>
{label}
{item.label}
</a>
) : (
<Link key={label} href={href} className={LINK_CLASS}>
{label}
<Link key={item.label} href={item.href} className={LINK_CLASS}>
{item.label}
</Link>
)
)}
Expand All @@ -137,7 +159,7 @@ function FooterColumn({ title, items }: { title: string; items: FooterItem[] })
)
}

export function Footer() {
export function Footer({ showConsentPreferences = false }: FooterProps) {
return (
<footer className='mt-[120px] w-full border-[var(--border)] border-t max-sm:mt-16 max-lg:mt-[88px]'>
<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'>
Expand All @@ -161,7 +183,12 @@ export function Footer() {
<FooterColumn title='Integrations' items={INTEGRATION_LINKS} />
<FooterColumn title='Models' items={MODEL_LINKS} />
<FooterColumn title='Socials' items={SOCIAL_LINKS} />
<FooterColumn title='Legal' items={LEGAL_LINKS} />
<FooterColumn
title='Legal'
items={
showConsentPreferences ? [...LEGAL_LINKS, CONSENT_PREFERENCES_LINK] : LEGAL_LINKS
}
/>
</nav>

<p className='mt-16 text-[var(--text-muted)] text-sm'>© 2026 Sim. All rights reserved.</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import { isHosted } from '@/lib/core/config/env-flags'
import { getGitHubStars } from '@/lib/github/stars'
import { Footer } from '@/app/(landing)/components/footer/footer'
import { Navbar } from '@/app/(landing)/components/navbar/navbar'
Expand Down Expand Up @@ -46,7 +47,7 @@ export async function LandingShell({ children }: LandingShellProps) {
</a>
<Navbar stars={stars} />
{children}
<Footer />
<Footer showConsentPreferences={isHosted} />
</div>
)
}
17 changes: 5 additions & 12 deletions apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

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

interface ConsentPreferencesLinkProps {
Expand All @@ -13,19 +13,12 @@ interface ConsentPreferencesLinkProps {
* expanded, so a recorded choice can be withdrawn or changed. Wearing the
* prose link chrome, it reads as part of the sentence it sits in.
*
* Only rendered where the consent runtime is mounted — see the call site. On a
* self-hosted deployment nothing would listen for the event, so the Cookie
* Policy renders the phrase as plain text rather than a control that does
* nothing when clicked.
* Only rendered where the consent runtime is mounted — see the call site. The
* Cookie Policy renders plain text on self-hosted deployments, where there is
* no preferences dialog to open.
*/
export function ConsentPreferencesLink({ children }: ConsentPreferencesLinkProps) {
return (
<button
type='button'
className={PROSE_TYPE.link}
onClick={() => window.dispatchEvent(new Event(OPEN_CONSENT_PREFERENCES_EVENT))}
>
{children}
</button>
<ConsentPreferencesTrigger className={PROSE_TYPE.link}>{children}</ConsentPreferencesTrigger>
)
}
47 changes: 31 additions & 16 deletions apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@ import {
import { PROSE_TABLE_WIDTHS } from '@/app/(landing)/components/prose-page/constants'
import { ConsentPreferencesLink } from '@/app/(landing)/cookie-policy/consent-preferences-link'

/**
* One cookie-inventory table per consent category. The three share a header and
* a column layout, so they are built from one shape rather than repeated.
*/
/**
* The withdrawal control, or the bare phrase on a self-hosted deployment. The
* consent runtime is hosted-only, so there the button would have no listener
Expand Down Expand Up @@ -41,15 +37,14 @@ function cookieTable(caption: string, rows: ReactNode[][]): LegalBlock {
*
* The tables describe what Sim and its providers actually set, grouped by the
* three categories the banner offers. Keep them in step with the banner's
* categories (`lib/consent/constants`) and with the tags configured in Google
* Tag Manager: naming a cookie the site no longer sets is as wrong as omitting
* one it does.
* categories and consent-managed scripts: naming a cookie the site no longer
* sets is as wrong as omitting one it does.
*/
export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
title: 'Cookie Policy',
description:
'What cookies Sim sets, why, how long they last, and how to change your choice at any time.',
lastUpdated: 'August 18, 2026',
lastUpdated: 'August 24, 2026',
intro: [
{
kind: 'paragraph',
Expand Down Expand Up @@ -120,7 +115,9 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
<>
<strong>Analytics</strong> — how many people use Sim, which pages and features they
reach, and where errors happen, so we can improve the product. Measurement only; we do
not use these to target advertising.
not use these to target advertising. Google Analytics loads with analytics storage
denied and cannot set analytics cookies until this category is allowed; before then,
it may send limited cookieless consent and measurement signals.
</>,
<>
<strong>Marketing</strong> — measuring which campaigns bring builders to Sim and
Expand Down Expand Up @@ -182,6 +179,24 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
['hubspotutk', 'HubSpot', 'Identifies a visitor across form submissions.', '6 months'],
['__hssc', 'HubSpot', 'Tracks the current session.', '30 minutes'],
['__hssrc', 'HubSpot', 'Detects whether the visitor restarted their browser.', 'Session'],
[
'ph_*_posthog',
'PostHog',
'Stores analytics identity and durable session state after analytics consent is granted.',
'1 year',
],
[
'__ph_opt_in_out_*',
'PostHog',
'Records PostHog’s local capture state, synchronized from your Sim analytics choice.',
'Until you change your choice',
],
[
'ph_*_window_id / ph_*_primary_window_exists',
'PostHog',
'Coordinates analytics state for the current browser tab.',
'Session',
],
]),
cookieTable('Marketing', [
[
Expand All @@ -199,7 +214,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
],
['personalization_id', 'X (Twitter)', 'Personalizes the ads shown on X.', '13 months'],
['muc_ads', 'X (Twitter)', 'Measures ad conversions across X domains.', '13 months'],
['_gcl_*', 'Google Ads', 'Attributes a sign-up to the ad that led to it.', '90 days'],
]),
],
},
Expand All @@ -224,7 +238,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
},
{
kind: 'paragraph',
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.`,
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.`,
},
{
kind: 'paragraph',
Expand All @@ -234,9 +248,9 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
<ProseLink href='https://tools.google.com/dlpage/gaoptout'>
Google Analytics
</ProseLink>
, <ProseLink href='https://myadcenter.google.com'>Google Ads</ProseLink>,{' '}
<ProseLink href='https://x.com/settings/privacy_and_safety'>X (Twitter)</ProseLink>,
and <ProseLink href='https://legal.hubspot.com/privacy-policy'>HubSpot</ProseLink>.
, <ProseLink href='https://x.com/settings/privacy_and_safety'>X (Twitter)</ProseLink>,{' '}
<ProseLink href='https://legal.hubspot.com/privacy-policy'>HubSpot</ProseLink>, and{' '}
<ProseLink href='https://posthog.com/privacy'>PostHog</ProseLink>.
</>
),
},
Expand All @@ -256,10 +270,11 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = {
<>
The providers currently in use are{' '}
<ProseLink href='https://policies.google.com/technologies/cookies'>Google</ProseLink>{' '}
(Analytics, Tag Manager, and Ads),{' '}
(Analytics),{' '}
<ProseLink href='https://legal.hubspot.com/privacy-policy'>HubSpot</ProseLink>,{' '}
<ProseLink href='https://x.com/en/privacy'>X (Twitter)</ProseLink>,{' '}
<ProseLink href='https://ahrefs.com/privacy'>Ahrefs</ProseLink>, and{' '}
<ProseLink href='https://ahrefs.com/privacy'>Ahrefs</ProseLink>,{' '}
<ProseLink href='https://posthog.com/privacy'>PostHog</ProseLink>, and{' '}
<ProseLink href='https://www.cloudflare.com/privacypolicy/'>Cloudflare</ProseLink>.
</>
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

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

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

/**
* X (Twitter) conversion event fired when a demo is actually booked, so ad
* delivery optimizes toward bookings rather than form submits.
*/
const X_DEMO_BOOKED_EVENT_ID = 'tw-q5xbl-q5xbn'

interface DemoSchedulerProps {
/** The captured lead used to prefill the Cal.com booking. */
lead: DemoLead
}

let calEmbedPreloaded = false

/**
* Fires the X conversion once the Cal.com booking is confirmed. There is no
* standalone confirmation page to drop the pixel snippet into — Cal renders the
* "you're booked" state inside its cross-origin iframe — so the embed's
* `bookingSuccessfulV2` event is the confirmation.
*
* Module-scope so the same function identity can be handed to both `on` and
* `off`. `window.twq` is only defined where {@link LandingLayout} renders the
* pixel base code, so the optional call is a second guard for the window
* between mount and `uwt.js` finishing — the stub `twq` queues calls made
* before the script loads and replays them.
*/
function trackDemoBooked(): void {
window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {})
}

/**
* Warm the Cal.com embed before the scheduler mounts. Loads `embed.js` and
* issues the embed's `preload` instruction, which fetches the booker in a
Expand Down Expand Up @@ -77,8 +57,20 @@ export function preloadCalEmbed(): void {
* card stays the same height across the form→calendar transition.
*/
export function DemoScheduler({ lead }: DemoSchedulerProps) {
const { marketing, measurement } = useTrackingConsent()

useEffect(() => {
let cancelled = false
const trackDemoBooked = () => {
if (measurement) {
trackGoogleEvent('get_a_demo', {
page_path: '/demo',
form_name: 'sim_demo',
booking_status: 'scheduled',
})
}
if (marketing) window.twq?.('event', X_DEMO_BOOKED_EVENT_ID, {})
}
const api = getCalApi({ namespace: CAL_NAMESPACE })
api
.then((cal) => {
Expand All @@ -87,19 +79,19 @@ export function DemoScheduler({ lead }: DemoSchedulerProps) {
hideEventTypeDetails: true,
styles: { branding: { brandColor: CAL_BRAND_COLOR } },
})
// Matches the layout's pixel gating - a self-hosted deployment loads no
// base pixel, so it must not subscribe an ad-tracking callback either.
if (isHosted) cal('on', { action: 'bookingSuccessfulV2', callback: trackDemoBooked })
if (measurement || marketing) {
cal('on', { action: 'bookingSuccessfulV2', callback: trackDemoBooked })
}
})
.catch(() => {})
return () => {
cancelled = true
if (!isHosted) return
if (!measurement && !marketing) return
api
.then((cal) => cal('off', { action: 'bookingSuccessfulV2', callback: trackDemoBooked }))
.catch(() => {})
}
}, [])
}, [marketing, measurement])

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