Skip to content

Commit 50f44ce

Browse files
committed
Add Usercentrics CMP (cookie consent + GDPR compliance)
1 parent b6d92b4 commit 50f44ce

17 files changed

Lines changed: 199 additions & 8 deletions

File tree

configs/app/features/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,5 @@ export { default as validators } from './validators';
5050
export { default as verifiedTokens } from './verifiedTokens';
5151
export { default as web3Wallet } from './web3Wallet';
5252
export { default as xStarScore } from './xStarScore';
53+
export { default as usercentrics } from './usercentrics';
5354
export { default as zetachain } from './zetachain';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Feature } from './types';
2+
3+
import app from '../app';
4+
import { getEnvValue, parseEnvJson } from '../utils';
5+
6+
interface UsercentricsConfig {
7+
readonly scriptUrl: string;
8+
readonly rulesetId: string;
9+
}
10+
11+
const title = 'Usercentrics CMP';
12+
13+
const config: Feature<{ scriptUrl: string; rulesetId: string }> = (() => {
14+
if (app.isPrivateMode) {
15+
return Object.freeze({ title, isEnabled: false as const });
16+
}
17+
18+
const rawConfig = parseEnvJson<UsercentricsConfig>(getEnvValue('NEXT_PUBLIC_USERCENTRICS_CONFIG') ?? '');
19+
20+
if (rawConfig?.scriptUrl && rawConfig?.rulesetId) {
21+
return Object.freeze({
22+
title,
23+
isEnabled: true as const,
24+
scriptUrl: rawConfig.scriptUrl,
25+
rulesetId: rawConfig.rulesetId,
26+
});
27+
}
28+
29+
return Object.freeze({ title, isEnabled: false as const });
30+
})();
31+
32+
export default config;

configs/envs/.env.eth

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,5 @@ NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'OpenSea','collection_url':'https://
7676
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
7777
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
7878
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
79-
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'
79+
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'
80+
NEXT_PUBLIC_USERCENTRICS_CONFIG={'scriptUrl': 'https://web.cmp.usercentrics.eu/ui/loader.js', 'rulesetId': 'bzr6TEix4Oas0i'}

cspell.jsonc

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"playwright/fixtures/rewards.ts",
1515
"public/static/capybara/index.js",
1616
"ui/showcases/utils.ts",
17-
"ui/tx/TxExternalTxs.pw.tsx"
17+
"ui/tx/TxExternalTxs.pw.tsx",
18+
"configs/envs/**"
1819
],
1920
"enableGlobDot": true,
2021
"ignoreRandomStrings": true,
@@ -250,6 +251,7 @@
250251
"unparse",
251252
"unstaked",
252253
"usehooks",
254+
"usercentrics",
253255
"utia",
254256
"utka",
255257
"utko",

deploy/tools/envs-validator/schema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,16 @@ const schema = yup
146146
}),
147147
NEXT_PUBLIC_FLASHBLOCKS_SOCKET_URL: yup.string().test(urlTest),
148148
NEXT_PUBLIC_HOT_CONTRACTS_ENABLED: yup.boolean(),
149+
NEXT_PUBLIC_USERCENTRICS_CONFIG: yup
150+
.mixed()
151+
.test('shape', 'Invalid schema for NEXT_PUBLIC_USERCENTRICS_CONFIG, it should have scriptUrl and rulesetId', (data) => {
152+
const isUndefined = data === undefined;
153+
const valueSchema = yup.object().transform(replaceQuotes).json().shape({
154+
scriptUrl: yup.string().test(urlTest).required(),
155+
rulesetId: yup.string().required(),
156+
});
157+
return isUndefined || valueSchema.isValidSync(data);
158+
}),
149159

150160
// Misc
151161
NEXT_PUBLIC_USE_NEXT_JS_PROXY: yup.boolean(),

deploy/tools/envs-validator/test/.env.base

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ NEXT_PUBLIC_WALLET_CONNECT_FEATURED_WALLET_IDS=['xxx']
55
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
66
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
77
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
8+
NEXT_PUBLIC_USERCENTRICS_CONFIG='{"scriptUrl":"https://app.usercentrics.eu/browser-ui/latest/loader.js","rulesetId":"xxx"}'
89
NEXT_PUBLIC_MIXPANEL_CONFIG_OVERRIDES='{"record_sessions_percent": 0.5,"record_heatmap_data": true}'
910
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
1011
NEXT_PUBLIC_AD_TEXT_PROVIDER=coinzilla

