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
97 changes: 14 additions & 83 deletions apps/sim/app/_shell/consent/consent-banner.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ConsentCategoryCopy | undefined> = {
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<ConsentCategory, ConsentCategoryCopy>
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<ReturnType<typeof useConsentManager>['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 `<html>` 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()
Expand All @@ -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 (
Expand All @@ -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'
>
<div className='flex flex-col gap-1'>
<p className='text-[var(--text-body)] text-sm leading-5'>Cookies</p>
<p className='text-[var(--text-muted)] text-small leading-[18px]'>
We use cookies to run Sim, understand how it is used, and improve it. Read our{' '}
<Link href='/cookie-policy' className={LINK_CLASS}>
<Link href='/cookie-policy' className={CONSENT_LINK_CLASS}>
Cookie Policy
</Link>
.
Expand All @@ -128,28 +80,7 @@ export function ConsentBanner() {
transition={EXPAND_TRANSITION}
className='overflow-hidden'
>
<ul className='flex flex-col gap-3'>
{categories.map((type) => {
const copy = CONSENT_CATEGORY_COPY[type.name]
const inputId = `consent-${type.name}`
return (
<li key={type.name} className='flex items-start justify-between gap-3'>
<div className='flex min-w-0 flex-col gap-1'>
<Label htmlFor={inputId}>{copy?.title ?? type.name}</Label>
<p className='text-[var(--text-muted)] text-caption leading-4'>
{copy?.description ?? type.description}
</p>
</div>
<Switch
id={inputId}
checked={selectedConsents[type.name] ?? consents[type.name] ?? false}
disabled={type.disabled}
onCheckedChange={(checked) => setSelectedConsent(type.name, checked)}
/>
</li>
)
})}
</ul>
<ConsentPreferences />
</motion.div>
)}
</AnimatePresence>
Expand Down
88 changes: 88 additions & 0 deletions apps/sim/app/_shell/consent/consent-preferences.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ConsentCategoryCopy | undefined> = {
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<ConsentCategory, ConsentCategoryCopy>

/**
* 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 (
<ul className='flex flex-col gap-3'>
{categories.map((type) => {
const copy = CONSENT_CATEGORY_COPY[type.name]
const inputId = `consent-${type.name}`
return (
<li key={type.name} className='flex items-start justify-between gap-3'>
<div className='flex min-w-0 flex-col gap-1'>
<Label htmlFor={inputId}>{copy?.title ?? type.name}</Label>
<p className='text-[var(--text-muted)] text-caption leading-4'>
{copy?.description ?? type.description}
</p>
</div>
<Switch
id={inputId}
checked={selectedConsents[type.name] ?? consents[type.name] ?? false}
disabled={type.disabled}
onCheckedChange={(checked) => setSelectedConsent(type.name, checked)}
/>
</li>
)
})}
</ul>
)
}
69 changes: 69 additions & 0 deletions apps/sim/app/_shell/consent/consent-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>) => {
return function LazyRuntime() {
mockDynamicImport(loader)
return <span data-testid='runtime' />
}
},
}))

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(<ConsentProvider />))
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()
}
)
})
36 changes: 29 additions & 7 deletions apps/sim/app/_shell/consent/consent-provider.tsx
Original file line number Diff line number Diff line change
@@ -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 <ConsentRuntime />
}
Loading
Loading