| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- // @ts-nocheck
- import type Usercentrics from '@usercentrics/cmp-browser-sdk'
- import type { BaseCategory, UserDecision } from '@usercentrics/cmp-browser-sdk'
- import { proxy, snapshot, useSnapshot } from 'valtio'
- import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from './constants'
- export type PriorConsentDecision =
- | null
- | { kind: 'uniform-accept' }
- | { kind: 'decisions'; decisions: UserDecision[] }
- type UcDataServiceEntry = [string, { consent: boolean }]
- const isValidUcDataServiceEntry = (entry: [string, unknown]): entry is UcDataServiceEntry => {
- const value = entry[1]
- return (
- typeof value === 'object' &&
- value !== null &&
- typeof (value as { consent: unknown }).consent === 'boolean'
- )
- }
- type UcSettingsService = { id: string; status: boolean }
- const isValidUcSettingsService = (service: unknown): service is UcSettingsService =>
- typeof service === 'object' &&
- service !== null &&
- typeof (service as { id: unknown }).id === 'string' &&
- typeof (service as { status: unknown }).status === 'boolean'
- /**
- * Check whether the user previously made a consent decision by reading
- * localStorage state that was written before UC.init() overwrites it.
- *
- * Returns enough information for the caller to restore the user's exact
- * prior state via UC.updateServices, or to fast-path via UC.acceptAllServices
- * when we can safely identify a uniform accept. Returns null when nothing
- * trustworthy can be detected — in that case the caller should show the
- * banner rather than fabricate a decision.
- *
- * Handles two scenarios (FE-2648 and GROWTH-790):
- *
- * 1. Slow navigation: GTM's Usercentrics integration replaced uc_settings with
- * compressed ucString/ucData after the user's decision. On the next page
- * load, UC.init() can't read that format and treats the user as new. We
- * read ucData.consent.services directly and return every per-service
- * decision so the caller can restore the user's exact state — including
- * mixed states (essentials + functional accepted, tracking denied) that
- * users produce via the Privacy Settings modal or the Opt out button.
- *
- * 2. Fast navigation: User decided on app A and navigated to app B before GTM
- * finished writing ucData (or before GTM loaded at all, which happens on
- * deny since TelemetryTagManager is gated behind hasAccepted). App B's
- * UC.init() overwrites uc_settings with a fresh controllerId and resets
- * uc_user_interaction to false. We check uc_user_interaction: "true" as
- * a gate confirming the user actually interacted, then parse uc_settings
- * to extract per-service decisions. uc_user_interaction alone is not
- * enough — without uc_settings we cannot tell accept from deny and must
- * not fabricate a direction.
- *
- * Both scenarios fail closed: any schema mismatch or partial corruption
- * returns null rather than proceeding with a subset of valid entries.
- * Over-consenting from malformed storage is the worst-direction bias in
- * this domain.
- *
- * Must be called BEFORE UC.init() since init overwrites these keys.
- */
- export function detectPriorConsent(): PriorConsentDecision {
- try {
- const ucData = localStorage?.getItem('ucData')
- if (ucData) {
- const data = JSON.parse(ucData)
- const services = data?.consent?.services
- if (services && typeof services === 'object') {
- const rawEntries = Object.entries(services) as Array<[string, unknown]>
- if (rawEntries.length > 0) {
- if (!rawEntries.every(isValidUcDataServiceEntry)) {
- // Partial corruption: don't cherry-pick the valid subset. Fall
- // through to scenario 2 — uc_settings may still be intact.
- } else {
- const entries = rawEntries as UcDataServiceEntry[]
- if (entries.every(([, s]) => s.consent === true)) {
- return { kind: 'uniform-accept' }
- }
- return {
- kind: 'decisions',
- decisions: entries.map(([serviceId, s]) => ({
- serviceId,
- status: s.consent,
- })),
- }
- }
- }
- }
- }
- // uc_user_interaction gates trust in uc_settings — the SDK sets it on any
- // user interaction, which confirms uc_settings holds real decisions rather
- // than ruleset defaults.
- if (localStorage?.getItem('uc_user_interaction') === 'true') {
- const ucSettings = localStorage?.getItem('uc_settings')
- if (ucSettings) {
- const parsed = JSON.parse(ucSettings)
- const services = parsed?.services
- if (
- Array.isArray(services) &&
- services.length > 0 &&
- services.every(isValidUcSettingsService)
- ) {
- const decisions: UserDecision[] = services.map((s) => ({
- serviceId: s.id,
- status: s.status,
- }))
- if (decisions.every((d) => d.status === true)) {
- return { kind: 'uniform-accept' }
- }
- return { kind: 'decisions', decisions }
- }
- }
- // Flag says interacted but uc_settings is missing or malformed.
- // Don't fabricate direction — show the banner on the next init.
- }
- return null
- } catch {
- return null
- }
- }
- export const consentState = proxy({
- UC: null as Usercentrics | null,
- categories: null as BaseCategory[] | null,
- showConsentToast: false,
- hasConsented: false,
- acceptAll: () => {
- if (!consentState.UC) return
- const previousConsentValue = consentState.hasConsented
- consentState.hasConsented = true
- consentState.showConsentToast = false
- consentState.UC.acceptAllServices()
- .then(() => {
- consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
- })
- .catch(() => {
- consentState.hasConsented = previousConsentValue
- consentState.showConsentToast = true
- })
- },
- denyAll: () => {
- if (!consentState.UC) return
- const previousConsentValue = consentState.hasConsented
- consentState.hasConsented = false
- consentState.showConsentToast = false
- consentState.UC.denyAllServices()
- .then(() => {
- consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
- })
- .catch(() => {
- consentState.showConsentToast = previousConsentValue
- })
- },
- updateServices: (decisions: UserDecision[]) => {
- if (!consentState.UC) return
- consentState.showConsentToast = false
- consentState.UC.updateServices(decisions)
- .then(() => {
- consentState.hasConsented = consentState.UC?.areAllConsentsAccepted() ?? false
- consentState.categories = consentState.UC?.getCategoriesBaseInfo() ?? null
- })
- .catch(() => {
- consentState.showConsentToast = true
- })
- },
- })
- /**
- * Apply a prior consent decision (or lack of one) to the freshly-initialized
- * Usercentrics SDK and the module's consentState proxy. Extracted from
- * initUserCentrics to make the orchestration unit-testable without mocking
- * the dynamic SDK import. Must be called after UC.init(). Mutates
- * consentState synchronously and may call UC methods asynchronously.
- */
- export function applyPriorDecisionToSDK(
- UC: Usercentrics,
- initialUIValues: { initialLayer: number },
- priorDecision: PriorConsentDecision
- ): void {
- consentState.UC = UC
- const hasConsented = UC.areAllConsentsAccepted()
- // If the SDK wants to show the banner but the user previously made a
- // decision (detected via ucData or uc_settings before init overwrote
- // them), silently re-apply that decision instead of re-prompting
- // (FE-2648, GROWTH-790).
- if (initialUIValues.initialLayer === 0 && !hasConsented && priorDecision) {
- consentState.categories = UC.getCategoriesBaseInfo()
- consentState.showConsentToast = false
- localStorage?.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
- if (priorDecision.kind === 'uniform-accept') {
- // Uniform accept covers any currently-active service by definition,
- // including any added to the ruleset since the user's decision was
- // stored — acceptAllServices applies to all current services.
- consentState.hasConsented = true
- UC.acceptAllServices()
- .then(() => {
- consentState.categories = UC.getCategoriesBaseInfo()
- })
- .catch(() => {
- consentState.hasConsented = false
- consentState.showConsentToast = true
- })
- return
- }
- // priorDecision.kind === 'decisions'. Only suppress the banner if the
- // stored decisions cover every non-essential service the SDK currently
- // knows about. If the ruleset has grown since the user's ucData/
- // uc_settings was written, force a re-prompt rather than silently
- // defaulting the new service. Essentials are skipped because the SDK
- // forces them on regardless of user decision.
- const currentNonEssentialIds = UC.getServicesBaseInfo()
- .filter((s) => !s.isEssential)
- .map((s) => s.id)
- const coveredIds = new Set(priorDecision.decisions.map((d) => d.serviceId))
- const allCovered = currentNonEssentialIds.every((id) => coveredIds.has(id))
- if (!allCovered) {
- // Fall through to the banner path below. Reset the early writes
- // so the default-branch state assignments take effect correctly.
- consentState.showConsentToast = initialUIValues.initialLayer === 0
- consentState.hasConsented = hasConsented
- return
- }
- // Restore the user's exact per-service state — handles deny and any
- // partial/category-level decision made via Privacy Settings. hasConsented
- // is computed from SDK state after the restore resolves (will be false
- // unless every service was accepted).
- UC.updateServices(priorDecision.decisions)
- .then(() => {
- consentState.hasConsented = UC.areAllConsentsAccepted()
- consentState.categories = UC.getCategoriesBaseInfo()
- })
- .catch(() => {
- // Falling back to the banner is safer than silently flipping to a
- // uniform state the user didn't choose.
- consentState.showConsentToast = true
- })
- return
- }
- // 0 = first layer, aka show consent toast
- consentState.showConsentToast = initialUIValues.initialLayer === 0
- consentState.hasConsented = hasConsented
- consentState.categories = UC.getCategoriesBaseInfo()
- // If the user has previously consented (before usercentrics), accept all services
- if (!hasConsented && localStorage?.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) === 'true') {
- consentState.acceptAll()
- localStorage.removeItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
- }
- }
- async function initUserCentrics() {
- if (process.env.NODE_ENV === 'test' || !IS_PLATFORM) return
- // [Alaister] For local development and staging, we accept all consent by default.
- // If you need to test usercentrics in these environments, comment out this
- // NEXT_PUBLIC_ENVIRONMENT check and add an ngrok domain to usercentrics
- if (
- process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
- process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
- ) {
- consentState.hasConsented = true
- return
- }
- // Check for prior consent BEFORE UC.init(), which can't read the compressed
- // ucData format written by the GTM/Usercentrics integration (FE-2648).
- const priorDecision = detectPriorConsent()
- try {
- const { default: Usercentrics } = await import('@usercentrics/cmp-browser-sdk')
- const UC = new Usercentrics(process.env.NEXT_PUBLIC_USERCENTRICS_RULESET_ID!, {
- rulesetId: process.env.NEXT_PUBLIC_USERCENTRICS_RULESET_ID,
- useRulesetId: true,
- })
- const initialUIValues = await UC.init()
- applyPriorDecisionToSDK(UC, initialUIValues, priorDecision)
- } catch (error) {
- console.error('Failed to initialize Usercentrics:', error)
- // If SDK fails but user previously accepted uniformly, honor that.
- // For explicit per-service decisions we can't restore without the SDK,
- // and showing the banner when the SDK is broken would fail anyway.
- if (priorDecision?.kind === 'uniform-accept') {
- consentState.hasConsented = true
- }
- }
- }
- // Usercentrics is not available on the server
- if (typeof window !== 'undefined') {
- initUserCentrics()
- }
- export function hasConsented() {
- return snapshot(consentState).hasConsented
- }
- export function useConsentState() {
- const snap = useSnapshot(consentState)
- return {
- hasAccepted: snap.hasConsented,
- categories: snap.categories as BaseCategory[] | null,
- acceptAll: snap.acceptAll,
- denyAll: snap.denyAll,
- updateServices: snap.updateServices,
- }
- }
|