### Overview Implement PDPA & GDPR compliant Cookie Consent Management Platform (CMP) for pps/website: - **Zustand Store (cookieConsentStore.ts):** Stores consent record ( ecessary, nalytics, marketing) in localStorage with skipHydration: true for Next.js SSR safety. - **Cookie Banner (CookieBanner.tsx):** Fixed bottom floating banner with Quiet Luxury styling, providing Accept All, Reject Non-Essential, and Customize buttons. - **Preferences Modal (CookiePreferencesModal.tsx):** Detailed modal dialog allowing users to granularly toggle Analytics and Marketing cookies (Strictly Necessary is locked). - **Script Manager (ScriptManager.tsx):** Coordinates Google Consent Mode v2 (nalytics_storage, d_storage), conditionally injects GA4, Meta Pixel, and TikTok Pixel, and dispatches homaroma:consent-change DOM events. - **Footer Revocation Link:** Added Cookie Preferences link in the Support column of Footer.tsx so users can modify/revoke consent anytime. - **Multilingual Support:** Added localized text for TH, EN, JA, and ZH. --------- Co-authored-by: Your Name <you@example.com> Reviewed-on: #14 Co-authored-by: antigravity-bot <bot@kittisak.com>
171 lines
5.4 KiB
TypeScript
171 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
import { useCookieConsentStore } from '../../store/cookieConsentStore';
|
|
|
|
declare global {
|
|
interface Window {
|
|
dataLayer?: any[];
|
|
gtag?: (...args: any[]) => void;
|
|
fbq?: any;
|
|
_fbq?: any;
|
|
ttq?: any;
|
|
}
|
|
}
|
|
|
|
export default function ScriptManager() {
|
|
const { record, isHydrated } = useCookieConsentStore();
|
|
const consentInitializedRef = useRef(false);
|
|
const gaInjectedRef = useRef(false);
|
|
const metaInjectedRef = useRef(false);
|
|
const tiktokInjectedRef = useRef(false);
|
|
|
|
// 1. Initialize Google Consent Mode v2 Defaults
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined' || consentInitializedRef.current) return;
|
|
consentInitializedRef.current = true;
|
|
|
|
window.dataLayer = window.dataLayer || [];
|
|
if (!window.gtag) {
|
|
window.gtag = function () {
|
|
window.dataLayer?.push(arguments);
|
|
};
|
|
}
|
|
|
|
// Default to 'denied' as required by GDPR/PDPA prior to user choice
|
|
window.gtag('consent', 'default', {
|
|
analytics_storage: 'denied',
|
|
ad_storage: 'denied',
|
|
ad_user_data: 'denied',
|
|
ad_personalization: 'denied',
|
|
});
|
|
}, []);
|
|
|
|
// 2. Sync consent updates whenever state changes
|
|
useEffect(() => {
|
|
if (!isHydrated || typeof window === 'undefined') return;
|
|
|
|
const { preferences, hasResponded } = record;
|
|
|
|
if (window.gtag) {
|
|
window.gtag('consent', 'update', {
|
|
analytics_storage: hasResponded && preferences.analytics ? 'granted' : 'denied',
|
|
ad_storage: hasResponded && preferences.marketing ? 'granted' : 'denied',
|
|
ad_user_data: hasResponded && preferences.marketing ? 'granted' : 'denied',
|
|
ad_personalization: hasResponded && preferences.marketing ? 'granted' : 'denied',
|
|
});
|
|
}
|
|
|
|
// Dispatch custom browser event for any other custom tags/scripts
|
|
window.dispatchEvent(
|
|
new CustomEvent('homaroma:consent-change', {
|
|
detail: {
|
|
hasResponded,
|
|
preferences,
|
|
},
|
|
})
|
|
);
|
|
|
|
// 3. Inject GA4 dynamically if enabled and configured
|
|
const gaId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;
|
|
if (gaId && hasResponded && preferences.analytics && !gaInjectedRef.current) {
|
|
gaInjectedRef.current = true;
|
|
const script = document.createElement('script');
|
|
script.src = `https://www.googletagmanager.com/gtag/js?id=${gaId}`;
|
|
script.async = true;
|
|
document.head.appendChild(script);
|
|
|
|
window.gtag?.('js', new Date());
|
|
window.gtag?.('config', gaId, {
|
|
anonymize_ip: true,
|
|
});
|
|
}
|
|
|
|
// 4. Inject Meta Pixel dynamically if enabled and configured
|
|
const metaPixelId = process.env.NEXT_PUBLIC_META_PIXEL_ID;
|
|
if (metaPixelId && hasResponded && preferences.marketing && !metaInjectedRef.current) {
|
|
metaInjectedRef.current = true;
|
|
(function (f: any, b: any, e: any, v: any, n?: any, t?: any, s?: any) {
|
|
if (f.fbq) return;
|
|
n = f.fbq = function () {
|
|
n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments);
|
|
};
|
|
if (!f._fbq) f._fbq = n;
|
|
n.push = n;
|
|
n.loaded = true;
|
|
n.version = '2.0';
|
|
n.queue = [];
|
|
t = b.createElement(e);
|
|
t.async = true;
|
|
t.src = v;
|
|
s = b.getElementsByTagName(e)[0];
|
|
s.parentNode.insertBefore(t, s);
|
|
})(window, document, 'script', 'https://connect.facebook.net/en_US/fbevents.js');
|
|
|
|
window.fbq('init', metaPixelId);
|
|
window.fbq('track', 'PageView');
|
|
}
|
|
|
|
// 5. Inject TikTok Pixel dynamically if enabled and configured
|
|
const tiktokPixelId = process.env.NEXT_PUBLIC_TIKTOK_PIXEL_ID;
|
|
if (tiktokPixelId && hasResponded && preferences.marketing && !tiktokInjectedRef.current) {
|
|
tiktokInjectedRef.current = true;
|
|
(function (w: any, d: any, t: any) {
|
|
w.TiktokAnalyticsObject = t;
|
|
const ttq = (w[t] = w[t] || []);
|
|
ttq.methods = [
|
|
'page',
|
|
'track',
|
|
'identify',
|
|
'instances',
|
|
'debug',
|
|
'on',
|
|
'off',
|
|
'once',
|
|
'ready',
|
|
'alias',
|
|
'group',
|
|
'enableCookie',
|
|
'disableCookie',
|
|
];
|
|
ttq.setAndDefer = function (t: any, e: any) {
|
|
t[e] = function () {
|
|
t.push([e].concat(Array.prototype.slice.call(arguments, 0)));
|
|
};
|
|
};
|
|
for (let i = 0; i < ttq.methods.length; i++) {
|
|
ttq.setAndDefer(ttq, ttq.methods[i]);
|
|
}
|
|
ttq.instance = function (t: any) {
|
|
const e = ttq._i[t] || [];
|
|
for (let n = 0; n < ttq.methods.length; n++) {
|
|
ttq.setAndDefer(e, ttq.methods[n]);
|
|
}
|
|
return e;
|
|
};
|
|
ttq.load = function (e: any, n: any) {
|
|
const i = 'https://analytics.tiktok.com/i18n/pixel/events.js';
|
|
ttq._i = ttq._i || {};
|
|
ttq._i[e] = [];
|
|
ttq._i[e]._u = i;
|
|
ttq._t = ttq._t || {};
|
|
ttq._t[e] = +new Date();
|
|
ttq._o = ttq._o || {};
|
|
ttq._o[e] = n || {};
|
|
const o = d.createElement('script');
|
|
o.type = 'text/javascript';
|
|
o.async = true;
|
|
o.src = `${i}?sdkid=${e}&lib=${t}`;
|
|
const a = d.getElementsByTagName('script')[0];
|
|
a.parentNode.insertBefore(o, a);
|
|
};
|
|
})(window, document, 'ttq');
|
|
|
|
window.ttq?.load(tiktokPixelId);
|
|
window.ttq?.page();
|
|
}
|
|
}, [record, isHydrated]);
|
|
|
|
return null;
|
|
}
|