diff --git a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx index 8cbee9c18ad..0c3f4cda18d 100644 --- a/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx +++ b/apps/sim/app/(landing)/components/prose-page/components/legal-block-group/components/legal-block/legal-block.tsx @@ -5,8 +5,9 @@ import type { LegalBlock } from '@/app/(landing)/components/prose-page/types' /** * Renders a single {@link LegalBlock} into its canonical chrome. The block's * `kind` discriminant selects the element (paragraph / subheading `

` / - * bulleted list / callout box); all sizing and color come from `PROSE_TYPE`, so - * Terms and Privacy share one visual treatment for every block type. Content + * bulleted list / callout box / reference table); all sizing and color come + * from `PROSE_TYPE`, so Terms and Privacy share one visual treatment for every + * block type. Content * only - no layout knob. Server Component. */ @@ -35,6 +36,46 @@ export function LegalBlockView({ block }: LegalBlockViewProps) { ) case 'callout': return
{block.content}
+ case 'table': + return ( +
+ + {block.caption ? ( + + ) : null} + {block.columnWidths ? ( + + {block.columnWidths.map((width, index) => ( + + ))} + + ) : null} + + + {block.columns.map((column) => ( + + ))} + + + + {block.rows.map((row, rowIndex) => { + const rowKey = `row-${rowIndex}` + return ( + + {row.map((cell, cellIndex) => ( + + ))} + + ) + })} + +
{block.caption}
+ {column} +
+ {block.codeColumns?.includes(cellIndex) ? {cell} : cell} +
+
+ ) default: return null } diff --git a/apps/sim/app/(landing)/components/prose-page/constants.ts b/apps/sim/app/(landing)/components/prose-page/constants.ts index d388afa1f11..4fda51fc303 100644 --- a/apps/sim/app/(landing)/components/prose-page/constants.ts +++ b/apps/sim/app/(landing)/components/prose-page/constants.ts @@ -36,6 +36,16 @@ export const PROSE_SPACING = { listIndent: 'pl-6', } as const +/** + * Column widths for the cookie-inventory tables. Sibling tables sharing a header + * must be given a fixed layout or each sizes itself to its own content and the + * group reads as unaligned grids — so the widths are chrome, and live here + * rather than as class strings in a content config. + */ +export const PROSE_TABLE_WIDTHS = { + cookieInventory: ['w-[24%]', 'w-[16%]', 'w-[45%]', 'w-[15%]'], +} as const + /** * Prose type tokens - the single source of truth for every heading size, body * color, list, callout, and inline-link treatment. Centralized alongside the @@ -53,4 +63,11 @@ export const PROSE_TYPE = { callout: 'rounded-lg border border-[var(--border)] bg-[var(--surface-2)] px-4 py-3 text-[14px] text-[var(--text-body)] leading-[1.6]', link: 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]', + tableWrap: 'w-full overflow-x-auto', + tableCaption: 'pb-2 text-left text-[15px] text-[var(--text-primary)]', + table: 'w-full min-w-[560px] table-fixed border-collapse text-left', + tableHeadCell: + 'border-[var(--border)] border-b px-3 py-2 align-bottom font-medium text-[13px] text-[var(--text-primary)] first:pl-0 last:pr-0', + tableCell: + 'border-[var(--border)] border-b px-3 py-2.5 align-top text-[14px] text-[var(--text-body)] leading-[1.55] first:pl-0 last:pr-0 [&_code]:font-mono [&_code]:text-[13px]', } as const diff --git a/apps/sim/app/(landing)/components/prose-page/types.ts b/apps/sim/app/(landing)/components/prose-page/types.ts index bf8d7853e2e..b8cfc2cc505 100644 --- a/apps/sim/app/(landing)/components/prose-page/types.ts +++ b/apps/sim/app/(landing)/components/prose-page/types.ts @@ -22,6 +22,33 @@ export type LegalBlock = | { kind: 'list'; items: ReactNode[] } /** An emphasized callout box (e.g. the arbitration / GDPR notices). */ | { kind: 'callout'; content: ReactNode } + /** + * A reference table — the cookie inventory's name / provider / purpose / + * retention grid. Rows are positional against `columns`, so every row must + * have the same length as the header. + */ + | { + kind: 'table' + caption?: string + columns: string[] + /** + * Tailwind width fragments applied per column, e.g. `['w-[22%]', …]`. Set + * them whenever a page renders sibling tables with the same columns: + * without a fixed layout each table sizes itself to its own content and + * the group reads as three unaligned grids. + */ + columnWidths?: string[] + /** + * Indices of columns rendered as inline code — cookie names, config keys. + * A marker rather than a `` in the row data, because a row carries + * content and the renderer owns chrome. It is also what keeps the rows + * plain strings: biome's `useJsxKeyInIterable` fires on JSX inside an + * array literal, and the key it wants means nothing to a cell the + * renderer already keys by column. + */ + codeColumns?: number[] + rows: ReactNode[][] + } /** A numbered (or named) legal section - an `

` plus its ordered blocks. */ export interface LegalSection { diff --git a/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx b/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx new file mode 100644 index 00000000000..dac9ce50048 --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/consent-preferences-link.tsx @@ -0,0 +1,31 @@ +'use client' + +import type { ReactNode } from 'react' +import { OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants' +import { PROSE_TYPE } from '@/app/(landing)/components/prose-page/constants' + +interface ConsentPreferencesLinkProps { + children: ReactNode +} + +/** + * Inline control that reopens the consent banner with its category switches + * 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. + */ +export function ConsentPreferencesLink({ children }: ConsentPreferencesLinkProps) { + return ( + + ) +} diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx new file mode 100644 index 00000000000..bb8bec8c565 --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -0,0 +1,295 @@ +import type { ReactNode } from 'react' +import { isHosted } from '@/lib/core/config/env-flags' +import { + type LegalBlock, + type LegalPageConfig, + ProseLink, +} from '@/app/(landing)/components/prose-page' +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 + * and clicking it would do nothing. + */ +const CHANGE_CHOICES: ReactNode = isHosted ? ( + change your cookie choices +) : ( + 'change your cookie choices' +) + +function cookieTable(caption: string, rows: ReactNode[][]): LegalBlock { + return { + kind: 'table', + caption, + columns: ['Cookie', 'Provider', 'Purpose', 'Retention'], + columnWidths: [...PROSE_TABLE_WIDTHS.cookieInventory], + codeColumns: [0], + rows, + } +} + +/** + * Cookie Policy content — the inventory a consent banner has to stand on, + * expressed as the typed {@link LegalPageConfig} that `ProsePage` renders, so it + * shares its layout and rhythm with Terms and Privacy and cannot drift. + * + * 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. + */ +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', + intro: [ + { + kind: 'paragraph', + content: ( + <> + This Cookie Policy explains how Sim uses cookies and similar technologies on sim.ai and in + the Sim application, what each one does, and the choices you have. It forms part of our{' '} + Privacy Policy, which describes how we handle + personal data more broadly. + + ), + }, + { + kind: 'paragraph', + content: ( + <> + If you are in the EU, the UK, or another region where consent is required, we ask before + setting anything that is not strictly necessary. You can {CHANGE_CHOICES} at any time. + + ), + }, + ], + sections: [ + { + id: 'what-are-cookies', + heading: 'What are cookies?', + blocks: [ + { + kind: 'paragraph', + content: `A cookie is a small text file a site stores on your device so it can recognize your browser on a later request. Cookies are how a site keeps you signed in between pages, remembers a preference, or counts a visit.`, + }, + { + kind: 'list', + items: [ + <> + Session cookies are deleted when you close your browser.{' '} + Persistent cookies stay until they expire or you delete them. + , + <> + First-party cookies are set by the site you are visiting.{' '} + Third-party cookies are set by another company whose code the site + loads, such as an analytics or advertising provider. + , + ], + }, + { + kind: 'paragraph', + content: `We also use technologies that behave like cookies without being one. Local storage and session storage keep data in your browser rather than sending it with each request; pixels (also called web beacons or tags) are tiny images or scripts that record that a page or email was opened. Where this policy says "cookies", it means all of these.`, + }, + ], + }, + { + id: 'how-we-use-cookies', + heading: 'How we use cookies', + blocks: [ + { + kind: 'paragraph', + content: `We group cookies into the three categories the consent banner offers. Necessary cookies are always on because the service cannot run without them. The other two are off until you turn them on.`, + }, + { + kind: 'list', + items: [ + <> + Necessary — sign-in, session security, abuse prevention, and + remembering the choice you made in the consent banner. These do not require consent + because the service you asked for cannot be delivered without them. + , + <> + Analytics — 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. + , + <> + Marketing — measuring which campaigns bring builders to Sim and + showing relevant ads on other sites. + , + ], + }, + ], + }, + { + id: 'cookies-we-use', + heading: 'Cookies we use', + blocks: [ + { + kind: 'paragraph', + content: `Retention periods are the maximum lifetime set when the cookie is written; a cookie can be cleared sooner at any time. Third-party providers occasionally rename or re-scope their cookies, so treat the provider column as the authoritative reference for anything not set by Sim.`, + }, + cookieTable('Necessary', [ + [ + 'better-auth.session_token', + 'Sim', + 'Keeps you signed in and identifies your session.', + '30 days', + ], + [ + 'better-auth.session_data', + 'Sim', + 'Short-lived signed cache of your session so each page load does not re-read the database.', + '5 minutes', + ], + [ + 'c15t', + 'Sim (via c15t)', + 'Records the cookie choice you made so the banner is not shown again.', + '365 days', + ], + [ + 'sidebar_collapsed', + 'Sim', + 'Remembers whether the workspace sidebar is collapsed, so the layout does not jump on load.', + '1 year', + ], + [ + '__cf_bm', + 'Cloudflare', + 'Bot-management check on requests to providers we load, such as HubSpot and X.', + '30 minutes', + ], + ]), + cookieTable('Analytics', [ + ['_ga', 'Google Analytics', 'Distinguishes one visitor from another.', '13 months'], + [ + '_ga_*', + 'Google Analytics', + 'Holds the session state for a specific Analytics property.', + '13 months', + ], + ['__hstc', 'HubSpot', 'Tracks visits across sessions for the main tracker.', '6 months'], + ['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'], + ]), + cookieTable('Marketing', [ + [ + 'guest_id', + 'X (Twitter)', + 'Identifies a browser to the X conversion pixel.', + '13 months', + ], + ['guest_id_ads', 'X (Twitter)', 'Measures conversions from X advertising.', '13 months'], + [ + 'guest_id_marketing', + 'X (Twitter)', + 'Measures the performance of X marketing campaigns.', + '13 months', + ], + ['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'], + ]), + ], + }, + { + id: 'your-choices', + heading: 'Your choices', + blocks: [ + { + kind: 'paragraph', + content: ( + <> + Where consent is required, the banner appears on your first visit with accept and + reject offered equally, and "Customize" lets you turn each category on or off + individually. To revisit that decision later — including withdrawing consent you + already gave — {CHANGE_CHOICES}. We ask again after 365 days. + + ), + }, + { + kind: 'paragraph', + content: `Independently of the banner, every major browser lets you block or delete cookies from its privacy settings, and can be set to clear them each time you close it. Blocking necessary cookies will sign you out and prevent parts of Sim from working.`, + }, + { + 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.`, + }, + { + kind: 'paragraph', + content: ( + <> + You can also opt out with the providers directly:{' '} + + Google Analytics + + , Google Ads,{' '} + X (Twitter), + and HubSpot. + + ), + }, + ], + }, + { + id: 'third-party-cookies', + heading: 'Third-party cookies', + blocks: [ + { + kind: 'paragraph', + content: `Some cookies above are set by companies we work with rather than by Sim. We choose these providers and decide when their code loads, but the data they collect is also governed by their own policies, which we cannot change on your behalf.`, + }, + { + kind: 'paragraph', + content: ( + <> + The providers currently in use are{' '} + Google{' '} + (Analytics, Tag Manager, and Ads),{' '} + HubSpot,{' '} + X (Twitter),{' '} + Ahrefs, and{' '} + Cloudflare. + + ), + }, + ], + }, + { + id: 'changes-to-this-policy', + heading: 'Changes to this policy', + blocks: [ + { + kind: 'paragraph', + content: `We update this policy when the cookies we set change, and we revise the "Last updated" date above whenever we do. If a change materially widens what we collect, we will ask for your consent again rather than rely on a choice you made under the previous version.`, + }, + ], + }, + { + id: 'contact', + heading: 'Contact', + blocks: [ + { + kind: 'paragraph', + content: ( + <> + Questions about this policy, or about how we use cookies, can go to{' '} + privacy@sim.ai. + + ), + }, + ], + }, + ], +} diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy.tsx new file mode 100644 index 00000000000..1214fb5b2d0 --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy.tsx @@ -0,0 +1,12 @@ +import { ProsePage } from '@/app/(landing)/components/prose-page' +import { COOKIE_POLICY_CONFIG } from '@/app/(landing)/cookie-policy/cookie-policy-content' + +/** + * Cookie Policy page - a thin consumer of the shared {@link ProsePage} + * primitive, alongside Terms and Privacy. The whole document is one typed + * config ({@link COOKIE_POLICY_CONFIG}) rendered inside the shared route-group + * layout chrome, so the three legal pages share a layout and cannot drift. + */ +export default function CookiePolicy() { + return +} diff --git a/apps/sim/app/(landing)/cookie-policy/page.tsx b/apps/sim/app/(landing)/cookie-policy/page.tsx new file mode 100644 index 00000000000..703faf0b2d4 --- /dev/null +++ b/apps/sim/app/(landing)/cookie-policy/page.tsx @@ -0,0 +1,18 @@ +import { buildLandingMetadata } from '@/lib/landing/seo' +import CookiePolicy from '@/app/(landing)/cookie-policy/cookie-policy' + +export const revalidate = 3600 + +const TITLE = 'Cookie Policy | Sim, the AI Workspace' +const DESCRIPTION = + 'What cookies Sim sets, why, how long they last, and how to change your choice at any time.' + +export const metadata = buildLandingMetadata({ + title: TITLE, + description: DESCRIPTION, + path: '/cookie-policy', +}) + +export default function Page() { + return +} diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index f72fb96d3b5..bf5240e4695 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -10,7 +10,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { title: 'Privacy Policy', description: 'How Sim, the open-source AI workspace, collects, uses, and protects your data, including data obtained from Google APIs, and the controls you have over it.', - lastUpdated: 'October 11, 2025', + lastUpdated: 'August 18, 2026', intro: [ { kind: 'paragraph', @@ -214,6 +214,16 @@ export const PRIVACY_CONFIG: LegalPageConfig = { kind: 'paragraph', content: `You can instruct your browser to refuse all cookies or to indicate when a cookie is being sent. However, if you do not accept cookies, you may not be able to use some portions of our Service.`, }, + { + kind: 'paragraph', + content: ( + <> + Our Cookie Policy lists every cookie we + and our providers set, what each one does, how long it lasts, and how to change or + withdraw your choice. + + ), + }, ], }, { diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx new file mode 100644 index 00000000000..230033ff19c --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-banner.tsx @@ -0,0 +1,192 @@ +'use client' + +import { useEffect } from 'react' +import { useConsentManager, useHeadlessConsentUI } from '@c15t/nextjs/headless' +import { Chip, Label, Switch } from '@sim/emcn' +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' +import Link from 'next/link' +import { type ConsentCategory, OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants' + +interface ConsentCategoryCopy { + title: string + description: string +} + +/** + * Sim's own wording per category. The runtime ships generic descriptions; these + * say what the cookies actually do here. + * + * Typed by name rather than by {@link ConsentCategory} because the runtime's + * union is wider than the three categories we configure — a policy that adds + * one server-side falls back to the runtime's description instead of + * disappearing. The `satisfies` still requires an entry for each of ours. + */ +const CONSENT_CATEGORY_COPY: Record = { + necessary: { + title: 'Necessary', + description: 'Sign-in and security. Always on.', + }, + measurement: { + title: 'Analytics', + description: 'Shows us how Sim is used so we can make it better.', + }, + marketing: { + title: 'Marketing', + description: 'Measures which campaigns bring builders to Sim.', + }, +} satisfies Record + +/** Shared expo-out easing and timings, matching the toast stack's motion. */ +const EASE = [0.22, 1, 0.36, 1] as const +const ENTER_TRANSITION = { duration: 0.28, ease: EASE } as const +const EXPAND_TRANSITION = { duration: 0.22, ease: EASE } as const + +const NO_CATEGORIES: ReturnType['getDisplayedConsents']> = [] + +const CATEGORIES_COLLAPSED = { height: 0, opacity: 0 } as const +const CATEGORIES_OPEN = { height: 'auto', opacity: 1 } as const + +/** + * A copy of `PROSE_TYPE.link` rather than an import: the banner lives in the + * app shell and the token lives in the landing route group, and a shell module + * reaching into a route group is the wrong direction for one class string. + */ +const LINK_CLASS = + 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]' + +/** + * Cookie consent banner — a non-modal card docked bottom-left, opposite the + * toast stack and wearing the same chrome. It never dims, blocks, or reflows + * the page, and "Customize" expands this same card into per-category switches + * rather than opening a dialog over the app. + * + * Visibility and the available actions come from the jurisdiction policy the + * consent runtime resolves, so the banner is absent entirely where no consent + * is required and never offers an action the policy does not allow. Accept and + * reject carry identical weight, which GDPR requires. + * + * The card pins the `light` token layer rather than following the visitor's + * theme, as every other public surface does (`LandingShell`, `AuthShell`, the + * chat interfaces, the public file view). Consent is asked for on a first + * visit, which lands on one of those. A record expiring against a live session + * is the one path that renders this card over the themed app, where it will + * read light-on-dark; accepted as the rarer case. + */ +export function ConsentBanner() { + const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } = + useConsentManager() + const { banner, dialog, openDialog, performAction, saveCustomPreferences } = + useHeadlessConsentUI() + const prefersReducedMotion = useReducedMotion() + + useEffect(() => { + window.addEventListener(OPEN_CONSENT_PREFERENCES_EVENT, openDialog) + return () => window.removeEventListener(OPEN_CONSENT_PREFERENCES_EVENT, openDialog) + }, [openDialog]) + + const isExpanded = dialog.isVisible + const surfaceName = isExpanded ? 'dialog' : 'banner' + const { allowedActions } = isExpanded ? dialog : banner + /** + * The store's own selector, not a hand-rolled filter over `consentTypes`: the + * shipped defaults mark every category except `necessary` as `display: false`, + * so filtering on that flag silently renders a one-row list. It re-filters and + * re-allocates on every call, so only the expanded card pays for it. + */ + const categories = isExpanded ? getDisplayedConsents() : NO_CATEGORIES + const enterOffset = prefersReducedMotion ? 0 : 8 + + return ( + + {(banner.isVisible || dialog.isVisible) && ( + +
+

Cookies

+

+ We use cookies to run Sim, understand how it is used, and improve it. Read our{' '} + + Cookie Policy + + . +

+
+ + + {isExpanded && ( + +
    + {categories.map((type) => { + const copy = CONSENT_CATEGORY_COPY[type.name] + const inputId = `consent-${type.name}` + return ( +
  • +
    + +

    + {copy?.description ?? type.description} +

    +
    + setSelectedConsent(type.name, checked)} + /> +
  • + ) + })} +
+
+ )} +
+ + {/* Two clusters, not `mr-auto` on the chip: chips carry no outer margin. */} +
+
+ {!isExpanded && allowedActions.includes('customize') && ( + Customize + )} +
+
+ {allowedActions.includes('reject') && ( + void performAction('reject', { surface: surfaceName })} + > + Reject all + + )} + {allowedActions.includes('accept') && ( + void performAction('accept', { surface: surfaceName })} + > + Accept all + + )} + {isExpanded && ( + void saveCustomPreferences()}> + Save + + )} +
+
+
+ )} +
+ ) +} diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx new file mode 100644 index 00000000000..d1c4f4d6dde --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -0,0 +1,21 @@ +'use client' + +import dynamic from 'next/dynamic' + +/** + * The cookie-consent runtime, loaded on the client only and only once this + * component is rendered — the root layout renders it behind `isHosted`, so a + * self-hosted deployment never fetches the chunk, never reaches Sim's consent + * backend, and never sees the banner. Deferring it also keeps the third-party + * store out of the server render and off the landing page's hydration path; the + * banner cannot paint before its geo lookup resolves anyway. + * + * It mounts alongside the app rather than wrapping it because an `ssr: false` + * boundary around the tree would disable SSR for every route. Nothing can reach + * the store through context as a result, which is what + * `OPEN_CONSENT_PREFERENCES_EVENT` exists for. + */ +export const ConsentProvider = dynamic( + () => import('@/app/_shell/consent/consent-runtime').then((m) => m.ConsentRuntime), + { ssr: false } +) diff --git a/apps/sim/app/_shell/consent/consent-runtime.test.tsx b/apps/sim/app/_shell/consent/consent-runtime.test.tsx new file mode 100644 index 00000000000..142bfb47c4a --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-runtime.test.tsx @@ -0,0 +1,57 @@ +/** + * @vitest-environment jsdom + */ +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mockConsentManagerProvider, mockConsentBanner } = vi.hoisted(() => ({ + mockConsentManagerProvider: vi.fn(), + mockConsentBanner: vi.fn(), +})) + +vi.mock('@c15t/nextjs/headless', () => ({ + ConsentManagerProvider: (props: { children: ReactNode; options: unknown }) => { + mockConsentManagerProvider(props.options) + return props.children + }, +})) + +vi.mock('@/app/_shell/consent/consent-banner', () => ({ + ConsentBanner: () => { + mockConsentBanner() + return + }, +})) + +import { ConsentRuntime } from '@/app/_shell/consent/consent-runtime' + +let root: Root | null = null + +afterEach(() => { + act(() => root?.unmount()) + root = null + vi.clearAllMocks() +}) + +describe('ConsentRuntime', () => { + it('mounts the banner against the hosted consent backend', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render()) + + expect(container.querySelector('[data-testid="banner"]')).not.toBeNull() + expect(mockConsentBanner).toHaveBeenCalled() + // `toMatchObject`, not exact equality: `DEV_CONSENT_COUNTRY` adds an + // `overrides` key whenever a developer has NEXT_PUBLIC_CONSENT_COUNTRY set + // locally, and the assertion is about the shipped configuration. + expect(mockConsentManagerProvider.mock.calls[0]?.[0]).toMatchObject({ + mode: 'hosted', + backendURL: 'https://sim-sim.inth.app', + consentCategories: ['necessary', 'measurement', 'marketing'], + }) + }) +}) diff --git a/apps/sim/app/_shell/consent/consent-runtime.tsx b/apps/sim/app/_shell/consent/consent-runtime.tsx new file mode 100644 index 00000000000..adfe5c381cb --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-runtime.tsx @@ -0,0 +1,39 @@ +'use client' + +import { type ConsentManagerOptions, ConsentManagerProvider } from '@c15t/nextjs/headless' +import { + CONSENT_BACKEND_URL, + CONSENT_CATEGORIES, + DEV_CONSENT_COUNTRY, +} from '@/lib/consent/constants' +import { ConsentBanner } from '@/app/_shell/consent/consent-banner' + +/** + * Imported from `@c15t/nextjs/headless`, not the package root: the headless + * entry leaves the runtime's own components and stylesheet out of the bundle, + * so {@link ConsentBanner} is the only consent UI that exists. The provider + * still injects a `