diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx index 230033ff19c..3fbeab7f772 100644 --- a/apps/sim/app/_shell/consent/consent-banner.tsx +++ b/apps/sim/app/_shell/consent/consent-banner.tsx @@ -1,80 +1,39 @@ 'use client' import { useEffect } from 'react' -import { useConsentManager, useHeadlessConsentUI } from '@c15t/nextjs/headless' -import { Chip, Label, Switch } from '@sim/emcn' +import { useHeadlessConsentUI } from '@c15t/nextjs/headless' +import { Chip } 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 +import { OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants' +import { CONSENT_LINK_CLASS, ConsentPreferences } from '@/app/_shell/consent/consent-preferences' /** 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. + * the page, and "Customize" expands this same card into the 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. + * It follows the visitor's theme. Every surface it can appear on either pins + * the light layer on `` through `ThemeProvider`'s forced theme, or is a + * themed app page where inheriting is what should happen — the card no longer + * decides for itself. Inside the workspace it never renders at all; consent is + * managed from Settings → Privacy there. */ export function ConsentBanner() { - const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } = - useConsentManager() const { banner, dialog, openDialog, performAction, saveCustomPreferences } = useHeadlessConsentUI() const prefersReducedMotion = useReducedMotion() @@ -87,13 +46,6 @@ export function ConsentBanner() { 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 ( @@ -105,13 +57,13 @@ export function ConsentBanner() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: enterOffset }} transition={ENTER_TRANSITION} - className='light fixed bottom-4 left-4 z-[var(--z-toast)] flex w-[min(100vw-2rem,380px)] flex-col gap-3 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)] p-4 shadow-overlay' + className='fixed bottom-4 left-4 z-[var(--z-toast)] flex w-[min(100vw-2rem,380px)] flex-col gap-3 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)] p-4 shadow-overlay' >

Cookies

We use cookies to run Sim, understand how it is used, and improve it. Read our{' '} - + Cookie Policy . @@ -128,28 +80,7 @@ export function ConsentBanner() { transition={EXPAND_TRANSITION} className='overflow-hidden' > -

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

    - {copy?.description ?? type.description} -

    -
    - setSelectedConsent(type.name, checked)} - /> -
  • - ) - })} -
+ )} diff --git a/apps/sim/app/_shell/consent/consent-preferences.tsx b/apps/sim/app/_shell/consent/consent-preferences.tsx new file mode 100644 index 00000000000..36946c22814 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-preferences.tsx @@ -0,0 +1,88 @@ +'use client' + +import { useConsentManager } from '@c15t/nextjs/headless' +import { Label, Switch } from '@sim/emcn' +import type { ConsentCategory } from '@/lib/consent/constants' + +/** + * Inline link chrome for the consent surfaces, matching `PROSE_TYPE.link` on the + * legal pages. Copied rather than imported because both consumers sit outside + * the landing route group that owns that token, and defined here — the module + * they already share — so the copy exists once. + */ +export const CONSENT_LINK_CLASS = + 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]' + +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 + +/** + * The per-category consent switches, shared by the two surfaces that offer + * them: the banner's expanded state and the Privacy settings page. Both write + * to `selectedConsents`; committing is the caller's, since the banner saves + * from its own footer and settings saves from the shell's header. + * + * Must be rendered inside a `ConsentManagerProvider`. + */ +export function ConsentPreferences() { + const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } = + useConsentManager() + + /** + * 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. + */ + const categories = getDisplayedConsents() + + return ( +
    + {categories.map((type) => { + const copy = CONSENT_CATEGORY_COPY[type.name] + const inputId = `consent-${type.name}` + return ( +
  • +
    + +

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

    +
    + setSelectedConsent(type.name, checked)} + /> +
  • + ) + })} +
+ ) +} diff --git a/apps/sim/app/_shell/consent/consent-provider.test.tsx b/apps/sim/app/_shell/consent/consent-provider.test.tsx new file mode 100644 index 00000000000..b4ad6f0f87b --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-provider.test.tsx @@ -0,0 +1,69 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mockPathname, mockDynamicImport } = vi.hoisted(() => ({ + mockPathname: vi.fn(), + mockDynamicImport: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ usePathname: mockPathname })) + +/** + * Stands in for the lazily-loaded runtime and records whether the chunk was + * asked for at all — that, not just the absence of a banner, is what the + * workspace gate is for. + */ +vi.mock('next/dynamic', () => ({ + default: (loader: () => Promise) => { + return function LazyRuntime() { + mockDynamicImport(loader) + return + } + }, +})) + +import { ConsentProvider } from '@/app/_shell/consent/consent-provider' + +let root: Root | null = null + +function renderAt(pathname: string): HTMLDivElement { + mockPathname.mockReturnValue(pathname) + ;(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()) + return container +} + +afterEach(() => { + act(() => root?.unmount()) + root = null + vi.clearAllMocks() +}) + +describe('ConsentProvider', () => { + it.each(['/', '/pricing', '/login', '/cookie-policy', '/upgrade', '/workspaces'])( + 'mounts the consent runtime on %s', + (pathname) => { + const container = renderAt(pathname) + + expect(container.querySelector('[data-testid="runtime"]')).not.toBeNull() + expect(mockDynamicImport).toHaveBeenCalled() + } + ) + + it.each(['/workspace', '/workspace/abc', '/workspace/abc/logs'])( + 'mounts nothing on %s', + (pathname) => { + const container = renderAt(pathname) + + expect(container.querySelector('[data-testid="runtime"]')).toBeNull() + expect(mockDynamicImport).not.toHaveBeenCalled() + } + ) +}) diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx index d1c4f4d6dde..97e544005c1 100644 --- a/apps/sim/app/_shell/consent/consent-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -1,21 +1,43 @@ 'use client' import dynamic from 'next/dynamic' +import { usePathname } from 'next/navigation' /** * 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 + * component renders it — 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( +const ConsentRuntime = dynamic( () => import('@/app/_shell/consent/consent-runtime').then((m) => m.ConsentRuntime), { ssr: false } ) + +const WORKSPACE_SEGMENT = 'workspace' + +/** + * Mounts the consent runtime everywhere except the workspace. + * + * Inside the product a floating consent card is the wrong surface — a signed-in + * user manages this from Settings → Privacy, which mounts the same store. The + * check sits above the `dynamic()` rather than inside the loaded module so the + * workspace pays neither the chunk nor the consent init request: gating within + * the module would still have downloaded it, on the surface with the most hard + * loads. + * + * The gap this leaves — a visitor who reaches the workspace with no consent + * record is not prompted — closes when the analytics scripts move behind + * consent, since nothing non-essential loads without a record at all. + */ +export function ConsentProvider() { + const pathname = usePathname() + + if (pathname.split('/')[1] === WORKSPACE_SEGMENT) { + return null + } + + return +} diff --git a/apps/sim/app/_shell/consent/consent-runtime.tsx b/apps/sim/app/_shell/consent/consent-runtime.tsx index adfe5c381cb..243c1ed6b66 100644 --- a/apps/sim/app/_shell/consent/consent-runtime.tsx +++ b/apps/sim/app/_shell/consent/consent-runtime.tsx @@ -1,39 +1,16 @@ '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' +import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider' /** - * 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 `