diff --git a/apps/docs/features/helpers.consent.tsx b/apps/docs/features/helpers.consent.tsx deleted file mode 100644 index fb4025cbd4..0000000000 --- a/apps/docs/features/helpers.consent.tsx +++ /dev/null @@ -1,15 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { useConsent } from 'ui-patterns/ConsentToast' - -/** - * Helper client component to trigger the consent toast. It has to be a client component. - */ -export const TriggerConsentToast = () => { - const { triggerConsentToast } = useConsent() - useEffect(() => { - setTimeout(() => triggerConsentToast(), 1000) - }, [triggerConsentToast]) - return null -} diff --git a/apps/docs/features/telemetry/telemetry.client.tsx b/apps/docs/features/telemetry/telemetry.client.tsx index ee1853b8d7..54b492e798 100644 --- a/apps/docs/features/telemetry/telemetry.client.tsx +++ b/apps/docs/features/telemetry/telemetry.client.tsx @@ -1,11 +1,11 @@ 'use client' import { IS_PLATFORM, PageTelemetry as PageTelemetryImpl } from 'common' -import { useConsent } from 'ui-patterns/ConsentToast' +import { useConsentToast } from 'ui-patterns/consent' import { API_URL } from '~/lib/constants' const PageTelemetry = () => { - const { hasAcceptedConsent } = useConsent() + const { hasAcceptedConsent } = useConsentToast() return ( { - const snap = useAppStateSnapshot() + const { hasAccepted, acceptAll, denyAll } = useConsentState() const { mutate: sendReset } = useSendResetMutation() const onToggleOptIn = () => { - const value = !snap.isOptedInTelemetry ? 'true' : 'false' - snap.setIsOptedInTelemetry(value === 'true') - if (value === 'false') sendReset() + if (hasAccepted) { + denyAll() + sendReset() + } else { + acceptAll() + } } return ( Analytics}> { const encoder = new TextEncoder() @@ -29,7 +28,6 @@ const GroupsTelemetry = ({ hasAcceptedConsent }: { hasAcceptedConsent: boolean } const user = useUser() const router = useRouter() const { ref, slug } = useParams() - const snap = useAppStateSnapshot() const organization = useSelectedOrganization() const previousPathname = usePrevious(router.pathname) @@ -41,13 +39,6 @@ const GroupsTelemetry = ({ hasAcceptedConsent }: { hasAcceptedConsent: boolean } const referrer = typeof document !== 'undefined' ? document?.referrer : '' useTelemetryCookie({ hasAcceptedConsent, title, referrer }) - useEffect(() => { - if (hasAcceptedConsent) { - snap.setIsOptedInTelemetry(hasAcceptedConsent) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasAcceptedConsent]) - useEffect(() => { // don't set the sentry user id if the user hasn't logged in (so that Sentry errors show null user id instead of anonymous id) if (!user?.id) { diff --git a/apps/studio/data/telemetry/send-groups-identify-mutation.ts b/apps/studio/data/telemetry/send-groups-identify-mutation.ts index 6ad7e4a534..07d8b316fc 100644 --- a/apps/studio/data/telemetry/send-groups-identify-mutation.ts +++ b/apps/studio/data/telemetry/send-groups-identify-mutation.ts @@ -1,7 +1,7 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { components } from 'api-types' -import { LOCAL_STORAGE_KEYS } from 'common' +import { hasConsented } from 'common' import { handleError, post } from 'data/fetchers' import { IS_PLATFORM } from 'lib/constants' import type { ResponseError } from 'types' @@ -9,10 +9,7 @@ import type { ResponseError } from 'types' export type SendGroupsIdentifyVariables = components['schemas']['TelemetryGroupsIdentityBody'] export async function sendGroupsIdentify({ body }: { body: SendGroupsIdentifyVariables }) { - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null) === 'true' + const consent = hasConsented() if (!consent || !IS_PLATFORM) return undefined diff --git a/apps/studio/data/telemetry/send-groups-reset-mutation.ts b/apps/studio/data/telemetry/send-groups-reset-mutation.ts index 28805372b5..6f8524547f 100644 --- a/apps/studio/data/telemetry/send-groups-reset-mutation.ts +++ b/apps/studio/data/telemetry/send-groups-reset-mutation.ts @@ -1,7 +1,7 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { components } from 'api-types' -import { LOCAL_STORAGE_KEYS } from 'common' +import { hasConsented } from 'common' import { handleError, post } from 'data/fetchers' import { IS_PLATFORM } from 'lib/constants' import type { ResponseError } from 'types' @@ -9,10 +9,7 @@ import type { ResponseError } from 'types' export type SendGroupsResetVariables = components['schemas']['TelemetryGroupsResetBody'] export async function sendGroupsReset({ body }: { body: SendGroupsResetVariables }) { - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null) === 'true' + const consent = hasConsented() if (!consent || !IS_PLATFORM) return undefined diff --git a/apps/studio/lib/posthog.ts b/apps/studio/lib/posthog.ts index c3560627bc..96735b0b77 100644 --- a/apps/studio/lib/posthog.ts +++ b/apps/studio/lib/posthog.ts @@ -1,15 +1,12 @@ import { components } from 'api-types' -import { LOCAL_STORAGE_KEYS } from 'common' +import { hasConsented } from 'common' import { handleError, post } from 'data/fetchers' import { IS_PLATFORM } from './constants' type TrackFeatureFlagVariables = components['schemas']['TelemetryFeatureFlagBodyDto'] export async function trackFeatureFlag(body: TrackFeatureFlagVariables) { - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null) === 'true' + const consent = hasConsented() if (!consent || !IS_PLATFORM) return undefined const { data, error } = await post(`/platform/telemetry/feature-flags/track`, { body }) diff --git a/apps/studio/lib/telemetry.tsx b/apps/studio/lib/telemetry.tsx new file mode 100644 index 0000000000..84b40e143d --- /dev/null +++ b/apps/studio/lib/telemetry.tsx @@ -0,0 +1,22 @@ +import { PageTelemetry } from 'common' +import GroupsTelemetry from 'components/ui/GroupsTelemetry' +import { API_URL, IS_PLATFORM } from 'lib/constants' +import { useConsentToast } from 'ui-patterns/consent' + +export function Telemetry() { + // Although this is "technically" breaking the rules of hooks + // IS_PLATFORM never changes within a session, so this won't cause any issues + // eslint-disable-next-line react-hooks/rules-of-hooks + const { hasAcceptedConsent } = IS_PLATFORM ? useConsentToast() : { hasAcceptedConsent: true } + + return ( + <> + + + + ) +} diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 7a6aeb0bba..68523a0984 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -56,14 +56,17 @@ const SUPABASE_ASSETS_URL = ? 'https://frontend-assets.supabase.green' : 'https://frontend-assets.supabase.com' +const USERCENTRICS_URLS = 'https://*.usercentrics.eu' +const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu' + // used by vercel live preview const PUSHER_URL = 'https://*.pusher.com' const PUSHER_URL_WS = 'wss://*.pusher.com' -const DEFAULT_SRC_URLS = `${API_URL} ${SUPABASE_URL} ${GOTRUE_URL} ${SUPABASE_LOCAL_PROJECTS_URL_WS} ${SUPABASE_PROJECTS_URL} ${SUPABASE_PROJECTS_URL_WS} ${HCAPTCHA_SUBDOMAINS_URL} ${CONFIGCAT_URL} ${STRIPE_SUBDOMAINS_URL} ${STRIPE_NETWORK_URL} ${CLOUDFLARE_URL} ${ONE_ONE_ONE_ONE_URL} ${VERCEL_INSIGHTS_URL} ${GITHUB_API_URL} ${GITHUB_USER_CONTENT_URL} ${SUPABASE_ASSETS_URL}` +const DEFAULT_SRC_URLS = `${API_URL} ${SUPABASE_URL} ${GOTRUE_URL} ${SUPABASE_LOCAL_PROJECTS_URL_WS} ${SUPABASE_PROJECTS_URL} ${SUPABASE_PROJECTS_URL_WS} ${HCAPTCHA_SUBDOMAINS_URL} ${CONFIGCAT_URL} ${STRIPE_SUBDOMAINS_URL} ${STRIPE_NETWORK_URL} ${CLOUDFLARE_URL} ${ONE_ONE_ONE_ONE_URL} ${VERCEL_INSIGHTS_URL} ${GITHUB_API_URL} ${GITHUB_USER_CONTENT_URL} ${SUPABASE_ASSETS_URL} ${USERCENTRICS_URLS}` const SCRIPT_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${HCAPTCHA_JS_URL} ${STRIPE_JS_URL} ${SUPABASE_ASSETS_URL}` const FRAME_SRC_URLS = `${HCAPTCHA_ASSET_URL} ${STRIPE_JS_URL}` -const IMG_SRC_URLS = `${SUPABASE_URL} ${SUPABASE_COM_URL} ${SUPABASE_PROJECTS_URL} ${GITHUB_USER_AVATAR_URL} ${GOOGLE_USER_AVATAR_URL} ${SUPABASE_ASSETS_URL}` +const IMG_SRC_URLS = `${SUPABASE_URL} ${SUPABASE_COM_URL} ${SUPABASE_PROJECTS_URL} ${GITHUB_USER_AVATAR_URL} ${GOOGLE_USER_AVATAR_URL} ${SUPABASE_ASSETS_URL} ${USERCENTRICS_APP_URL}` const STYLE_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${SUPABASE_ASSETS_URL}` const FONT_SRC_URLS = `${CLOUDFLARE_CDN_URL} ${SUPABASE_ASSETS_URL}` diff --git a/apps/studio/package.json b/apps/studio/package.json index 393216104e..90203f3af6 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -125,7 +125,7 @@ "ui-patterns": "workspace:*", "use-debounce": "^7.0.1", "uuid": "^9.0.1", - "valtio": "^1.12.0", + "valtio": "catalog:", "yup": "^1.4.0", "yup-password": "^0.3.0", "zod": "^3.22.4", diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 6f00417d4d..82dde900ed 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -31,7 +31,7 @@ import { NuqsAdapter } from 'nuqs/adapters/next/pages' import { ErrorInfo } from 'react' import { ErrorBoundary } from 'react-error-boundary' -import { FeatureFlagProvider, PageTelemetry, ThemeProvider, useThemeSandbox } from 'common' +import { FeatureFlagProvider, ThemeProvider, useThemeSandbox } from 'common' import MetaFaviconsPagesRouter from 'common/MetaFavicons/pages-router' import { RouteValidationWrapper } from 'components/interfaces/App' import { AppBannerContextProvider } from 'components/interfaces/App/AppBannerWrapperContext' @@ -41,18 +41,17 @@ import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/Featur import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvider' import { GenerateSql } from 'components/interfaces/SqlGenerator/SqlGenerator' import { ErrorBoundaryState } from 'components/ui/ErrorBoundaryState' -import GroupsTelemetry from 'components/ui/GroupsTelemetry' import { useRootQueryClient } from 'data/query-client' import { customFont, sourceCodePro } from 'fonts' import { AuthProvider } from 'lib/auth' import { getFlags as getConfigCatFlags } from 'lib/configcat' import { API_URL, BASE_PATH, IS_PLATFORM } from 'lib/constants' import { ProfileProvider } from 'lib/profile' +import { Telemetry } from 'lib/telemetry' import HCaptchaLoadedStore from 'stores/hcaptcha-loaded-store' import { AppPropsWithLayout } from 'types' import { SonnerToaster, TooltipProvider } from 'ui' import { CommandProvider } from 'ui-patterns/CommandMenu' -import { useConsent } from 'ui-patterns/ConsentToast' dayjs.extend(customParseFormat) dayjs.extend(utc) @@ -93,11 +92,6 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { useThemeSandbox() - // Although this is "technically" breaking the rules of hooks - // IS_PLATFORM never changes within a session, so this won't cause any issues - // eslint-disable-next-line react-hooks/rules-of-hooks - const { hasAcceptedConsent } = IS_PLATFORM ? useConsent() : { hasAcceptedConsent: true } - const isTestEnv = process.env.NEXT_PUBLIC_NODE_ENV === 'test' return ( @@ -146,12 +140,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - - + {!isTestEnv && } {!isTestEnv && ( diff --git a/apps/studio/sentry.client.config.ts b/apps/studio/sentry.client.config.ts index 44cc043b02..f107f699cb 100644 --- a/apps/studio/sentry.client.config.ts +++ b/apps/studio/sentry.client.config.ts @@ -3,8 +3,8 @@ // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from '@sentry/nextjs' +import { hasConsented } from 'common' import { IS_PLATFORM } from 'common/constants/environment' -import { LOCAL_STORAGE_KEYS } from 'common/constants/local-storage' import { match } from 'path-to-regexp' // This is a workaround to ignore hCaptcha related errors. @@ -26,12 +26,9 @@ Sentry.init({ // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, beforeSend(event, hint) { - const consent = - typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null + const consent = hasConsented() - if (IS_PLATFORM && consent === 'true') { + if (IS_PLATFORM && consent) { // Ignore invalid URL events for 99% of the time because it's using up a lot of quota. const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes( `Failed to construct 'URL': Invalid URL` diff --git a/apps/studio/state/app-state.ts b/apps/studio/state/app-state.ts index 84438cfba3..186f4078c6 100644 --- a/apps/studio/state/app-state.ts +++ b/apps/studio/state/app-state.ts @@ -1,6 +1,5 @@ import { proxy, snapshot, subscribe, useSnapshot } from 'valtio' -import { LOCAL_STORAGE_KEYS as COMMON_LOCAL_STORAGE_KEYS } from 'common' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { LOCAL_STORAGE_KEYS } from 'lib/constants' @@ -52,7 +51,6 @@ const getInitialState = () => { activeDocsSection: ['introduction'], docsLanguage: 'js', showProjectApiDocs: false, - isOptedInTelemetry: false, showEnableBranchingModal: false, showFeaturePreviewModal: false, selectedFeaturePreview: '', @@ -84,7 +82,6 @@ const getInitialState = () => { activeDocsSection: ['introduction'], docsLanguage: 'js', showProjectApiDocs: false, - isOptedInTelemetry: false, showEnableBranchingModal: false, showFeaturePreviewModal: false, selectedFeaturePreview: '', @@ -124,14 +121,6 @@ export const appState = proxy({ appState.docsLanguage = value }, - isOptedInTelemetry: false, - setIsOptedInTelemetry: (value: boolean | null) => { - appState.isOptedInTelemetry = value === null ? false : value - if (typeof window !== 'undefined' && value !== null) { - localStorage.setItem(COMMON_LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT, value.toString()) - } - }, - showEnableBranchingModal: false, setShowEnableBranchingModal: (value: boolean) => { appState.showEnableBranchingModal = value diff --git a/apps/ui-library/app/(app)/telemetry-wrapper.tsx b/apps/ui-library/app/(app)/telemetry-wrapper.tsx index 5460e46156..124d9c5477 100644 --- a/apps/ui-library/app/(app)/telemetry-wrapper.tsx +++ b/apps/ui-library/app/(app)/telemetry-wrapper.tsx @@ -2,10 +2,10 @@ import { API_URL } from '@/lib/constants' import { IS_PLATFORM, PageTelemetry } from 'common' -import { useConsent } from 'ui-patterns/ConsentToast' +import { useConsentToast } from 'ui-patterns/consent' export const TelemetryWrapper = () => { - const { hasAcceptedConsent } = useConsent() + const { hasAcceptedConsent } = useConsentToast() return ( - - {children} - - + + + {children} + + + ) diff --git a/apps/www/pages/_app.tsx b/apps/www/pages/_app.tsx index dbb1b0e053..982d272174 100644 --- a/apps/www/pages/_app.tsx +++ b/apps/www/pages/_app.tsx @@ -16,7 +16,7 @@ import Head from 'next/head' import { useRouter } from 'next/router' import { SonnerToaster, themes, TooltipProvider } from 'ui' import { CommandProvider } from 'ui-patterns/CommandMenu' -import { useConsent } from 'ui-patterns/ConsentToast' +import { useConsentToast } from 'ui-patterns/consent' import MetaFaviconsPagesRouter, { DEFAULT_FAVICON_ROUTE, @@ -28,7 +28,7 @@ import useDarkLaunchWeeks from '../hooks/useDarkLaunchWeeks' export default function App({ Component, pageProps }: AppProps) { const router = useRouter() - const { hasAcceptedConsent } = useConsent() + const { hasAcceptedConsent } = useConsentToast() useThemeSandbox() diff --git a/packages/common/consent-state.ts b/packages/common/consent-state.ts new file mode 100644 index 0000000000..10b5b46799 --- /dev/null +++ b/packages/common/consent-state.ts @@ -0,0 +1,109 @@ +import type Usercentrics from '@usercentrics/cmp-browser-sdk' +import type { BaseCategory, UserDecision } from '@usercentrics/cmp-browser-sdk' +import { proxy, snapshot, useSnapshot } from 'valtio' +import { LOCAL_STORAGE_KEYS } from './constants' + +export const consentState = proxy({ + // Usercentrics state + UC: null as Usercentrics | null, + categories: null as BaseCategory[] | null, + + // Our state + 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 + }) + }, +}) + +async function initUserCentrics() { + if (process.env.NODE_ENV === 'test') return + + 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() + + consentState.UC = UC + const hasConsented = UC.areAllConsentsAccepted() + + // 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) + } +} + +// Usercentrics is not available on the server +if (typeof window !== 'undefined') { + initUserCentrics() +} + +// Public API for consent + +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, + } +} diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 2aa48c9d1a..f65187df51 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -1,4 +1,5 @@ export const LOCAL_STORAGE_KEYS = { + /** @deprecated – we're using usercentrics instead to handle telemetry consent */ TELEMETRY_CONSENT: 'supabase-consent-ph', TELEMETRY_DATA: 'supabase-telemetry-data', HIDE_PROMO_TOAST: 'supabase-hide-promo-toast-lw13-d1', diff --git a/packages/common/feature-flags.tsx b/packages/common/feature-flags.tsx index 8e85b519fd..d05507c030 100644 --- a/packages/common/feature-flags.tsx +++ b/packages/common/feature-flags.tsx @@ -5,7 +5,7 @@ import { createContext, PropsWithChildren, useContext, useEffect, useState } fro import { components } from 'api-types' import { useUser } from './auth' -import { LOCAL_STORAGE_KEYS } from './constants' +import { hasConsented } from './consent-state' import { get, post } from './fetchWrappers' import { ensurePlatformSuffix } from './helpers' @@ -19,10 +19,7 @@ export async function getFeatureFlags(API_URL: string) { } export async function trackFeatureFlag(API_URL: string, body: TrackFeatureFlagVariables) { - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null) === 'true' + const consent = hasConsented() if (!consent) return undefined await post(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags/track`, { body }) @@ -83,15 +80,21 @@ export const FeatureFlagProvider = ({ let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} } - // Load PH flags - const flags = await getFeatureFlags(API_URL) + // Run both async operations in parallel + const [flags, flagValues] = await Promise.all([ + getFeatureFlags(API_URL), + typeof getConfigCatFlags === 'function' + ? getConfigCatFlags(user?.email) + : Promise.resolve([]), + ]) + + // Process PostHog flags if (flags) { flagStore.posthog = flags } - // Load ConfigCat flags + // Process ConfigCat flags if (typeof getConfigCatFlags === 'function') { - const flagValues = await getConfigCatFlags(user?.email) let overridesCookieValue: Record = {} try { const cookies = getCookies() diff --git a/packages/common/index.tsx b/packages/common/index.tsx index bb0ac6c555..863f4ebbc4 100644 --- a/packages/common/index.tsx +++ b/packages/common/index.tsx @@ -1,4 +1,5 @@ export * from './auth' +export * from './consent-state' export * from './constants' export * from './database-types' export * from './gotrue' diff --git a/packages/common/package.json b/packages/common/package.json index 9acff42761..79f9e1be36 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -12,13 +12,15 @@ }, "dependencies": { "@types/dat.gui": "^0.7.12", + "@usercentrics/cmp-browser-sdk": "^4.42.0", "@vercel/flags": "^2.6.0", "api-types": "workspace:*", "config": "workspace:*", "dat.gui": "^0.7.9", "lodash": "^4.17.21", "next-themes": "^0.3.0", - "react-use": "^17.4.0" + "react-use": "^17.4.0", + "valtio": "catalog:" }, "devDependencies": { "@types/lodash": "4.17.5", diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index 51e9295aee..d401d209d9 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -4,15 +4,19 @@ import { components } from 'api-types' import { useRouter } from 'next/compat/router' import { usePathname } from 'next/navigation' import { useCallback, useEffect, useRef } from 'react' +import { useLatest } from 'react-use' +import { useUser } from './auth' +import { hasConsented } from './consent-state' import { LOCAL_STORAGE_KEYS } from './constants' import { useFeatureFlags } from './feature-flags' import { post } from './fetchWrappers' import { ensurePlatformSuffix, isBrowser } from './helpers' import { useTelemetryCookie } from './hooks' import { TelemetryEvent } from './telemetry-constants' -import { useUser } from './auth' import { getSharedTelemetryData } from './telemetry-utils' +const { TELEMETRY_DATA } = LOCAL_STORAGE_KEYS + //--- // PAGE TELEMETRY //--- @@ -74,51 +78,67 @@ export const PageTelemetry = ({ const referrer = typeof document !== 'undefined' ? document?.referrer : '' useTelemetryCookie({ hasAcceptedConsent, title, referrer }) + const pathname = + pagesPathname ?? appPathname ?? (isBrowser ? window.location.pathname : undefined) + const pathnameRef = useLatest(pathname) + const featureFlagsRef = useLatest(featureFlags.posthog) + const sendPageTelemetry = useCallback(() => { if (!(enabled && hasAcceptedConsent)) return Promise.resolve() - return handlePageTelemetry( - API_URL, - pagesPathname ?? appPathname ?? undefined, - featureFlags.posthog - ).catch((e) => { + return handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current).catch((e) => { console.error('Problem sending telemetry page:', e) }) - }, [API_URL, pagesPathname, appPathname, hasAcceptedConsent, featureFlags.posthog]) + }, [API_URL, enabled, hasAcceptedConsent]) const sendPageLeaveTelemetry = useCallback(() => { if (!(enabled && hasAcceptedConsent)) return Promise.resolve() - return handlePageTelemetry( - API_URL, - pagesPathname ?? appPathname ?? undefined, - featureFlags.posthog - ).catch((e) => { + return handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current).catch((e) => { console.error('Problem sending telemetry page-leave:', e) }) - }, [pagesPathname, appPathname, hasAcceptedConsent, featureFlags.posthog]) + }, [API_URL, enabled, hasAcceptedConsent]) + // Handle initial page telemetry event const hasSentInitialPageTelemetryRef = useRef(false) - useEffect(() => { // Send page telemetry on first page load // Waiting for router ready before sending page_view // if not the path will be dynamic route instead of the browser url if ( (router?.isReady ?? true) && + hasAcceptedConsent && featureFlags.hasLoaded && !hasSentInitialPageTelemetryRef.current ) { - sendPageTelemetry() + const cookies = document.cookie.split(';') + const telemetryCookie = cookies.find((cookie) => cookie.trim().startsWith(TELEMETRY_DATA)) + if (telemetryCookie) { + try { + const encodedData = telemetryCookie.split('=')[1] + const telemetryData = JSON.parse(decodeURIComponent(encodedData)) + handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current, telemetryData) + // remove the telemetry cookie + document.cookie = `${TELEMETRY_DATA}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` + } catch (error) { + console.error('Invalid telemetry data:', error) + } + } else { + handlePageTelemetry(API_URL, pathnameRef.current, featureFlagsRef.current) + } + hasSentInitialPageTelemetryRef.current = true } - }, [router?.isReady, featureFlags.hasLoaded]) + }, [router?.isReady, hasAcceptedConsent, featureFlags.hasLoaded]) useEffect(() => { // For pages router if (router === null) return function handleRouteChange() { + // Wait until we've sent the initial page telemetry event + if (!hasSentInitialPageTelemetryRef.current) return + sendPageTelemetry() } @@ -134,7 +154,8 @@ export const PageTelemetry = ({ // For app router if (router !== null) return - if (appPathname) { + // Wait until we've sent the initial page telemetry event + if (appPathname && !hasSentInitialPageTelemetryRef.current) { sendPageTelemetry() } }, [appPathname, router, sendPageTelemetry]) @@ -161,11 +182,7 @@ export const PageTelemetry = ({ type EventBody = components['schemas']['TelemetryEventBodyV2Dto'] export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathname?: string) { - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null) === 'true' - + const consent = hasConsented() if (!consent) return const body: EventBody = { @@ -194,10 +211,7 @@ export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathn type IdentifyBody = components['schemas']['TelemetryIdentifyBodyV2'] export function sendTelemetryIdentify(API_URL: string, body: IdentifyBody) { - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - : null) === 'true' + const consent = hasConsented() if (!consent) return Promise.resolve() diff --git a/packages/ui-patterns/ConsentToast/index.tsx b/packages/ui-patterns/ConsentToast/index.tsx index cdf639a05b..aeb288b8f7 100644 --- a/packages/ui-patterns/ConsentToast/index.tsx +++ b/packages/ui-patterns/ConsentToast/index.tsx @@ -17,7 +17,7 @@ export const ConsentToast = ({ onAccept = noop, onOptOut = noop }: ConsentToastP

- We use first-party cookies to improve our services.{' '} + We use cookies to collect data and improve our services.{' '} ) } - -export { useConsent, useConsentValue } from '../shared/consent' diff --git a/packages/ui-patterns/PrivacySettings/index.tsx b/packages/ui-patterns/PrivacySettings/index.tsx index e1981ce5b6..ea1607b3fb 100644 --- a/packages/ui-patterns/PrivacySettings/index.tsx +++ b/packages/ui-patterns/PrivacySettings/index.tsx @@ -1,40 +1,45 @@ 'use client' -import { handleResetTelemetry, LOCAL_STORAGE_KEYS } from 'common' +import { useConsentState } from 'common' import Link from 'next/link' -import { PropsWithChildren, useEffect, useState } from 'react' +import { PropsWithChildren, useState } from 'react' import { Modal, Toggle } from 'ui' -import { useConsentValue } from '../shared/consent' + +interface PrivacySettingsProps { + className?: string +} export const PrivacySettings = ({ children, ...props -}: PropsWithChildren<{ className?: string }>) => { +}: PropsWithChildren) => { const [isOpen, setIsOpen] = useState(false) - const { hasAccepted, handleConsent } = useConsentValue(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) - const [telemetryValue, setTelemetryValue] = useState(hasAccepted) + const { categories, updateServices } = useConsentState() - // Every time the modal opens, sync state with localStorage - useEffect(() => { - setTelemetryValue(localStorage?.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) === 'true') - }, [isOpen]) + const [serviceConsentMap, setServiceConsentMap] = useState(() => new Map()) + + function handleServicesChange(services: { id: string; status: boolean }[]) { + let newServiceConsentMap = new Map(serviceConsentMap) + services.forEach((service) => { + newServiceConsentMap.set(service.id, service.status) + }) + setServiceConsentMap(newServiceConsentMap) + } const handleConfirmPreferences = () => { - handleConsent && handleConsent(telemetryValue ? 'true' : 'false') + const services = Array.from(serviceConsentMap.entries()).map(([id, status]) => ({ + serviceId: id, + status, + })) + updateServices(services) + setIsOpen(false) } const handleCancel = () => { - setTelemetryValue(hasAccepted) setIsOpen(false) } - const handleOptOutTelemetry = async () => { - // remove telemetry data from cookies - document.cookie = `${LOCAL_STORAGE_KEYS.TELEMETRY_DATA}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` - handleResetTelemetry(process.env.NEXT_PUBLIC_API_URL!) - } - return ( <>