Skip to content
Draft
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
1 change: 1 addition & 0 deletions configs/app/features/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@ export { default as validators } from './validators';
export { default as verifiedTokens } from './verifiedTokens';
export { default as web3Wallet } from './web3Wallet';
export { default as xStarScore } from './xStarScore';
export { default as usercentrics } from './usercentrics';
export { default as zetachain } from './zetachain';
32 changes: 32 additions & 0 deletions configs/app/features/usercentrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Feature } from './types';

import app from '../app';
import { getEnvValue, parseEnvJson } from '../utils';

interface UsercentricsConfig {
readonly scriptUrl: string;
readonly rulesetId: string;
}

const title = 'Usercentrics CMP';

const config: Feature<{ scriptUrl: string; rulesetId: string }> = (() => {
if (app.isPrivateMode) {
return Object.freeze({ title, isEnabled: false as const });
}

const rawConfig = parseEnvJson<UsercentricsConfig>(getEnvValue('NEXT_PUBLIC_USERCENTRICS_CONFIG') ?? '');

if (rawConfig?.scriptUrl && rawConfig?.rulesetId) {
return Object.freeze({
title,
isEnabled: true as const,
scriptUrl: rawConfig.scriptUrl,
rulesetId: rawConfig.rulesetId,
});
}

return Object.freeze({ title, isEnabled: false as const });
})();

export default config;
3 changes: 2 additions & 1 deletion configs/envs/.env.eth
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,5 @@ NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_XSTAR_SCORE_URL=https://docs.xname.app/the-solution-adaptive-proof-of-humanity-on-blockchain/xhs-scoring-algorithm?utm_source=blockscout&utm_medium=address
NEXT_PUBLIC_API_KEYS_ALERT_MESSAGE='<strong>Chain-specific API keys are being deprecated.</strong> Please migrate to <a href="https://docs.blockscout.com/for-developers/api-keys/pro-api" target="_blank" rel="noopener noreferrer">Blockscout's PRO API</a> for new multichain access. Existing API keys will become invalid on 1st of Jan 2027'
NEXT_PUBLIC_API_KEYS_ALERT_MESSAGE='<strong>Chain-specific API keys are being deprecated.</strong> Please migrate to <a href="https://docs.blockscout.com/for-developers/api-keys/pro-api" target="_blank" rel="noopener noreferrer">Blockscout's PRO API</a> for new multichain access. Existing API keys will become invalid on 1st of Jan 2027'
NEXT_PUBLIC_USERCENTRICS_CONFIG={'scriptUrl': 'https://web.cmp.usercentrics.eu/ui/loader.js', 'rulesetId': 'bzr6TEix4Oas0i'}
4 changes: 3 additions & 1 deletion cspell.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"playwright/fixtures/rewards.ts",
"public/static/capybara/index.js",
"ui/showcases/utils.ts",
"ui/tx/TxExternalTxs.pw.tsx"
"ui/tx/TxExternalTxs.pw.tsx",
"configs/envs/**"
],
"enableGlobDot": true,
"ignoreRandomStrings": true,
Expand Down Expand Up @@ -250,6 +251,7 @@
"unparse",
"unstaked",
"usehooks",
"usercentrics",
"utia",
"utka",
"utko",
Expand Down
10 changes: 10 additions & 0 deletions deploy/tools/envs-validator/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,16 @@ const schema = yup
}),
NEXT_PUBLIC_FLASHBLOCKS_SOCKET_URL: yup.string().test(urlTest),
NEXT_PUBLIC_HOT_CONTRACTS_ENABLED: yup.boolean(),
NEXT_PUBLIC_USERCENTRICS_CONFIG: yup
.mixed()
.test('shape', 'Invalid schema for NEXT_PUBLIC_USERCENTRICS_CONFIG, it should have scriptUrl and rulesetId', (data) => {
const isUndefined = data === undefined;
const valueSchema = yup.object().transform(replaceQuotes).json().shape({
scriptUrl: yup.string().test(urlTest).required(),
rulesetId: yup.string().required(),
});
return isUndefined || valueSchema.isValidSync(data);
}),