docs/ENVS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d
4646
- [Export data to CSV file](#export-data-to-csv-file)
4747
- [Google analytics](#google-analytics)
4848
- [Mixpanel analytics](#mixpanel-analytics)
49+
- [Usercentrics CMP](#usercentrics-cmp)
4950
- [GrowthBook feature flagging and A/B testing](#growthbook-feature-flagging-and-ab-testing)
5051
- [GraphQL API documentation](#graphql-api-documentation)
5152
- [API documentation](#api-documentation)
@@ -581,6 +582,14 @@ Ads are enabled by default on all self-hosted instances. If you would like to di
581582

582583
&nbsp;
583584

585+
### Usercentrics CMP
586+
587+
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
588+
| --- | --- | --- | --- | --- | --- | --- |
589+
| 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+ |
590+
591+
&nbsp;
592+
584593
### GrowthBook feature flagging and A/B testing
585594

586595
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |

global.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ declare global {
2424
__envs: Record<string, string>;
2525
__multichainConfig?: MultichainConfig;
2626
__essentialDappsChains?: { chains: Array<EssentialDappsChainConfig> };
27+
__ucCmp?: {
28+
getConsentDetails(): Promise<{ consent?: { status?: string } }>;
29+
};
2730
}
2831

2932
namespace NodeJS {

lib/mixpanel/useInit.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import config from 'configs/app';
99
import * as cookies from 'lib/cookies';
1010
import dayjs from 'lib/date/dayjs';
1111
import getQueryParamString from 'lib/router/getQueryParamString';
12+
import useUsercentricsConsent from 'lib/usercentrics/useConsent';
1213

1314
import * as userProfile from './userProfile';
1415

@@ -17,14 +18,31 @@ const opSuperchainFeature = config.features.opSuperchain;
1718
export default function useMixpanelInit() {
1819
const [ isInitialized, setIsInitialized ] = React.useState(false);
1920
const router = useRouter();
21+
const hasConsent = useUsercentricsConsent();
2022
const debugFlagQuery = React.useRef(getQueryParamString(router.query._mixpanel_debug));
23+
const isInitializedRef = React.useRef(false);
2124

2225
React.useEffect(() => {
2326
const feature = config.features.mixpanel;
2427
if (!feature.isEnabled) {
2528
return;
2629
}
2730

31+
if (!hasConsent) {
32+
if (isInitializedRef.current) {
33+
// Consent was withdrawn after Mixpanel was already running — stop all tracking
34+
isInitializedRef.current = false;
35+
setIsInitialized(false);
36+
try {
37+
mixpanel.opt_out_tracking();
38+
} catch {
39+
// opt_out_tracking can throw if called before Mixpanel's internal state is ready
40+
mixpanel.disable();
41+
}
42+
}
43+
return;
44+
}
45+
2846
const debugFlagCookie = cookies.get(cookies.NAMES.MIXPANEL_DEBUG);
2947

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

78+
isInitializedRef.current = true;
6079
setIsInitialized(true);
6180
if (debugFlagQuery.current && !debugFlagCookie) {
6281
cookies.set(cookies.NAMES.MIXPANEL_DEBUG, 'true');
6382
}
64-
}, [ ]);
83+
}, [ hasConsent ]);
6584

6685
return isInitialized;
6786
}

lib/usercentrics/useConsent.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from 'react';
2+
3+
import config from 'configs/app';
4+
5+
async function checkConsent(): Promise<boolean> {
6+
if (!window.__ucCmp) {
7+
return false;
8+
}
9+
const details = await window.__ucCmp.getConsentDetails();
10+
return details.consent?.status === 'ALL_ACCEPTED';
11+
}
12+
13+
export default function useUsercentricsConsent(): boolean {
14+
const [ hasConsent, setHasConsent ] = React.useState<boolean>(!config.features.usercentrics.isEnabled);
15+
16+
React.useEffect(() => {
17+
if (!config.features.usercentrics.isEnabled) {
18+
return;
19+
}
20+
21+
const updateConsent = async() => {
22+
setHasConsent(await checkConsent());
23+
};
24+
25+
// Get initial consent state
26+
updateConsent();
27+
28+
// Re-check on every consent change and on CMP initialization
29+
window.addEventListener('UC_CONSENT', updateConsent);
30+
window.addEventListener('UC_UI_INITIALIZED', updateConsent);
31+
32+
return () => {
33+
window.removeEventListener('UC_CONSENT', updateConsent);
34+
window.removeEventListener('UC_UI_INITIALIZED', updateConsent);
35+
};
36+
}, []);
37+
38+
return hasConsent;
39+
}

0 commit comments

Comments
 (0)