cloudflare-next-intl 0.3.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -0
- package/dist/src/client/components/client_provider.d.ts +11 -1
- package/dist/src/client/components/client_provider.js +7 -2
- package/dist/src/config/cookie_key.d.ts +2 -0
- package/dist/src/config/cookie_key.js +2 -0
- package/dist/src/config/index.d.ts +1 -1
- package/dist/src/config/index.js +1 -1
- package/dist/src/cookie_consent/client/components/cookie_consent_analytics.bench.d.ts +1 -0
- package/dist/src/cookie_consent/client/components/cookie_consent_analytics.bench.js +14 -0
- package/dist/src/cookie_consent/client/components/cookie_consent_analytics.d.ts +19 -0
- package/dist/src/cookie_consent/client/components/cookie_consent_analytics.js +91 -0
- package/dist/src/cookie_consent/client/components/cookie_consent_dialog.d.ts +28 -0
- package/dist/src/cookie_consent/client/components/cookie_consent_dialog.js +17 -0
- package/dist/src/cookie_consent/client/components/privacy_policy_update_dialog.d.ts +24 -0
- package/dist/src/cookie_consent/client/components/privacy_policy_update_dialog.js +17 -0
- package/dist/src/cookie_consent/client/cookie_consent_provider.d.ts +36 -0
- package/dist/src/cookie_consent/client/cookie_consent_provider.js +94 -0
- package/dist/src/cookie_consent/client/use_cookie_consent.d.ts +6 -0
- package/dist/src/cookie_consent/client/use_cookie_consent.js +14 -0
- package/dist/src/cookie_consent/gdpr_countries.bench.d.ts +1 -0
- package/dist/src/cookie_consent/gdpr_countries.bench.js +33 -0
- package/dist/src/cookie_consent/gdpr_countries.d.ts +18 -0
- package/dist/src/cookie_consent/gdpr_countries.js +52 -0
- package/dist/src/cookie_consent/index.d.ts +8 -0
- package/dist/src/cookie_consent/index.js +6 -0
- package/dist/src/cookie_consent/require_config.d.ts +7 -0
- package/dist/src/cookie_consent/require_config.js +13 -0
- package/dist/src/cookie_consent/types.d.ts +37 -0
- package/dist/src/cookie_consent/types.js +1 -0
- package/dist/src/firebase_auth/client/auth_user_provider.js +27 -4
- package/dist/src/server/components/server_provider.js +14 -1
- package/dist/src/types/index.d.ts +1 -1
- package/dist/src/types/types.d.ts +113 -0
- package/llms.txt +10 -0
- package/package.json +30 -1
package/README.md
CHANGED
|
@@ -185,6 +185,51 @@ import ThemeSwitcher from "cloudflare-next-intl/ThemeSwitcher";
|
|
|
185
185
|
<ThemeSwitcher lightLabelText="Light" darkLabelText="Dark" />
|
|
186
186
|
```
|
|
187
187
|
|
|
188
|
+
### Cookie consent
|
|
189
|
+
|
|
190
|
+
Set `cookieConsent` on your `RoutingConfig` to enable — `IntlProvider` then
|
|
191
|
+
auto-wires `CookieConsentProvider` (and `CookieConsentAnalytics`, if
|
|
192
|
+
`cookieConsent.secrets`/`getSecrets` is set) with no manual nesting needed.
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// intl-config.ts
|
|
196
|
+
export default setIntlConfig({
|
|
197
|
+
locales: ["en", "de"],
|
|
198
|
+
defaultLocale: "en",
|
|
199
|
+
cookieConsent: {
|
|
200
|
+
privacyPolicyDate: "2026-01-01",
|
|
201
|
+
// Optional: gate the banner to GDPR-region visitors only. Omit both
|
|
202
|
+
// getters to disable country-based gating (consent always implicit).
|
|
203
|
+
getCloudflareContext: () => getCloudflareContext(),
|
|
204
|
+
// gdprCountries: [...], // defaults to EU/EEA + UK + Switzerland
|
|
205
|
+
// enableAnalyticsInDevMode: true, // analytics stay off in dev otherwise
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
```tsx
|
|
211
|
+
import { CookieConsentDialog, PrivacyPolicyUpdateDialog, useCookieConsent } from "cloudflare-next-intl/cookieConsent";
|
|
212
|
+
|
|
213
|
+
export default function Layout({ children }) {
|
|
214
|
+
return (
|
|
215
|
+
<>
|
|
216
|
+
{children}
|
|
217
|
+
<CookieConsentDialog />
|
|
218
|
+
<PrivacyPolicyUpdateDialog />
|
|
219
|
+
</>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
"use client";
|
|
226
|
+
import { useCookieConsent } from "cloudflare-next-intl/useCookieConsent";
|
|
227
|
+
|
|
228
|
+
const { consent, setConsent } = useCookieConsent();
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
See [`package/src/cookie_consent/README.md`](package/src/cookie_consent/README.md) for layout, customization, and gotchas.
|
|
232
|
+
|
|
188
233
|
## License
|
|
189
234
|
|
|
190
235
|
MIT
|
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import type { TranslationObject } from "../../types/types";
|
|
2
2
|
import type { SerializedAuthUser } from "../../firebase_auth/types";
|
|
3
|
+
import type { CookieConsentAnalyticsSecrets } from "../../types/types";
|
|
3
4
|
interface LocaleContextType {
|
|
4
5
|
language: string;
|
|
5
6
|
messages: TranslationObject;
|
|
6
7
|
}
|
|
7
8
|
export declare const LocaleContext: import("react").Context<LocaleContextType | undefined>;
|
|
8
|
-
export default function LocationzationClientProvider({ language, messages, initialAuthUser, skipAuthProvider, children }: {
|
|
9
|
+
export default function LocationzationClientProvider({ language, messages, initialAuthUser, skipAuthProvider, analyticsSecrets, requiresConsent, children }: {
|
|
9
10
|
language: string;
|
|
10
11
|
messages: TranslationObject;
|
|
11
12
|
initialAuthUser?: SerializedAuthUser | null;
|
|
12
13
|
/** Set when `firebaseAuth.autoWireClientProvider` is `false` — skips wrapping `children` in the client `AuthUserProvider` entirely. */
|
|
13
14
|
skipAuthProvider?: boolean;
|
|
15
|
+
/** Resolved server-side from `cookieConsent.secrets`/`getSecrets` when `autoWireAnalytics` isn't `false`. */
|
|
16
|
+
analyticsSecrets?: CookieConsentAnalyticsSecrets;
|
|
17
|
+
/**
|
|
18
|
+
* Resolved server-side from `cookieConsent.getCountryCode`/`gdprCountries`.
|
|
19
|
+
* `false` means the visitor's country doesn't require the consent
|
|
20
|
+
* banner — `CookieConsentProvider` seeds consent as implicitly granted
|
|
21
|
+
* for a first-time visitor instead of `null`.
|
|
22
|
+
*/
|
|
23
|
+
requiresConsent?: boolean;
|
|
14
24
|
children: React.ReactNode;
|
|
15
25
|
}): Component;
|
|
16
26
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { setLocaleCache, setMessageForLocaleCache } from "../../general/cache_variables";
|
|
4
4
|
import { createContext, useMemo } from "react";
|
|
5
5
|
import dynamic from "next/dynamic";
|
|
@@ -13,7 +13,9 @@ export const LocaleContext = createContext(undefined);
|
|
|
13
13
|
// update (and a `getIdToken(true)` refresh) that causes another render —
|
|
14
14
|
// an infinite loop of session-cookie writes, one per render.
|
|
15
15
|
const AuthUserProvider = dynamic(() => import("../../firebase_auth/client/auth_user_provider"));
|
|
16
|
-
|
|
16
|
+
const CookieConsentProvider = dynamic(() => import("../../cookie_consent/client/cookie_consent_provider"));
|
|
17
|
+
const CookieConsentAnalytics = dynamic(() => import("../../cookie_consent/client/components/cookie_consent_analytics"));
|
|
18
|
+
export default function LocationzationClientProvider({ language, messages, initialAuthUser = null, skipAuthProvider = false, analyticsSecrets, requiresConsent = true, children }) {
|
|
17
19
|
setLocaleCache(language);
|
|
18
20
|
setMessageForLocaleCache(language, messages);
|
|
19
21
|
// `LocaleContext.Provider` stays the outermost element here — the
|
|
@@ -25,6 +27,9 @@ export default function LocationzationClientProvider({ language, messages, initi
|
|
|
25
27
|
if (config.firebaseAuth && !skipAuthProvider) {
|
|
26
28
|
providedChildren = _jsx(AuthUserProvider, { initialUser: initialAuthUser, children: children });
|
|
27
29
|
}
|
|
30
|
+
if (config.cookieConsent) {
|
|
31
|
+
providedChildren = _jsxs(CookieConsentProvider, { requiresConsent: requiresConsent, children: [providedChildren, analyticsSecrets && _jsx(CookieConsentAnalytics, { secrets: analyticsSecrets })] });
|
|
32
|
+
}
|
|
28
33
|
const contextValue = useMemo(() => ({ language, messages }), [language, messages]);
|
|
29
34
|
return _jsx(LocaleContext.Provider, { value: contextValue, children: providedChildren });
|
|
30
35
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export declare const localeCookieName = "__user_locale_key__";
|
|
2
2
|
export declare const isBotCookieKey = "__is_bot_key__";
|
|
3
3
|
export declare const isDarkCookieKey = "__is_dark_key__";
|
|
4
|
+
export declare const cookieConsentCookieKey = "__cookie_consent_key__";
|
|
5
|
+
export declare const privacyPolicyDateCookieKey = "__privacy_policy_date_key__";
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export const localeCookieName = '__user_locale_key__';
|
|
2
2
|
export const isBotCookieKey = '__is_bot_key__';
|
|
3
3
|
export const isDarkCookieKey = '__is_dark_key__';
|
|
4
|
+
export const cookieConsentCookieKey = '__cookie_consent_key__';
|
|
5
|
+
export const privacyPolicyDateCookieKey = '__privacy_policy_date_key__';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { isBotCookieKey, localeCookieName, isDarkCookieKey } from './cookie_key';
|
|
1
|
+
export { isBotCookieKey, localeCookieName, isDarkCookieKey, cookieConsentCookieKey, privacyPolicyDateCookieKey } from './cookie_key';
|
|
2
2
|
export { default as intlMiddleware } from './middleware';
|
|
3
3
|
export { setIntlConfig } from './init_config';
|
|
4
4
|
export { default as generateIntlSitemap } from './intl_sitemap';
|
package/dist/src/config/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { isBotCookieKey, localeCookieName, isDarkCookieKey } from './cookie_key'; // Export specific middleware function
|
|
1
|
+
export { isBotCookieKey, localeCookieName, isDarkCookieKey, cookieConsentCookieKey, privacyPolicyDateCookieKey } from './cookie_key'; // Export specific middleware function
|
|
2
2
|
export { default as intlMiddleware } from './middleware'; // Export specific middleware function
|
|
3
3
|
export { setIntlConfig } from './init_config';
|
|
4
4
|
export { default as generateIntlSitemap } from './intl_sitemap';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { bench, describe } from 'vitest';
|
|
2
|
+
import { googleConsentModeBootstrapScript } from './cookie_consent_analytics';
|
|
3
|
+
describe('googleConsentModeBootstrapScript', () => {
|
|
4
|
+
bench('all providers configured', () => {
|
|
5
|
+
googleConsentModeBootstrapScript({
|
|
6
|
+
googleAnalyticsId: 'G-XXX',
|
|
7
|
+
googleAdsId: 'AW-YYY',
|
|
8
|
+
googleAdSenseId: 'ca-pub-ZZZ',
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
bench('no providers configured', () => {
|
|
12
|
+
googleConsentModeBootstrapScript({});
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CookieConsentAnalyticsSecrets } from '../../../types/types';
|
|
2
|
+
/**
|
|
3
|
+
* Renders whichever analytics/ads scripts have a resolved secret, gated on
|
|
4
|
+
* consent: Google Consent Mode bootstrap always loads (defaults to
|
|
5
|
+
* `denied`, only sends `update` once `consent` is decided); Cloudflare Web
|
|
6
|
+
* Analytics beacon and Microsoft Clarity only load once `consent === true`.
|
|
7
|
+
* Rendered automatically by `IntlProvider` when `cookieConsent.secrets`/
|
|
8
|
+
* `getSecrets` resolves at least one field and `autoWireAnalytics` isn't
|
|
9
|
+
* `false` — render manually instead if you set `autoWireAnalytics: false`.
|
|
10
|
+
*/
|
|
11
|
+
export default function CookieConsentAnalytics({ secrets }: {
|
|
12
|
+
secrets: CookieConsentAnalyticsSecrets;
|
|
13
|
+
}): React.ReactElement | null;
|
|
14
|
+
/**
|
|
15
|
+
* Denies storage by default and loads the configured Google tags; the
|
|
16
|
+
* effect above sends the `update` once consent is known. Only IDs present
|
|
17
|
+
* in `secrets` are included.
|
|
18
|
+
*/
|
|
19
|
+
export declare function googleConsentModeBootstrapScript(secrets: CookieConsentAnalyticsSecrets): string;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import useCookieConsent from '../use_cookie_consent';
|
|
5
|
+
/**
|
|
6
|
+
* Renders whichever analytics/ads scripts have a resolved secret, gated on
|
|
7
|
+
* consent: Google Consent Mode bootstrap always loads (defaults to
|
|
8
|
+
* `denied`, only sends `update` once `consent` is decided); Cloudflare Web
|
|
9
|
+
* Analytics beacon and Microsoft Clarity only load once `consent === true`.
|
|
10
|
+
* Rendered automatically by `IntlProvider` when `cookieConsent.secrets`/
|
|
11
|
+
* `getSecrets` resolves at least one field and `autoWireAnalytics` isn't
|
|
12
|
+
* `false` — render manually instead if you set `autoWireAnalytics: false`.
|
|
13
|
+
*/
|
|
14
|
+
export default function CookieConsentAnalytics({ secrets }) {
|
|
15
|
+
const { consent } = useCookieConsent();
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (consent === null)
|
|
18
|
+
return;
|
|
19
|
+
const w = window;
|
|
20
|
+
if (typeof w.gtag !== 'function')
|
|
21
|
+
return;
|
|
22
|
+
const state = consent ? 'granted' : 'denied';
|
|
23
|
+
w.gtag('consent', 'update', {
|
|
24
|
+
ad_storage: state,
|
|
25
|
+
ad_user_data: state,
|
|
26
|
+
ad_personalization: state,
|
|
27
|
+
analytics_storage: state,
|
|
28
|
+
});
|
|
29
|
+
}, [consent]);
|
|
30
|
+
const hasGoogle = Boolean(secrets.googleAnalyticsId || secrets.googleAdsId || secrets.googleAdSenseId);
|
|
31
|
+
return (_jsxs(_Fragment, { children: [hasGoogle && (_jsx("script", { id: "cookie-consent-google-consent-mode", dangerouslySetInnerHTML: { __html: googleConsentModeBootstrapScript(secrets) } })), consent === true && secrets.cloudflareBeaconToken && (_jsx("script", { defer: true, src: "https://static.cloudflareinsights.com/beacon.min.js", "data-cf-beacon": secrets.cloudflareBeaconToken })), consent === true && secrets.clarityProjectId && _jsx(ClarityScript, { projectId: secrets.clarityProjectId })] }));
|
|
32
|
+
}
|
|
33
|
+
let cachedClarityModule;
|
|
34
|
+
function getClarityModule() {
|
|
35
|
+
if (!cachedClarityModule) {
|
|
36
|
+
cachedClarityModule = import('@microsoft/clarity');
|
|
37
|
+
}
|
|
38
|
+
return cachedClarityModule;
|
|
39
|
+
}
|
|
40
|
+
function ClarityScript({ projectId }) {
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
getClarityModule()
|
|
43
|
+
.then(({ default: Clarity }) => {
|
|
44
|
+
Clarity.init(projectId);
|
|
45
|
+
Clarity.consent();
|
|
46
|
+
})
|
|
47
|
+
.catch((error) => console.error(`cloudflare-next-intl: failed to load @microsoft/clarity: ${error}`));
|
|
48
|
+
}, [projectId]);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Denies storage by default and loads the configured Google tags; the
|
|
53
|
+
* effect above sends the `update` once consent is known. Only IDs present
|
|
54
|
+
* in `secrets` are included.
|
|
55
|
+
*/
|
|
56
|
+
export function googleConsentModeBootstrapScript(secrets) {
|
|
57
|
+
const configCalls = [secrets.googleAnalyticsId, secrets.googleAdsId]
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.map((id) => `gtag('config', '${id}');`)
|
|
60
|
+
.join('\n');
|
|
61
|
+
const gtagLoader = secrets.googleAnalyticsId || secrets.googleAdsId
|
|
62
|
+
? `(function(){
|
|
63
|
+
var s = document.createElement('script');
|
|
64
|
+
s.async = true;
|
|
65
|
+
s.src = 'https://www.googletagmanager.com/gtag/js?id=${secrets.googleAnalyticsId ?? secrets.googleAdsId}';
|
|
66
|
+
document.head.appendChild(s);
|
|
67
|
+
})();`
|
|
68
|
+
: '';
|
|
69
|
+
const adSenseLoader = secrets.googleAdSenseId
|
|
70
|
+
? `(function(){
|
|
71
|
+
var a = document.createElement('script');
|
|
72
|
+
a.async = true;
|
|
73
|
+
a.crossOrigin = 'anonymous';
|
|
74
|
+
a.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${secrets.googleAdSenseId}';
|
|
75
|
+
document.head.appendChild(a);
|
|
76
|
+
})();`
|
|
77
|
+
: '';
|
|
78
|
+
return `window.dataLayer = window.dataLayer || [];
|
|
79
|
+
function gtag(){dataLayer.push(arguments);}
|
|
80
|
+
gtag('consent', 'default', {
|
|
81
|
+
'ad_storage': 'denied',
|
|
82
|
+
'ad_user_data': 'denied',
|
|
83
|
+
'ad_personalization': 'denied',
|
|
84
|
+
'analytics_storage': 'denied',
|
|
85
|
+
'wait_for_update': 500
|
|
86
|
+
});
|
|
87
|
+
gtag('js', new Date());
|
|
88
|
+
${configCalls}
|
|
89
|
+
${gtagLoader}
|
|
90
|
+
${adSenseLoader}`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CookieDialogClassNames, CookieDialogStyles } from '../../types';
|
|
2
|
+
export interface CookieConsentDialogProps {
|
|
3
|
+
/** Banner message text. */
|
|
4
|
+
message?: React.ReactNode;
|
|
5
|
+
/** Optional link element rendered right after `message` (e.g. a privacy-policy link). */
|
|
6
|
+
link?: React.ReactNode;
|
|
7
|
+
acceptText?: string;
|
|
8
|
+
declineText?: string;
|
|
9
|
+
/** Hides the decline ("necessary only") button, leaving only accept. */
|
|
10
|
+
hideDecline?: boolean;
|
|
11
|
+
id?: string;
|
|
12
|
+
classNames?: CookieDialogClassNames;
|
|
13
|
+
styles?: CookieDialogStyles;
|
|
14
|
+
/**
|
|
15
|
+
* Full custom render — receives the resolved consent state/actions and
|
|
16
|
+
* bypasses the default markup entirely. Use for a fully bespoke dialog.
|
|
17
|
+
*/
|
|
18
|
+
render?: (props: {
|
|
19
|
+
setConsent: (value: boolean) => void;
|
|
20
|
+
}) => React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Cookie-consent banner. Renders `null` once `consent` is already decided.
|
|
24
|
+
* Every visual aspect is overridable via `classNames`/`styles` (per-slot) or
|
|
25
|
+
* `render` (full custom markup) — none of it is hardcoded to Tailwind or any
|
|
26
|
+
* particular design system.
|
|
27
|
+
*/
|
|
28
|
+
export default function CookieConsentDialog({ message, link, acceptText, declineText, hideDecline, id, classNames, styles, render, }: CookieConsentDialogProps): React.ReactElement | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import useCookieConsent from '../use_cookie_consent';
|
|
4
|
+
/**
|
|
5
|
+
* Cookie-consent banner. Renders `null` once `consent` is already decided.
|
|
6
|
+
* Every visual aspect is overridable via `classNames`/`styles` (per-slot) or
|
|
7
|
+
* `render` (full custom markup) — none of it is hardcoded to Tailwind or any
|
|
8
|
+
* particular design system.
|
|
9
|
+
*/
|
|
10
|
+
export default function CookieConsentDialog({ message = 'We use cookies to improve your experience.', link, acceptText = 'Accept', declineText = 'Necessary only', hideDecline = false, id = 'cookie-consent-dialog', classNames, styles, render, }) {
|
|
11
|
+
const { consent, setConsent } = useCookieConsent();
|
|
12
|
+
if (consent !== null)
|
|
13
|
+
return null;
|
|
14
|
+
if (render)
|
|
15
|
+
return _jsx(_Fragment, { children: render({ setConsent }) });
|
|
16
|
+
return (_jsxs("div", { id: id, role: "dialog", "aria-modal": "false", "aria-labelledby": `${id}-title`, className: classNames?.root, style: styles?.root, children: [_jsxs("p", { id: `${id}-title`, className: classNames?.message, style: styles?.message, children: [message, link ? _jsxs("span", { className: classNames?.link, style: styles?.link, children: [" ", link] }) : null] }), _jsxs("div", { className: classNames?.actions, style: styles?.actions, children: [!hideDecline && (_jsx("button", { type: "button", onClick: () => setConsent(false), className: classNames?.declineButton, style: styles?.declineButton, children: declineText })), _jsx("button", { type: "button", onClick: () => setConsent(true), className: classNames?.acceptButton, style: styles?.acceptButton, children: acceptText })] })] }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CookieDialogClassNames, CookieDialogStyles } from '../../types';
|
|
2
|
+
export interface PrivacyPolicyUpdateDialogProps {
|
|
3
|
+
message?: React.ReactNode;
|
|
4
|
+
/** Optional link element rendered right after `message` (e.g. to your privacy-policy page). */
|
|
5
|
+
link?: React.ReactNode;
|
|
6
|
+
closeText?: string;
|
|
7
|
+
id?: string;
|
|
8
|
+
classNames?: CookieDialogClassNames;
|
|
9
|
+
styles?: CookieDialogStyles;
|
|
10
|
+
/**
|
|
11
|
+
* Full custom render — receives the acknowledge action and bypasses the
|
|
12
|
+
* default markup entirely.
|
|
13
|
+
*/
|
|
14
|
+
render?: (props: {
|
|
15
|
+
acknowledge: () => void;
|
|
16
|
+
}) => React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* "Privacy policy updated" banner. Auto-enabled only when
|
|
20
|
+
* `cookieConsent.privacyPolicyDate` is set on the `RoutingConfig` — renders
|
|
21
|
+
* `null` otherwise, or once acknowledged. Every visual aspect is overridable
|
|
22
|
+
* via `classNames`/`styles` (per-slot) or `render` (full custom markup).
|
|
23
|
+
*/
|
|
24
|
+
export default function PrivacyPolicyUpdateDialog({ message, link, closeText, id, classNames, styles, render, }: PrivacyPolicyUpdateDialogProps): React.ReactElement | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import useCookieConsent from '../use_cookie_consent';
|
|
4
|
+
/**
|
|
5
|
+
* "Privacy policy updated" banner. Auto-enabled only when
|
|
6
|
+
* `cookieConsent.privacyPolicyDate` is set on the `RoutingConfig` — renders
|
|
7
|
+
* `null` otherwise, or once acknowledged. Every visual aspect is overridable
|
|
8
|
+
* via `classNames`/`styles` (per-slot) or `render` (full custom markup).
|
|
9
|
+
*/
|
|
10
|
+
export default function PrivacyPolicyUpdateDialog({ message = 'Our privacy policy has been updated.', link, closeText = 'Got it', id = 'privacy-policy-update-dialog', classNames, styles, render, }) {
|
|
11
|
+
const { privacyPolicyUpdated, acknowledgePrivacyPolicyUpdate } = useCookieConsent();
|
|
12
|
+
if (!privacyPolicyUpdated)
|
|
13
|
+
return null;
|
|
14
|
+
if (render)
|
|
15
|
+
return _jsx(_Fragment, { children: render({ acknowledge: acknowledgePrivacyPolicyUpdate }) });
|
|
16
|
+
return (_jsxs("div", { id: id, role: "dialog", "aria-modal": "false", "aria-labelledby": `${id}-title`, className: classNames?.root, style: styles?.root, children: [_jsxs("p", { id: `${id}-title`, className: classNames?.message, style: styles?.message, children: [message, link ? _jsxs("span", { className: classNames?.link, style: styles?.link, children: [" ", link] }) : null] }), _jsx("button", { type: "button", onClick: acknowledgePrivacyPolicyUpdate, "aria-label": closeText, className: classNames?.closeButton, style: styles?.closeButton, children: closeText })] }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { CookieConsentContextType } from '../types';
|
|
2
|
+
export declare const CookieConsentContext: import("react").Context<CookieConsentContextType | null>;
|
|
3
|
+
/**
|
|
4
|
+
* Provides cookie-consent + privacy-policy-update state to
|
|
5
|
+
* `useCookieConsent()` and the default `CookieConsentDialog`/
|
|
6
|
+
* `PrivacyPolicyUpdateDialog` components. Requires `cookieConsent` to be set
|
|
7
|
+
* on the `RoutingConfig` passed to `setIntlConfig` — throws a descriptive
|
|
8
|
+
* error otherwise.
|
|
9
|
+
*
|
|
10
|
+
* The privacy-policy-update banner turns on automatically, and only when
|
|
11
|
+
* `cookieConsent.privacyPolicyDate` is configured: once a visitor has
|
|
12
|
+
* consented, if their stored consent date predates `privacyPolicyDate`,
|
|
13
|
+
* `privacyPolicyUpdated` becomes `true` until they call
|
|
14
|
+
* `acknowledgePrivacyPolicyUpdate()`.
|
|
15
|
+
*
|
|
16
|
+
* @param requiresConsent Resolved server-side from
|
|
17
|
+
* `cookieConsent.getCountryCode`/`gdprCountries` — `false` means the
|
|
18
|
+
* visitor's country doesn't require the banner at all, so a first-time
|
|
19
|
+
* visitor (no stored cookie) gets `consent` seeded to `true` instead of
|
|
20
|
+
* `null`, skipping the dialog and unlocking analytics immediately.
|
|
21
|
+
* Defaults to `true` (always show the banner) when omitted, e.g. when
|
|
22
|
+
* `cookieConsent.getCountryCode` isn't configured.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <CookieConsentProvider>
|
|
27
|
+
* {children}
|
|
28
|
+
* <CookieConsentDialog />
|
|
29
|
+
* <PrivacyPolicyUpdateDialog />
|
|
30
|
+
* </CookieConsentProvider>
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export default function CookieConsentProvider({ requiresConsent, children }: {
|
|
34
|
+
requiresConsent?: boolean;
|
|
35
|
+
children: React.ReactNode;
|
|
36
|
+
}): React.ReactElement;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import config from '../../config/intl_config';
|
|
5
|
+
import requireCookieConsentConfig from '../require_config';
|
|
6
|
+
import getCookie from '../../client/functions/get_cookie';
|
|
7
|
+
import setCookie from '../../client/functions/set_cookie';
|
|
8
|
+
import { cookieConsentCookieKey, privacyPolicyDateCookieKey } from '../../config/cookie_key';
|
|
9
|
+
export const CookieConsentContext = createContext(null);
|
|
10
|
+
function parseConsent(raw) {
|
|
11
|
+
if (raw === 'true')
|
|
12
|
+
return true;
|
|
13
|
+
if (raw === 'false')
|
|
14
|
+
return false;
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Provides cookie-consent + privacy-policy-update state to
|
|
19
|
+
* `useCookieConsent()` and the default `CookieConsentDialog`/
|
|
20
|
+
* `PrivacyPolicyUpdateDialog` components. Requires `cookieConsent` to be set
|
|
21
|
+
* on the `RoutingConfig` passed to `setIntlConfig` — throws a descriptive
|
|
22
|
+
* error otherwise.
|
|
23
|
+
*
|
|
24
|
+
* The privacy-policy-update banner turns on automatically, and only when
|
|
25
|
+
* `cookieConsent.privacyPolicyDate` is configured: once a visitor has
|
|
26
|
+
* consented, if their stored consent date predates `privacyPolicyDate`,
|
|
27
|
+
* `privacyPolicyUpdated` becomes `true` until they call
|
|
28
|
+
* `acknowledgePrivacyPolicyUpdate()`.
|
|
29
|
+
*
|
|
30
|
+
* @param requiresConsent Resolved server-side from
|
|
31
|
+
* `cookieConsent.getCountryCode`/`gdprCountries` — `false` means the
|
|
32
|
+
* visitor's country doesn't require the banner at all, so a first-time
|
|
33
|
+
* visitor (no stored cookie) gets `consent` seeded to `true` instead of
|
|
34
|
+
* `null`, skipping the dialog and unlocking analytics immediately.
|
|
35
|
+
* Defaults to `true` (always show the banner) when omitted, e.g. when
|
|
36
|
+
* `cookieConsent.getCountryCode` isn't configured.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* <CookieConsentProvider>
|
|
41
|
+
* {children}
|
|
42
|
+
* <CookieConsentDialog />
|
|
43
|
+
* <PrivacyPolicyUpdateDialog />
|
|
44
|
+
* </CookieConsentProvider>
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export default function CookieConsentProvider({ requiresConsent = true, children }) {
|
|
48
|
+
const { consentCookieName, dateCookieName, maxAge, policyDate } = useMemo(() => {
|
|
49
|
+
const cc = requireCookieConsentConfig(config.cookieConsent);
|
|
50
|
+
return {
|
|
51
|
+
consentCookieName: cc.consentCookieName ?? cookieConsentCookieKey,
|
|
52
|
+
dateCookieName: cc.privacyPolicyDateCookieName ?? privacyPolicyDateCookieKey,
|
|
53
|
+
maxAge: cc.cookieMaxAge ?? 31536000,
|
|
54
|
+
policyDate: cc.privacyPolicyDate ? new Date(cc.privacyPolicyDate) : null,
|
|
55
|
+
};
|
|
56
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
57
|
+
}, []);
|
|
58
|
+
const [consent, setConsentState] = useState(null);
|
|
59
|
+
const [privacyPolicyUpdated, setPrivacyPolicyUpdated] = useState(false);
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const storedConsent = parseConsent(getCookie(consentCookieName));
|
|
62
|
+
if (storedConsent === null && !requiresConsent) {
|
|
63
|
+
setConsentState(true);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setConsentState(storedConsent);
|
|
67
|
+
if (storedConsent === null || !policyDate)
|
|
68
|
+
return;
|
|
69
|
+
const storedDateRaw = getCookie(dateCookieName);
|
|
70
|
+
if (!storedDateRaw) {
|
|
71
|
+
setCookie({ name: dateCookieName, value: policyDate.toISOString(), maxAge });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const storedDate = new Date(storedDateRaw);
|
|
75
|
+
setPrivacyPolicyUpdated(!Number.isNaN(storedDate.getTime()) && storedDate < policyDate);
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
}, []);
|
|
78
|
+
const setConsent = useCallback((value) => {
|
|
79
|
+
setCookie({ name: consentCookieName, value, maxAge });
|
|
80
|
+
if (policyDate)
|
|
81
|
+
setCookie({ name: dateCookieName, value: policyDate.toISOString(), maxAge });
|
|
82
|
+
setConsentState(value);
|
|
83
|
+
setPrivacyPolicyUpdated(false);
|
|
84
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
+
}, []);
|
|
86
|
+
const acknowledgePrivacyPolicyUpdate = useCallback(() => {
|
|
87
|
+
if (policyDate)
|
|
88
|
+
setCookie({ name: dateCookieName, value: policyDate.toISOString(), maxAge });
|
|
89
|
+
setPrivacyPolicyUpdated(false);
|
|
90
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
91
|
+
}, []);
|
|
92
|
+
const contextValue = useMemo(() => ({ consent, privacyPolicyUpdated, setConsent, acknowledgePrivacyPolicyUpdate }), [consent, privacyPolicyUpdated, setConsent, acknowledgePrivacyPolicyUpdate]);
|
|
93
|
+
return (_jsx(CookieConsentContext.Provider, { value: contextValue, children: children }));
|
|
94
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useContext } from 'react';
|
|
3
|
+
import { CookieConsentContext } from './cookie_consent_provider';
|
|
4
|
+
/**
|
|
5
|
+
* Reads cookie-consent + privacy-policy-update state. Must be called within
|
|
6
|
+
* a `CookieConsentProvider`.
|
|
7
|
+
*/
|
|
8
|
+
export default function useCookieConsent() {
|
|
9
|
+
const context = useContext(CookieConsentContext);
|
|
10
|
+
if (context === null) {
|
|
11
|
+
throw new Error('useCookieConsent must be used within a CookieConsentProvider');
|
|
12
|
+
}
|
|
13
|
+
return context;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { bench, describe } from 'vitest';
|
|
2
|
+
import resolveRequiresConsent, { defaultGdprCountries } from './gdpr_countries';
|
|
3
|
+
const customList = ['US', 'CA', 'MX'];
|
|
4
|
+
describe('resolveRequiresConsent: default GDPR list lookup', () => {
|
|
5
|
+
bench('country inside the list (worst case for Array.includes: near the end)', async () => {
|
|
6
|
+
await resolveRequiresConsent(() => 'CH', undefined, undefined);
|
|
7
|
+
});
|
|
8
|
+
bench('country outside the list', async () => {
|
|
9
|
+
await resolveRequiresConsent(() => 'US', undefined, undefined);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
describe('resolveRequiresConsent: custom GDPR list lookup (cached Set)', () => {
|
|
13
|
+
bench('repeated calls with the same list reference', async () => {
|
|
14
|
+
await resolveRequiresConsent(() => 'US', undefined, customList);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
describe('resolveRequiresConsent: gating disabled', () => {
|
|
18
|
+
bench('neither getter set (fast path, no lookup at all)', async () => {
|
|
19
|
+
await resolveRequiresConsent(undefined, undefined, undefined);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('resolveRequiresConsent: getCloudflareContext path', () => {
|
|
23
|
+
bench('resolves cf.country from an async context getter', async () => {
|
|
24
|
+
await resolveRequiresConsent(undefined, async () => ({ cf: { country: 'DE' } }), undefined);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
// Sanity check the list itself isn't accidentally growing unbounded — a
|
|
28
|
+
// regression here would also regress the Set-build cost on first use.
|
|
29
|
+
describe('defaultGdprCountries', () => {
|
|
30
|
+
bench('Set construction cost (paid once per process, not per request)', () => {
|
|
31
|
+
new Set(defaultGdprCountries);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CookieConsentCloudflareContext } from '../types/types';
|
|
2
|
+
/**
|
|
3
|
+
* Default `cookieConsent.gdprCountries` — EU/EEA member states (GDPR),
|
|
4
|
+
* Iceland/Liechtenstein/Norway (EEA), the UK (UK-GDPR), and Switzerland
|
|
5
|
+
* (nFADP). ISO 3166-1 alpha-2.
|
|
6
|
+
*/
|
|
7
|
+
export declare const defaultGdprCountries: readonly string[];
|
|
8
|
+
/**
|
|
9
|
+
* Resolves whether the cookie-consent banner is required for a visitor.
|
|
10
|
+
*
|
|
11
|
+
* - Neither getter set: country-based gating is off entirely — consent is
|
|
12
|
+
* never required (the simplest opt-in-by-default setup).
|
|
13
|
+
* - Either getter set: fail-safe — a country that couldn't be resolved
|
|
14
|
+
* still requires consent; only a resolved country OUTSIDE
|
|
15
|
+
* `gdprCountries` skips the banner. `getCountryCode` takes precedence
|
|
16
|
+
* over `getCloudflareContext` when both are set.
|
|
17
|
+
*/
|
|
18
|
+
export default function resolveRequiresConsent(getCountryCode: (() => string | undefined | Promise<string | undefined>) | undefined, getCloudflareContext: (() => CookieConsentCloudflareContext | Promise<CookieConsentCloudflareContext>) | undefined, gdprCountries: readonly string[] | undefined): Promise<boolean>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default `cookieConsent.gdprCountries` — EU/EEA member states (GDPR),
|
|
3
|
+
* Iceland/Liechtenstein/Norway (EEA), the UK (UK-GDPR), and Switzerland
|
|
4
|
+
* (nFADP). ISO 3166-1 alpha-2.
|
|
5
|
+
*/
|
|
6
|
+
export const defaultGdprCountries = [
|
|
7
|
+
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR',
|
|
8
|
+
'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL',
|
|
9
|
+
'PT', 'RO', 'SE', 'SI', 'SK',
|
|
10
|
+
'IS', 'LI', 'NO',
|
|
11
|
+
'GB',
|
|
12
|
+
'CH',
|
|
13
|
+
];
|
|
14
|
+
// `Set.has()` is O(1) vs `Array.includes()`'s O(n) — this runs on every
|
|
15
|
+
// request that has country-based gating enabled, so the lookup cost matters.
|
|
16
|
+
// Mirrors this package's `localesSet` convention in `config/middleware.ts`.
|
|
17
|
+
const defaultGdprCountriesSet = new Set(defaultGdprCountries);
|
|
18
|
+
// Custom `gdprCountries` lists are typically static config passed at
|
|
19
|
+
// `setIntlConfig` call time (module-scope, stable reference) — caching one
|
|
20
|
+
// Set per distinct array reference avoids rebuilding it on every request
|
|
21
|
+
// while still supporting a caller that legitimately swaps the array.
|
|
22
|
+
const customGdprCountriesSetCache = new WeakMap();
|
|
23
|
+
function getGdprCountriesSet(gdprCountries) {
|
|
24
|
+
if (!gdprCountries)
|
|
25
|
+
return defaultGdprCountriesSet;
|
|
26
|
+
let set = customGdprCountriesSetCache.get(gdprCountries);
|
|
27
|
+
if (!set) {
|
|
28
|
+
set = new Set(gdprCountries);
|
|
29
|
+
customGdprCountriesSetCache.set(gdprCountries, set);
|
|
30
|
+
}
|
|
31
|
+
return set;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolves whether the cookie-consent banner is required for a visitor.
|
|
35
|
+
*
|
|
36
|
+
* - Neither getter set: country-based gating is off entirely — consent is
|
|
37
|
+
* never required (the simplest opt-in-by-default setup).
|
|
38
|
+
* - Either getter set: fail-safe — a country that couldn't be resolved
|
|
39
|
+
* still requires consent; only a resolved country OUTSIDE
|
|
40
|
+
* `gdprCountries` skips the banner. `getCountryCode` takes precedence
|
|
41
|
+
* over `getCloudflareContext` when both are set.
|
|
42
|
+
*/
|
|
43
|
+
export default async function resolveRequiresConsent(getCountryCode, getCloudflareContext, gdprCountries) {
|
|
44
|
+
if (!getCountryCode && !getCloudflareContext)
|
|
45
|
+
return false;
|
|
46
|
+
const countryCode = getCountryCode
|
|
47
|
+
? await getCountryCode()
|
|
48
|
+
: (await getCloudflareContext()).cf?.country;
|
|
49
|
+
if (!countryCode)
|
|
50
|
+
return true;
|
|
51
|
+
return getGdprCountriesSet(gdprCountries).has(countryCode);
|
|
52
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as CookieConsentProvider } from './client/cookie_consent_provider';
|
|
2
|
+
export { default as useCookieConsent } from './client/use_cookie_consent';
|
|
3
|
+
export { default as CookieConsentDialog } from './client/components/cookie_consent_dialog';
|
|
4
|
+
export { default as PrivacyPolicyUpdateDialog } from './client/components/privacy_policy_update_dialog';
|
|
5
|
+
export { default as CookieConsentAnalytics } from './client/components/cookie_consent_analytics';
|
|
6
|
+
export { defaultGdprCountries } from './gdpr_countries';
|
|
7
|
+
export type { CookieConsentContextType, ConsentValue, CookieDialogClassNames, CookieDialogStyles } from './types';
|
|
8
|
+
export type { CookieConsentRoutingConfig, CookieConsentAnalyticsSecrets } from '../types/types';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { default as CookieConsentProvider } from './client/cookie_consent_provider';
|
|
2
|
+
export { default as useCookieConsent } from './client/use_cookie_consent';
|
|
3
|
+
export { default as CookieConsentDialog } from './client/components/cookie_consent_dialog';
|
|
4
|
+
export { default as PrivacyPolicyUpdateDialog } from './client/components/privacy_policy_update_dialog';
|
|
5
|
+
export { default as CookieConsentAnalytics } from './client/components/cookie_consent_analytics';
|
|
6
|
+
export { defaultGdprCountries } from './gdpr_countries';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CookieConsentRoutingConfig } from '../types/types';
|
|
2
|
+
/**
|
|
3
|
+
* Throws a descriptive error instead of silently no-op'ing when the
|
|
4
|
+
* `cookie_consent` submodule is used without `cookieConsent` set on the
|
|
5
|
+
* `RoutingConfig` passed to `setIntlConfig`.
|
|
6
|
+
*/
|
|
7
|
+
export default function requireCookieConsentConfig(value: CookieConsentRoutingConfig | undefined): CookieConsentRoutingConfig;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throws a descriptive error instead of silently no-op'ing when the
|
|
3
|
+
* `cookie_consent` submodule is used without `cookieConsent` set on the
|
|
4
|
+
* `RoutingConfig` passed to `setIntlConfig`.
|
|
5
|
+
*/
|
|
6
|
+
export default function requireCookieConsentConfig(value) {
|
|
7
|
+
if (!value) {
|
|
8
|
+
throw new Error('cloudflare-next-intl: `cookieConsent` is not set on your `RoutingConfig`. ' +
|
|
9
|
+
'Add a `cookieConsent` block (see `CookieConsentRoutingConfig`) to the config ' +
|
|
10
|
+
'object passed to `setIntlConfig` before using `CookieConsentProvider`/`useCookieConsent`.');
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Consent value: `true` accepted, `false` necessary-only, `null` not yet decided. */
|
|
2
|
+
export type ConsentValue = boolean | null;
|
|
3
|
+
/** Context value returned by `useCookieConsent()`. */
|
|
4
|
+
export interface CookieConsentContextType {
|
|
5
|
+
/** Current consent value; `null` until the visitor decides. */
|
|
6
|
+
consent: ConsentValue;
|
|
7
|
+
/**
|
|
8
|
+
* `true` once a privacy-policy update has been detected (stored consent
|
|
9
|
+
* predates `cookieConsent.privacyPolicyDate`) and hasn't been
|
|
10
|
+
* acknowledged yet. Always `false` when `privacyPolicyDate` is unset.
|
|
11
|
+
*/
|
|
12
|
+
privacyPolicyUpdated: boolean;
|
|
13
|
+
/** Accepts (or rejects, with `false`) cookie consent and persists it. */
|
|
14
|
+
setConsent: (value: boolean) => void;
|
|
15
|
+
/** Acknowledges the privacy-policy update banner and persists the new date. */
|
|
16
|
+
acknowledgePrivacyPolicyUpdate: () => void;
|
|
17
|
+
}
|
|
18
|
+
/** Slot-level style/class overrides accepted by the default dialog components. */
|
|
19
|
+
export interface CookieDialogClassNames {
|
|
20
|
+
root?: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
link?: string;
|
|
23
|
+
actions?: string;
|
|
24
|
+
acceptButton?: string;
|
|
25
|
+
declineButton?: string;
|
|
26
|
+
closeButton?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Slot-level inline-style overrides accepted by the default dialog components. */
|
|
29
|
+
export interface CookieDialogStyles {
|
|
30
|
+
root?: React.CSSProperties;
|
|
31
|
+
message?: React.CSSProperties;
|
|
32
|
+
link?: React.CSSProperties;
|
|
33
|
+
actions?: React.CSSProperties;
|
|
34
|
+
acceptButton?: React.CSSProperties;
|
|
35
|
+
declineButton?: React.CSSProperties;
|
|
36
|
+
closeButton?: React.CSSProperties;
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -7,7 +7,7 @@ import config from '@intl-config';
|
|
|
7
7
|
import requireFirebaseAuthConfig from '../require_config';
|
|
8
8
|
import { getFirebaseAuthClient, getFirebaseAuthModule } from './firebase_client';
|
|
9
9
|
import { setAuthUserCache } from './auth_user_cache';
|
|
10
|
-
import { defaultSessionCookieName } from '../middleware/update_session';
|
|
10
|
+
import { defaultRefreshTokenCookieName, defaultSessionCookieName } from '../middleware/update_session';
|
|
11
11
|
import setCookie from '../../client/functions/set_cookie';
|
|
12
12
|
// `null` default (instead of a `{ loading: true, ... }` stand-in) lets
|
|
13
13
|
// `useAuthUser` distinguish "not wrapped in AuthUserProvider" (throw) from
|
|
@@ -19,6 +19,12 @@ function writeSessionCookie(sessionCookieName, idToken, maxAge) {
|
|
|
19
19
|
function clearSessionCookie(sessionCookieName) {
|
|
20
20
|
setCookie({ name: sessionCookieName, value: '', maxAge: 0 });
|
|
21
21
|
}
|
|
22
|
+
function writeRefreshTokenCookie(refreshTokenCookieName, user, maxAge) {
|
|
23
|
+
setCookie({ name: refreshTokenCookieName, value: user.refreshToken, maxAge });
|
|
24
|
+
}
|
|
25
|
+
function clearRefreshTokenCookie(refreshTokenCookieName) {
|
|
26
|
+
setCookie({ name: refreshTokenCookieName, value: '', maxAge: 0 });
|
|
27
|
+
}
|
|
22
28
|
/**
|
|
23
29
|
* Client-side auth-state provider for `firebase_auth`. Wrap your root layout
|
|
24
30
|
* (or a client boundary below it) with this to make `useAuthUser()`
|
|
@@ -46,6 +52,8 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
46
52
|
const isWhiteListed = fa.whiteListPaths?.includes(pathname) ?? false;
|
|
47
53
|
const maxAge = fa.sessionCookieMaxAge ?? 60 * 60 * 24 * 5;
|
|
48
54
|
const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
|
|
55
|
+
const refreshTokenMaxAge = fa.refreshTokenCookieMaxAge ?? 60 * 60 * 24 * 365;
|
|
56
|
+
const refreshTokenCookieName = fa.refreshTokenCookieName ?? defaultRefreshTokenCookieName;
|
|
49
57
|
const [state, setState] = useState({
|
|
50
58
|
user: initialUser,
|
|
51
59
|
loading: initialUser === null,
|
|
@@ -86,10 +94,18 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
86
94
|
const previous = syncedSignedIn.current;
|
|
87
95
|
try {
|
|
88
96
|
if (user) {
|
|
89
|
-
|
|
97
|
+
try {
|
|
98
|
+
writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
console.error('AuthUserProvider: refresh-token cookie sync failed', e);
|
|
102
|
+
}
|
|
103
|
+
const token = await user.getIdToken(true);
|
|
104
|
+
writeSessionCookie(sessionCookieName, token, maxAge);
|
|
90
105
|
}
|
|
91
106
|
else if (previous) {
|
|
92
107
|
clearSessionCookie(sessionCookieName);
|
|
108
|
+
clearRefreshTokenCookie(refreshTokenCookieName);
|
|
93
109
|
}
|
|
94
110
|
}
|
|
95
111
|
catch (e) {
|
|
@@ -122,7 +138,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
122
138
|
unsubscribe?.();
|
|
123
139
|
};
|
|
124
140
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
125
|
-
}, [router, isAuthPage, maxAge, sessionCookieName]);
|
|
141
|
+
}, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName]);
|
|
126
142
|
const reloadUser = useCallback(async () => {
|
|
127
143
|
const { auth } = await getFirebaseAuthClient();
|
|
128
144
|
const user = auth.currentUser;
|
|
@@ -131,6 +147,12 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
131
147
|
try {
|
|
132
148
|
const { reload } = await getFirebaseAuthModule();
|
|
133
149
|
await reload(user);
|
|
150
|
+
try {
|
|
151
|
+
writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
console.error('AuthUserProvider: refresh-token cookie sync failed', e);
|
|
155
|
+
}
|
|
134
156
|
writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
|
|
135
157
|
setAuthUserCache(user);
|
|
136
158
|
setState({ user, loading: false });
|
|
@@ -155,9 +177,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
155
177
|
}
|
|
156
178
|
finally {
|
|
157
179
|
clearSessionCookie(sessionCookieName);
|
|
180
|
+
clearRefreshTokenCookie(refreshTokenCookieName);
|
|
158
181
|
window.location.assign(fa.redirectAuthPath);
|
|
159
182
|
}
|
|
160
183
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
161
|
-
}, [fa.redirectAuthPath, sessionCookieName]);
|
|
184
|
+
}, [fa.redirectAuthPath, sessionCookieName, refreshTokenCookieName]);
|
|
162
185
|
return _jsx(AuthUserContext.Provider, { value: { ...state, reloadUser, sendVerificationEmail, logout }, children: children });
|
|
163
186
|
}
|
|
@@ -4,6 +4,7 @@ import { getMessage } from "../functions/server";
|
|
|
4
4
|
import dynamic from "next/dynamic";
|
|
5
5
|
import { localesSet } from "../../config/middleware";
|
|
6
6
|
import config from "../../config/intl_config";
|
|
7
|
+
import resolveRequiresConsent from "../../cookie_consent/gdpr_countries";
|
|
7
8
|
const LocationzationClientProvider = dynamic(() => import("../../client/components/client_provider"));
|
|
8
9
|
let authUserServerProviderModule;
|
|
9
10
|
/**
|
|
@@ -55,5 +56,17 @@ export default async function LocationzationProvider({ language, messages, child
|
|
|
55
56
|
}
|
|
56
57
|
initialAuthUser = await authUserServerProviderModule.resolveAuthUserAndRedirect();
|
|
57
58
|
}
|
|
58
|
-
|
|
59
|
+
let analyticsSecrets;
|
|
60
|
+
let requiresConsent = true;
|
|
61
|
+
if (config.cookieConsent) {
|
|
62
|
+
requiresConsent = await resolveRequiresConsent(config.cookieConsent.getCountryCode, config.cookieConsent.getCloudflareContext, config.cookieConsent.gdprCountries);
|
|
63
|
+
const isDevEnvironment = process.env.NODE_ENV === 'development';
|
|
64
|
+
const analyticsAllowedInEnv = config.cookieConsent.enableAnalyticsInDevMode === true || !isDevEnvironment;
|
|
65
|
+
if (config.cookieConsent.autoWireAnalytics !== false && analyticsAllowedInEnv) {
|
|
66
|
+
analyticsSecrets = config.cookieConsent.getSecrets
|
|
67
|
+
? await config.cookieConsent.getSecrets()
|
|
68
|
+
: config.cookieConsent.secrets;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return _jsx(LocationzationClientProvider, { language: language, messages: messagesValue, initialAuthUser: initialAuthUser, skipAuthProvider: !autoWireClientProvider, analyticsSecrets: analyticsSecrets, requiresConsent: requiresConsent, children: children });
|
|
59
72
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export type { CookieAttributes, LocalePrefixMode, Locales, ReturnType, RoutingConfig, TranslationEntry, TranslationObject, TranslatorReturnType, Alternates, changeFrequency, IntlSitemap, } from './types';
|
|
1
|
+
export type { CookieAttributes, LocalePrefixMode, Locales, ReturnType, RoutingConfig, TranslationEntry, TranslationObject, TranslatorReturnType, Alternates, changeFrequency, IntlSitemap, CookieConsentRoutingConfig, CookieConsentAnalyticsSecrets, CookieConsentCloudflareContext, } from './types';
|
|
@@ -81,6 +81,119 @@ export interface RoutingConfig<AppLocales extends Locales, AppLocalePrefixMode e
|
|
|
81
81
|
* if this field is missing at call time rather than silently no-op'ing.
|
|
82
82
|
*/
|
|
83
83
|
firebaseAuth?: FirebaseAuthRoutingConfig;
|
|
84
|
+
/**
|
|
85
|
+
* Configures the optional `cookie_consent` submodule (cookie-consent +
|
|
86
|
+
* privacy-policy-update banners). Omit entirely to keep it disabled —
|
|
87
|
+
* `useCookieConsent()`/`CookieConsentProvider` will throw a descriptive
|
|
88
|
+
* error if called without this set.
|
|
89
|
+
*/
|
|
90
|
+
cookieConsent?: CookieConsentRoutingConfig;
|
|
91
|
+
}
|
|
92
|
+
export interface CookieConsentRoutingConfig {
|
|
93
|
+
/**
|
|
94
|
+
* Date the current privacy policy was last modified, e.g. `"2026-07-20"`
|
|
95
|
+
* or a `Date`. When set, the "privacy policy updated" banner
|
|
96
|
+
* automatically shows to any visitor whose stored consent predates this
|
|
97
|
+
* date. Omit to disable the privacy-policy-update banner entirely (the
|
|
98
|
+
* cookie-consent banner still works independently).
|
|
99
|
+
*/
|
|
100
|
+
privacyPolicyDate?: string | Date;
|
|
101
|
+
/** Cookie-consent cookie name. Defaults to `'__cookie_consent_key__'`. */
|
|
102
|
+
consentCookieName?: string;
|
|
103
|
+
/** Privacy-policy-date cookie name. Defaults to `'__privacy_policy_date_key__'`. */
|
|
104
|
+
privacyPolicyDateCookieName?: string;
|
|
105
|
+
/** Cookie max-age in seconds for both cookies above. Defaults to 1 year (31536000). */
|
|
106
|
+
cookieMaxAge?: number;
|
|
107
|
+
/**
|
|
108
|
+
* Whether `IntlProvider` should automatically render the analytics/ads
|
|
109
|
+
* scripts (Cloudflare Web Analytics beacon, Google Consent Mode + gtag,
|
|
110
|
+
* Microsoft Clarity — whichever secrets resolve below) once consent is
|
|
111
|
+
* granted, and gate them behind the cookie-consent banner otherwise.
|
|
112
|
+
* Defaults to `true` when `secrets`/`getSecrets` is set; set `false` to
|
|
113
|
+
* keep `cookieConsent` configured for the dialogs/hook only and wire
|
|
114
|
+
* analytics yourself.
|
|
115
|
+
*/
|
|
116
|
+
autoWireAnalytics?: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Static secrets/IDs for the analytics providers below. Use this OR
|
|
119
|
+
* `getSecrets`, not both — `getSecrets` takes precedence when both are
|
|
120
|
+
* set (e.g. secrets only available at request time from a Cloudflare
|
|
121
|
+
* `env` binding).
|
|
122
|
+
*/
|
|
123
|
+
secrets?: CookieConsentAnalyticsSecrets;
|
|
124
|
+
/**
|
|
125
|
+
* Resolves the same secrets at request time — e.g. from Cloudflare's
|
|
126
|
+
* `getCloudflareContext().env` (via `@opennextjs/cloudflare`, not a
|
|
127
|
+
* dependency of this package — pass your own getter). Any field left
|
|
128
|
+
* `undefined` in the returned object disables that provider's script.
|
|
129
|
+
*/
|
|
130
|
+
getSecrets?: () => CookieConsentAnalyticsSecrets | Promise<CookieConsentAnalyticsSecrets>;
|
|
131
|
+
/**
|
|
132
|
+
* Resolves the visitor's country code directly (ISO 3166-1 alpha-2,
|
|
133
|
+
* e.g. `"DE"`) — the simplest option when you already have it from
|
|
134
|
+
* somewhere (a header, a KV lookup, your own logic). Takes precedence
|
|
135
|
+
* over `getCloudflareContext` when both are set.
|
|
136
|
+
*/
|
|
137
|
+
getCountryCode?: () => string | undefined | Promise<string | undefined>;
|
|
138
|
+
/**
|
|
139
|
+
* Returns the Cloudflare request context — e.g. your own
|
|
140
|
+
* `getCloudflareContext()` call from `@opennextjs/cloudflare` (not a
|
|
141
|
+
* dependency of this package, so bring your own). Only `cf.country`
|
|
142
|
+
* is read from the resolved context. Ignored when `getCountryCode` is
|
|
143
|
+
* also set.
|
|
144
|
+
*
|
|
145
|
+
* Country-based gating (via either `getCountryCode` or
|
|
146
|
+
* `getCloudflareContext`) decides whether the cookie-consent banner is
|
|
147
|
+
* required at all: visitors outside `gdprCountries` skip the banner and
|
|
148
|
+
* get analytics immediately (still gated by `enableAnalyticsInDevMode`).
|
|
149
|
+
* Omit BOTH to skip country-based gating entirely — the banner is never
|
|
150
|
+
* shown and consent is treated as implicitly granted for everyone. This
|
|
151
|
+
* is the simplest opt-in-by-default setup; set one of the two getters
|
|
152
|
+
* once you need real GDPR-region gating.
|
|
153
|
+
*/
|
|
154
|
+
getCloudflareContext?: () => CookieConsentCloudflareContext | Promise<CookieConsentCloudflareContext>;
|
|
155
|
+
/**
|
|
156
|
+
* Country codes (ISO 3166-1 alpha-2) for which the cookie-consent banner
|
|
157
|
+
* is required. Only consulted when `getCountryCode` or
|
|
158
|
+
* `getCloudflareContext` is set. Defaults to the EU/EEA + UK +
|
|
159
|
+
* Switzerland (GDPR/UK-GDPR/nFADP scope). A visitor whose resolved
|
|
160
|
+
* country isn't in this set is treated as NOT requiring consent; a
|
|
161
|
+
* country that couldn't be resolved still requires it (fail-safe:
|
|
162
|
+
* unknown defaults to "ask").
|
|
163
|
+
*/
|
|
164
|
+
gdprCountries?: readonly string[];
|
|
165
|
+
/**
|
|
166
|
+
* Whether the auto-wired analytics scripts (see `autoWireAnalytics`)
|
|
167
|
+
* are allowed to load in your local/dev environment. Defaults to
|
|
168
|
+
* `false` — analytics stay off during local development regardless of
|
|
169
|
+
* consent, matching most analytics providers' own recommendation not to
|
|
170
|
+
* pollute production data with dev traffic. Set `true` to test the
|
|
171
|
+
* scripts locally.
|
|
172
|
+
*/
|
|
173
|
+
enableAnalyticsInDevMode?: boolean;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Minimal shape read from your `getCloudflareContext()` return value — only
|
|
177
|
+
* `cf.country` is consulted, so any superset (the real `CloudflareContext`
|
|
178
|
+
* from `@opennextjs/cloudflare`) is accepted as-is without a hard
|
|
179
|
+
* dependency on that package.
|
|
180
|
+
*/
|
|
181
|
+
export interface CookieConsentCloudflareContext {
|
|
182
|
+
cf?: {
|
|
183
|
+
country?: string;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export interface CookieConsentAnalyticsSecrets {
|
|
187
|
+
/** Cloudflare Web Analytics beacon token, e.g. `'{"token": "..."}'` (the raw `data-cf-beacon` attribute value). */
|
|
188
|
+
cloudflareBeaconToken?: string;
|
|
189
|
+
/** Google Analytics measurement ID, e.g. `"G-XXXXXXX"`. */
|
|
190
|
+
googleAnalyticsId?: string;
|
|
191
|
+
/** Google Ads conversion ID, e.g. `"AW-XXXXXXXXX"`. */
|
|
192
|
+
googleAdsId?: string;
|
|
193
|
+
/** Google AdSense publisher ID, e.g. `"ca-pub-XXXXXXXXXXXXXXXX"`. */
|
|
194
|
+
googleAdSenseId?: string;
|
|
195
|
+
/** Microsoft Clarity project ID. */
|
|
196
|
+
clarityProjectId?: string;
|
|
84
197
|
}
|
|
85
198
|
export interface FirebaseAuthRoutingConfig {
|
|
86
199
|
/**
|
package/llms.txt
CHANGED
|
@@ -34,6 +34,16 @@ other subpath can be used.
|
|
|
34
34
|
- `./firebaseAuthActions` — `createLoginAction`/`createSignUpAction`/`createForgotPasswordAction`: factories returning React `useActionState`-shaped form actions.
|
|
35
35
|
- `./firebaseAuthMiddleware` — `updateSession`: session-cookie refresh, called automatically by `./middleware`'s default handler.
|
|
36
36
|
|
|
37
|
+
## `cookieConsent*` subpaths (require `cookieConsent` set on your `RoutingConfig`)
|
|
38
|
+
|
|
39
|
+
- `./cookieConsent` — barrel: `CookieConsentProvider`, `useCookieConsent`, `CookieConsentDialog`, `PrivacyPolicyUpdateDialog`, `CookieConsentAnalytics`.
|
|
40
|
+
- `./CookieConsentProvider` — context provider; reads/writes consent + privacy-policy-date cookies. Auto-wired by `IntlProvider` when `cookieConsent` is configured — manual nesting is optional.
|
|
41
|
+
- `./useCookieConsent` — context hook; throws `"useCookieConsent must be used within a CookieConsentProvider"` if called outside one.
|
|
42
|
+
- `./CookieConsentDialog` — default cookie-consent banner; accepts per-slot `classNames`/`styles` or a `render` prop for fully custom markup.
|
|
43
|
+
- `./PrivacyPolicyUpdateDialog` — "privacy policy updated" banner; auto-enabled only when `cookieConsent.privacyPolicyDate` is set.
|
|
44
|
+
- `./cookieConsentAnalytics` — `CookieConsentAnalytics`: gates Cloudflare Web Analytics / Google Ads / Google Analytics / AdSense / Microsoft Clarity behind consent; rendered automatically by `IntlProvider` when `cookieConsent.secrets` or `getSecrets` is set (and `autoWireAnalytics !== false`). Never renders in local dev (`NODE_ENV === 'development'`) unless `cookieConsent.enableAnalyticsInDevMode` is `true`.
|
|
45
|
+
- Country-based gating (`cookieConsent.getCountryCode` / `getCloudflareContext` + `gdprCountries`): resolved server-side by `IntlProvider` into a `requiresConsent` boolean passed to `CookieConsentProvider`. Neither getter set → gating off, consent always implicitly granted. `getCountryCode` (direct country resolver) takes precedence over `getCloudflareContext` (reads `cf.country`) when both are set. Unresolved country always requires consent (fail-safe). `./cookieConsent` also exports `defaultGdprCountries` (EU/EEA + UK + Switzerland).
|
|
46
|
+
|
|
37
47
|
## Conventions
|
|
38
48
|
|
|
39
49
|
- Every exported function/component has a JSDoc comment with an `@example` where usage isn't obvious from the signature alone.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloudflare-next-intl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Optimized Next Intl Package Special for App Router and Cloudflare",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -115,6 +115,30 @@
|
|
|
115
115
|
"./firebaseAuthMiddleware": {
|
|
116
116
|
"types": "./dist/src/firebase_auth/middleware/update_session.d.ts",
|
|
117
117
|
"import": "./dist/src/firebase_auth/middleware/update_session.js"
|
|
118
|
+
},
|
|
119
|
+
"./cookieConsent": {
|
|
120
|
+
"types": "./dist/src/cookie_consent/index.d.ts",
|
|
121
|
+
"import": "./dist/src/cookie_consent/index.js"
|
|
122
|
+
},
|
|
123
|
+
"./CookieConsentProvider": {
|
|
124
|
+
"types": "./dist/src/cookie_consent/client/cookie_consent_provider.d.ts",
|
|
125
|
+
"import": "./dist/src/cookie_consent/client/cookie_consent_provider.js"
|
|
126
|
+
},
|
|
127
|
+
"./useCookieConsent": {
|
|
128
|
+
"types": "./dist/src/cookie_consent/client/use_cookie_consent.d.ts",
|
|
129
|
+
"import": "./dist/src/cookie_consent/client/use_cookie_consent.js"
|
|
130
|
+
},
|
|
131
|
+
"./CookieConsentDialog": {
|
|
132
|
+
"types": "./dist/src/cookie_consent/client/components/cookie_consent_dialog.d.ts",
|
|
133
|
+
"import": "./dist/src/cookie_consent/client/components/cookie_consent_dialog.js"
|
|
134
|
+
},
|
|
135
|
+
"./PrivacyPolicyUpdateDialog": {
|
|
136
|
+
"types": "./dist/src/cookie_consent/client/components/privacy_policy_update_dialog.d.ts",
|
|
137
|
+
"import": "./dist/src/cookie_consent/client/components/privacy_policy_update_dialog.js"
|
|
138
|
+
},
|
|
139
|
+
"./cookieConsentAnalytics": {
|
|
140
|
+
"types": "./dist/src/cookie_consent/client/components/cookie_consent_analytics.d.ts",
|
|
141
|
+
"import": "./dist/src/cookie_consent/client/components/cookie_consent_analytics.js"
|
|
118
142
|
}
|
|
119
143
|
},
|
|
120
144
|
"scripts": {
|
|
@@ -158,6 +182,7 @@
|
|
|
158
182
|
},
|
|
159
183
|
"homepage": "https://github.com/demian-ilnytskyi/cloudflare-next-intl#readme",
|
|
160
184
|
"peerDependencies": {
|
|
185
|
+
"@microsoft/clarity": ">=1.0.0",
|
|
161
186
|
"firebase": ">=10.0.0",
|
|
162
187
|
"next": ">=12.0.0",
|
|
163
188
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
|
|
@@ -169,11 +194,15 @@
|
|
|
169
194
|
},
|
|
170
195
|
"firebase": {
|
|
171
196
|
"optional": true
|
|
197
|
+
},
|
|
198
|
+
"@microsoft/clarity": {
|
|
199
|
+
"optional": true
|
|
172
200
|
}
|
|
173
201
|
},
|
|
174
202
|
"devDependencies": {
|
|
175
203
|
"@eslint/eslintrc": "^3",
|
|
176
204
|
"@eslint/js": "^9.27.0",
|
|
205
|
+
"@microsoft/clarity": "^1.0.2",
|
|
177
206
|
"@testing-library/dom": "^10.4.1",
|
|
178
207
|
"@testing-library/jest-dom": "^7.0.0",
|
|
179
208
|
"@testing-library/react": "^16.3.2",
|