// Misc
NEXT_PUBLIC_USE_NEXT_JS_PROXY: yup.boolean(),
Expand Down
1 change: 1 addition & 0 deletions deploy/tools/envs-validator/test/.env.base
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ NEXT_PUBLIC_WALLET_CONNECT_FEATURED_WALLET_IDS=['xxx']
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_USERCENTRICS_CONFIG='{"scriptUrl":"https://app.usercentrics.eu/browser-ui/latest/loader.js","rulesetId":"xxx"}'
NEXT_PUBLIC_MIXPANEL_CONFIG_OVERRIDES='{"record_sessions_percent": 0.5,"record_heatmap_data": true}'
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
NEXT_PUBLIC_AD_TEXT_PROVIDER=coinzilla
Expand Down
9 changes: 9 additions & 0 deletions docs/ENVS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d
- [Export data to CSV file](#export-data-to-csv-file)
- [Google analytics](#google-analytics)
- [Mixpanel analytics](#mixpanel-analytics)
- [Usercentrics CMP](#usercentrics-cmp)
- [GrowthBook feature flagging and A/B testing](#growthbook-feature-flagging-and-ab-testing)
- [GraphQL API documentation](#graphql-api-documentation)
- [API documentation](#api-documentation)
Expand Down Expand Up @@ -581,6 +582,14 @@ Ads are enabled by default on all self-hosted instances. If you would like to di

&nbsp;

### Usercentrics CMP

| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_USERCENTRICS_CONFIG | `object` | JSON config for [Usercentrics](https://usercentrics.com/) Consent Management Platform. When set, the UC script is injected and all analytics (Google Analytics, Mixpanel, Rollbar) are gated behind user consent. Disabled in private mode. | true | - | `{'scriptUrl':'https://your-cdn.com/uc.js','rulesetId':'<your-ruleset-id>'}` | v1.37.x+ |

&nbsp;

### GrowthBook feature flagging and A/B testing

| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
Expand Down
6 changes: 6 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ declare global {
__envs: Record<string, string>;
__multichainConfig?: MultichainConfig;
__essentialDappsChains?: { chains: Array<EssentialDappsChainConfig> };
__ucCmp?: {
getConsentDetails(): Promise<{
consent?: { status?: string };
categories?: Record<string, { state?: string }>;
}>;
};
}

namespace NodeJS {
Expand Down
21 changes: 20 additions & 1 deletion lib/mixpanel/useInit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import config from 'configs/app';
import * as cookies from 'lib/cookies';
import dayjs from 'lib/date/dayjs';
import getQueryParamString from 'lib/router/getQueryParamString';
import useUsercentricsMarketingConsent from 'lib/usercentrics/useConsent';

import * as userProfile from './userProfile';

Expand All @@ -17,14 +18,31 @@ const opSuperchainFeature = config.features.opSuperchain;
export default function useMixpanelInit() {
const [ isInitialized, setIsInitialized ] = React.useState(false);
const router = useRouter();
const hasConsent = useUsercentricsMarketingConsent();
const debugFlagQuery = React.useRef(getQueryParamString(router.query._mixpanel_debug));
const isInitializedRef = React.useRef(false);

React.useEffect(() => {
const feature = config.features.mixpanel;
if (!feature.isEnabled) {
return;
}

if (!hasConsent) {
if (isInitializedRef.current) {
// Consent was withdrawn after Mixpanel was already running — stop all tracking
isInitializedRef.current = false;
setIsInitialized(false);
try {
mixpanel.opt_out_tracking();
} catch {
// opt_out_tracking can throw if called before Mixpanel's internal state is ready
mixpanel.disable();
}
}
return;
}

const debugFlagCookie = cookies.get(cookies.NAMES.MIXPANEL_DEBUG);

const mixpanelConfig: Partial<Config> = {
Expand Down Expand Up @@ -57,11 +75,12 @@ export default function useMixpanelInit() {
'First Time Join': dayjs().toISOString(),
});

isInitializedRef.current = true;
setIsInitialized(true);
if (debugFlagQuery.current && !debugFlagCookie) {
cookies.set(cookies.NAMES.MIXPANEL_DEBUG, 'true');
}
}, [ ]);
}, [ hasConsent ]);

return isInitialized;
}
39 changes: 39 additions & 0 deletions lib/usercentrics/useConsent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';

import config from 'configs/app';

async function checkMarketingConsent(): Promise<boolean> {
if (!window.__ucCmp) {
return false;
}
const details = await window.__ucCmp.getConsentDetails();
return details.categories?.marketing?.state === 'ALL_ACCEPTED';
}

export default function useUsercentricsMarketingConsent(): boolean {
const [ hasConsent, setHasConsent ] = React.useState<boolean>(!config.features.usercentrics.isEnabled);

React.useEffect(() => {
if (!config.features.usercentrics.isEnabled) {
return;
}

const updateConsent = async() => {
setHasConsent(await checkMarketingConsent());
};

// Get initial consent state
updateConsent();

// Re-check on every consent change and on CMP initialization
window.addEventListener('UC_CONSENT', updateConsent);
window.addEventListener('UC_UI_INITIALIZED', updateConsent);

return () => {
window.removeEventListener('UC_CONSENT', updateConsent);
window.removeEventListener('UC_UI_INITIALIZED', updateConsent);
};
}, []);

return hasConsent;
}
1 change: 1 addition & 0 deletions nextjs/csp/generateCspPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function generateCspPolicy(isPrivateMode = false) {
descriptors.monaco(),
descriptors.multichain(),
isPrivateMode ? {} : descriptors.rollbar(),
isPrivateMode ? {} : descriptors.usercentrics(),
descriptors.rollup(),
descriptors.safe(),
descriptors.usernameApi(),
Expand Down
2 changes: 1 addition & 1 deletion nextjs/csp/policies/googleAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function googleAnalytics(): CspDev.DirectiveDescriptor {
'https://stats.g.doubleclick.net',
],
'script-src': [
// inline script hash, see ui/shared/GoogleAnalytics.tsx
// inline script hash, see GA_INLINE_SCRIPT in pages/_document.tsx
'\'sha256-WXRwCtfSfMoCPzPUIOUAosSaADdGgct0/Lhmnbm7MCA=\'',
'https://www.googletagmanager.com',
'*.google-analytics.com',
Expand Down
1 change: 1 addition & 0 deletions nextjs/csp/policies/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ export { multichain } from './multichain';
export { rollbar } from './rollbar';
export { rollup } from './rollup';
export { safe } from './safe';
export { usercentrics } from './usercentrics';
export { usernameApi } from './usernameApi';
export { zetachain } from './zetachain';
31 changes: 31 additions & 0 deletions nextjs/csp/policies/usercentrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type CspDev from 'csp-dev';

import config from 'configs/app';
const feature = config.features.usercentrics;

export function usercentrics(): CspDev.DirectiveDescriptor {
if (!feature.isEnabled) {
return {};
}

let scriptOrigin: string;
try {
scriptOrigin = new URL(feature.scriptUrl).origin;
} catch {
return {};
}

return {
'script-src': [
scriptOrigin,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we provide the complete link to the script instead of just its origin to make the rule stricter?

'https://web.cmp.usercentrics.eu/',
],
'connect-src': [
scriptOrigin,
'https://api.usercentrics.eu/',
'https://web.cmp.usercentrics.eu/',
'https://v1.api.service.cmp.usercentrics.eu/',
'https://consent-api.service.consent.usercentrics.eu/',
],
};
}
12 changes: 9 additions & 3 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ import { initGrowthBook } from 'lib/growthbook/init';
import useLoadFeatures from 'lib/growthbook/useLoadFeatures';
import { clientConfig as rollbarConfig, Provider as RollbarProvider } from 'lib/rollbar';
import { SocketProvider } from 'lib/socket/context';
import useUsercentricsMarketingConsent from 'lib/usercentrics/useConsent';
import { Provider as ChakraProvider } from 'toolkit/chakra/provider';
import { Toaster } from 'toolkit/chakra/toaster';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import AppErrorGlobalContainer from 'ui/shared/AppError/AppErrorGlobalContainer';
import GoogleAnalytics from 'ui/shared/GoogleAnalytics';
import Layout from 'ui/shared/layout/Layout';
import Web3Provider from 'ui/shared/web3/Web3Provider';

Expand Down Expand Up @@ -63,6 +63,13 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
useLoadFeatures(growthBook);

const queryClient = useQueryClientConfig();
const analyticsConsent = useUsercentricsMarketingConsent();
const effectiveRollbarConfig = React.useMemo(() => {
if (!config.features.usercentrics.isEnabled || !rollbarConfig) {
return rollbarConfig;
}
return analyticsConsent ? rollbarConfig : { ...rollbarConfig, enabled: false };
}, [ analyticsConsent ]);

React.useEffect(() => {
// after the app is rendered/hydrated, show the console scam warning
Expand Down Expand Up @@ -97,7 +104,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {

return (
<ChakraProvider>
<RollbarProvider config={ rollbarConfig }>
<RollbarProvider config={ effectiveRollbarConfig }>
<AppErrorBoundary
{ ...ERROR_SCREEN_STYLES }
Container={ AppErrorGlobalContainer }
Expand All @@ -117,7 +124,6 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
</SocketProvider>
</GrowthBookProvider>
<ReactQueryDevtools buttonPosition="bottom-left" position="left"/>
<GoogleAnalytics/>
</AppContextProvider>
</Web3Provider>
</QueryClientProvider>
Expand Down
32 changes: 32 additions & 0 deletions pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ import config from 'configs/app';
import * as svgSprite from 'ui/shared/IconSvg';

const marketplaceFeature = config.features.marketplace;
const usercentricsFeature = config.features.usercentrics;
const googleAnalyticsFeature = config.features.googleAnalytics;

// Inline GA config script; hash is used in CSP policy (nextjs/csp/policies/googleAnalytics.ts)
const GA_INLINE_SCRIPT = `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', window.__envs.NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID);
`;

class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
Expand Down Expand Up @@ -67,6 +77,28 @@ class MyDocument extends Document {
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon-180x180.png"/>
<link rel="icon" type="image/png" sizes="192x192" href="/assets/favicon/android-chrome-192x192.png"/>
<link rel="preload" as="image" href={ svgSprite.href }/>

{ /* USERCENTRICS */ }
{ usercentricsFeature.isEnabled && (
<script id="usercentrics-cmp" src={ usercentricsFeature.scriptUrl } data-settings-id={ usercentricsFeature.rulesetId } async/>
) }

{ /* GOOGLE ANALYTICS */ }
{ googleAnalyticsFeature.isEnabled && (
<>
<script
{ ...(usercentricsFeature.isEnabled ? { type: 'text/plain', 'data-usercentrics': 'Google Analytics' } : {}) }
async
src={ `https://www.googletagmanager.com/gtag/js?id=${ googleAnalyticsFeature.propertyId }` }
/>
{ }
<script
{ ...(usercentricsFeature.isEnabled ? { type: 'text/plain', 'data-usercentrics': 'Google Analytics' } : {}) }
dangerouslySetInnerHTML={{ __html: GA_INLINE_SCRIPT }}
/>
</>
) }
Comment on lines +87 to +100
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it necessary to include the Google Analytics script in the head tag instead of loading it after the page loads? I assume there will be two scripts loaded: one from here and another from the ui/shared/GoogleAnalytics.tsx component.


</Head>
<body>
<Main/>
Expand Down
5 changes: 4 additions & 1 deletion ui/shared/GoogleAnalytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import Script from 'next/script';
import React from 'react';

import config from 'configs/app';
import useUsercentricsConsent from 'lib/usercentrics/useConsent';

const feature = config.features.googleAnalytics;

const GoogleAnalytics = () => {
if (!feature.isEnabled) {
const hasConsent = useUsercentricsConsent();

if (!feature.isEnabled || !hasConsent) {
return null;
}

Expand Down