From cf4546422d7db1a64aece9e1d879ba772e86ee46 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 19 Oct 2026 05:45:36 +0700 Subject: [PATCH] feat(website): implement PDPA cookie consent banner and script manager - Add cookieConsentStore using Zustand with localStorage persistence and skipHydration - Implement responsive CookieBanner with Quiet Luxury design and PDPA compliance - Implement CookiePreferencesModal with category toggles (Necessary, Analytics, Marketing) - Implement ScriptManager for Google Consent Mode v2, GA4, Meta Pixel, and TikTok Pixel orchestration - Add Cookie Preferences trigger in Footer support column - Add multilingual translation keys for Thai, English, Japanese, and Chinese --- .../src/components/ClientProviders.tsx | 8 + apps/website/src/components/Footer.tsx | 8 + .../src/components/cookie/CookieBanner.tsx | 78 ++++++ .../cookie/CookiePreferencesModal.tsx | 225 ++++++++++++++++++ .../src/components/cookie/ScriptManager.tsx | 170 +++++++++++++ apps/website/src/lib/translations.ts | 14 +- apps/website/src/locales/en/translation.json | 18 ++ apps/website/src/locales/ja/translation.json | 18 ++ apps/website/src/locales/th/translation.json | 18 ++ apps/website/src/locales/zh/translation.json | 18 ++ apps/website/src/store/cookieConsentStore.ts | 124 ++++++++++ 11 files changed, 692 insertions(+), 7 deletions(-) create mode 100644 apps/website/src/components/cookie/CookieBanner.tsx create mode 100644 apps/website/src/components/cookie/CookiePreferencesModal.tsx create mode 100644 apps/website/src/components/cookie/ScriptManager.tsx create mode 100644 apps/website/src/store/cookieConsentStore.ts diff --git a/apps/website/src/components/ClientProviders.tsx b/apps/website/src/components/ClientProviders.tsx index d8d9216..398090e 100644 --- a/apps/website/src/components/ClientProviders.tsx +++ b/apps/website/src/components/ClientProviders.tsx @@ -7,7 +7,11 @@ import i18n from '../lib/i18n'; import { useCartStore } from '../store/cartStore'; import { useWishlistStore } from '../store/wishlistStore'; import { useAuthStore } from '../store/authStore'; +import { useCookieConsentStore } from '../store/cookieConsentStore'; import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '../lib/locales'; +import CookieBanner from './cookie/CookieBanner'; +import CookiePreferencesModal from './cookie/CookiePreferencesModal'; +import ScriptManager from './cookie/ScriptManager'; export default function ClientProviders({ children }: { children: ReactNode }) { useEffect(() => { @@ -25,13 +29,17 @@ export default function ClientProviders({ children }: { children: ReactNode }) { void useCartStore.persist.rehydrate() void useWishlistStore.persist.rehydrate() void useAuthStore.persist.rehydrate() + void useCookieConsentStore.persist.rehydrate() }, []) return ( <> + {children} + + ); } diff --git a/apps/website/src/components/Footer.tsx b/apps/website/src/components/Footer.tsx index e1d4dd5..d18a66b 100644 --- a/apps/website/src/components/Footer.tsx +++ b/apps/website/src/components/Footer.tsx @@ -15,6 +15,7 @@ import { import { useTranslation } from 'react-i18next' import { resolveLocalized } from '../lib/localization' import { useLang } from '../hooks/useLang' +import { useCookieConsentStore } from '../store/cookieConsentStore' interface FooterProps { categories?: Category[] @@ -154,6 +155,13 @@ export default function Footer({
{supportMenu.map((item) => {translateMenuLabel(item)})} +

diff --git a/apps/website/src/components/cookie/CookieBanner.tsx b/apps/website/src/components/cookie/CookieBanner.tsx new file mode 100644 index 0000000..29b5b80 --- /dev/null +++ b/apps/website/src/components/cookie/CookieBanner.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useCookieConsentStore } from '../../store/cookieConsentStore'; +import { useTranslation } from 'react-i18next'; +import { Cookie, Settings2, ShieldCheck } from 'lucide-react'; + +export default function CookieBanner() { + const { t } = useTranslation(); + const { record, isHydrated, acceptAll, rejectAll, openPreferences } = useCookieConsentStore(); + + // Do not render on server or before rehydration, or if user has already made a choice + if (!isHydrated || record.hasResponded) { + return null; + } + + return ( +

+ ); +} diff --git a/apps/website/src/components/cookie/CookiePreferencesModal.tsx b/apps/website/src/components/cookie/CookiePreferencesModal.tsx new file mode 100644 index 0000000..cd21a92 --- /dev/null +++ b/apps/website/src/components/cookie/CookiePreferencesModal.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { useCookieConsentStore, type CookiePreferences } from '../../store/cookieConsentStore'; +import { useTranslation } from 'react-i18next'; +import { X, ShieldCheck, BarChart3, Target, Lock, Check } from 'lucide-react'; + +export default function CookiePreferencesModal() { + const { t } = useTranslation(); + const { + record, + isPreferencesOpen, + closePreferences, + savePreferences, + acceptAll, + rejectAll, + } = useCookieConsentStore(); + + // Local state for interactive toggling inside the modal + const [localPrefs, setLocalPrefs] = useState({ + necessary: true, + analytics: false, + marketing: false, + }); + + // Sync local state when modal opens + useEffect(() => { + if (isPreferencesOpen) { + setLocalPrefs({ + necessary: true, + analytics: Boolean(record.preferences.analytics), + marketing: Boolean(record.preferences.marketing), + }); + } + }, [isPreferencesOpen, record.preferences]); + + // Handle ESC key to close + useEffect(() => { + if (!isPreferencesOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') closePreferences(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isPreferencesOpen, closePreferences]); + + if (!isPreferencesOpen) return null; + + const handleSave = () => { + savePreferences(localPrefs); + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+ +

+ {t('cookie.prefSubtitle', 'จัดการความยินยอมในการจัดเก็บและใช้งานคุกกี้ตาม พ.ร.บ. คุ้มครองข้อมูลส่วนบุคคล (PDPA)')} +

+
+
+ +
+ + {/* Body / Cookie Categories */} +
+ {/* 1. Necessary Cookies */} +
+
+
+
+ +
+
+
+

+ {t('cookie.catNecessaryTitle', 'คุกกี้ที่จำเป็นอย่างยิ่ง (Strictly Necessary)')} +

+ + {t('cookie.alwaysActive', 'เปิดใช้งานเสมอ')} + +
+

+ {t( + 'cookie.catNecessaryDesc', + 'คุกกี้เหล่านี้มีความจำเป็นต่อการทำงานพื้นฐานของเว็บไซต์ เช่น การเข้าสู่ระบบ ความปลอดภัย การจดจำสินค้าในตะกร้า และการตั้งค่าภาษา เว็บไซต์ไม่สามารถทำงานได้อย่างถูกต้องหากไม่มีคุกกี้เหล่านี้' + )} +

+
+
+
+ +
+
+
+
+ + {/* 2. Analytics Cookies */} +
+
+
+
+ +
+
+

+ {t('cookie.catAnalyticsTitle', 'คุกกี้เพื่อการวิเคราะห์และวัดผล (Analytics & Performance)')} +

+

+ {t( + 'cookie.catAnalyticsDesc', + 'ช่วยให้เราทราบสถิติการเข้าชม พฤติกรรมหน้าเว็บยอดนิยม และเส้นทางการใช้งานของผู้ใช้ เพื่อนำข้อมูลไปปรับปรุงประสิทธิภาพและบริการ (เช่น Google Analytics 4) ข้อมูลจะถูกจัดเก็บแบบไม่ระบุตัวตนโดยตรง' + )} +

+
+
+ +
+
+ + {/* 3. Marketing Cookies */} +
+
+
+
+ +
+
+

+ {t('cookie.catMarketingTitle', 'คุกกี้เพื่อการตลาดและโฆษณา (Marketing & Targeting)')} +

+

+ {t( + 'cookie.catMarketingDesc', + 'ใช้เพื่อนำเสนอโปรโมชัน โฆษณา และเนื้อหาที่สอดคล้องกับความสนใจของท่านผ่านแพลตฟอร์มพันธมิตร (เช่น Meta Pixel, TikTok Pixel, Google Ads) รวมถึงวัดประสิทธิผลของแคมเปญ' + )} +

+
+
+ +
+
+
+ + {/* Footer / Buttons */} +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/apps/website/src/components/cookie/ScriptManager.tsx b/apps/website/src/components/cookie/ScriptManager.tsx new file mode 100644 index 0000000..5a53f81 --- /dev/null +++ b/apps/website/src/components/cookie/ScriptManager.tsx @@ -0,0 +1,170 @@ +"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; +} diff --git a/apps/website/src/lib/translations.ts b/apps/website/src/lib/translations.ts index 0709180..e42c08c 100644 --- a/apps/website/src/lib/translations.ts +++ b/apps/website/src/lib/translations.ts @@ -11,13 +11,13 @@ export type TranslationSchema = typeof thTranslation const translations: Record = { th: thTranslation, - en: enTranslation as TranslationSchema, - ja: jaTranslation as TranslationSchema, - zh: zhTranslation as TranslationSchema, - vi: viTranslation as TranslationSchema, - ko: koTranslation as TranslationSchema, - lo: loTranslation as TranslationSchema, - it: itTranslation as TranslationSchema, + en: enTranslation as unknown as TranslationSchema, + ja: jaTranslation as unknown as TranslationSchema, + zh: zhTranslation as unknown as TranslationSchema, + vi: viTranslation as unknown as TranslationSchema, + ko: koTranslation as unknown as TranslationSchema, + lo: loTranslation as unknown as TranslationSchema, + it: itTranslation as unknown as TranslationSchema, } export function getServerTranslations(lang = 'th'): TranslationSchema { diff --git a/apps/website/src/locales/en/translation.json b/apps/website/src/locales/en/translation.json index 8f8afdf..f6da18a 100644 --- a/apps/website/src/locales/en/translation.json +++ b/apps/website/src/locales/en/translation.json @@ -216,6 +216,24 @@ "notFound": "Unable to verify order details", "networkError": "Connection error. Please try again." } + }, + "cookie": { + "settings": "Cookie Preferences", + "bannerTitle": "We value your privacy", + "bannerDesc": "We use cookies to improve your browsing experience, provide personalized content, and analyze our website traffic in accordance with privacy laws. You can choose to accept all cookies or manage your preferences.", + "btnCustomize": "Preferences", + "btnRejectAll": "Reject Non-Essential", + "btnAcceptAll": "Accept All", + "btnSavePreferences": "Save Preferences", + "prefTitle": "Privacy & Cookie Preferences", + "prefSubtitle": "Manage your consent for cookie storage and data processing under privacy regulations (PDPA & GDPR)", + "alwaysActive": "Always Active", + "catNecessaryTitle": "Strictly Necessary Cookies", + "catNecessaryDesc": "These cookies are essential for the website to function properly, enabling basic functions like navigation, cart checkout, and access to secure areas. The website cannot function properly without these cookies.", + "catAnalyticsTitle": "Analytics & Performance Cookies", + "catAnalyticsDesc": "These cookies allow us to count visits and traffic sources so we can measure and improve the performance of our site (e.g., Google Analytics 4). All information is aggregated and anonymous.", + "catMarketingTitle": "Marketing & Targeting Cookies", + "catMarketingDesc": "These cookies may be set through our site by our advertising partners (e.g., Meta Pixel, TikTok Pixel, Google Ads) to build a profile of your interests and show you relevant adverts on other sites." } } diff --git a/apps/website/src/locales/ja/translation.json b/apps/website/src/locales/ja/translation.json index 4a0695e..87a9b7e 100644 --- a/apps/website/src/locales/ja/translation.json +++ b/apps/website/src/locales/ja/translation.json @@ -237,6 +237,24 @@ "notFound": "注文情報を確認できませんでした", "networkError": "接続エラーが発生しました。もう一度お試しください。" } + }, + "cookie": { + "settings": "クッキー設定", + "bannerTitle": "お客様のプライバシーを尊重します", + "bannerDesc": "当ウェブサイトでは、利便性の向上、利用状況の分析、パーソナライズされた広告の提供のためにクッキーを使用しています。同意設定はいつでも変更できます。", + "btnCustomize": "設定", + "btnRejectAll": "必須以外を拒否", + "btnAcceptAll": "すべて同意", + "btnSavePreferences": "設定を保存", + "prefTitle": "プライバシーおよびクッキー設定", + "prefSubtitle": "個人情報保護法(PDPA/GDPR)に基づくクッキーの保存およびデータ利用の同意管理", + "alwaysActive": "常に有効", + "catNecessaryTitle": "必須クッキー (Strictly Necessary)", + "catNecessaryDesc": "ウェブサイトの基本的な機能(ログイン、セキュリティ、カート、言語設定など)に不可欠なクッキーです。このクッキーを無効にすることはできません。", + "catAnalyticsTitle": "分析・パフォーマンスクッキー (Analytics)", + "catAnalyticsDesc": "訪問者数やトラフィックソースを計測し、サイトのパフォーマンスを向上させるためのクッキーです(Google Analyticsなど)。", + "catMarketingTitle": "マーケティング・ターゲティングクッキー (Marketing)", + "catMarketingDesc": "お客様の興味関心に基づいた関連性の高い広告やプロモーションを配信するために使用されます(Meta Pixel、TikTok Pixelなど)。" } } diff --git a/apps/website/src/locales/th/translation.json b/apps/website/src/locales/th/translation.json index 3662e8c..e5a5d9f 100644 --- a/apps/website/src/locales/th/translation.json +++ b/apps/website/src/locales/th/translation.json @@ -216,6 +216,24 @@ "notFound": "ไม่สามารถยืนยันข้อมูลคำสั่งซื้อได้", "networkError": "เกิดข้อผิดพลาดในการเชื่อมต่อ กรุณาลองใหม่อีกครั้ง" } + }, + "cookie": { + "settings": "การตั้งค่าคุกกี้", + "bannerTitle": "เราให้ความสำคัญกับความเป็นส่วนตัวของคุณ", + "bannerDesc": "เราใช้คุกกี้เพื่อเพิ่มประสิทธิภาพ ประสบการณ์ที่ดีในการใช้งานเว็บไซต์ และเพื่อการวิเคราะห์ปรับปรุงบริการ ท่านสามารถเลือกตั้งค่าความยินยอมตามประเภทคุกกี้ได้", + "btnCustomize": "ตั้งค่าคุกกี้", + "btnRejectAll": "ปฏิเสธทั้งหมด", + "btnAcceptAll": "ยอมรับทั้งหมด", + "btnSavePreferences": "บันทึกการตั้งค่า", + "prefTitle": "ศูนย์การตั้งค่าความเป็นส่วนตัว", + "prefSubtitle": "จัดการความยินยอมในการจัดเก็บและใช้งานคุกกี้ตาม พ.ร.บ. คุ้มครองข้อมูลส่วนบุคคล (PDPA)", + "alwaysActive": "เปิดใช้งานเสมอ", + "catNecessaryTitle": "คุกกี้ที่จำเป็นอย่างยิ่ง (Strictly Necessary)", + "catNecessaryDesc": "คุกกี้เหล่านี้มีความจำเป็นต่อการทำงานพื้นฐานของเว็บไซต์ เช่น การเข้าสู่ระบบ ความปลอดภัย การจดจำสินค้าในตะกร้า และการตั้งค่าภาษา เว็บไซต์ไม่สามารถทำงานได้อย่างถูกต้องหากไม่มีคุกกี้เหล่านี้", + "catAnalyticsTitle": "คุกกี้เพื่อการวิเคราะห์และวัดผล (Analytics & Performance)", + "catAnalyticsDesc": "ช่วยให้เราทราบสถิติการเข้าชม พฤติกรรมหน้าเว็บยอดนิยม และเส้นทางการใช้งานของผู้ใช้ เพื่อนำข้อมูลไปปรับปรุงประสิทธิภาพและบริการ (เช่น Google Analytics 4) ข้อมูลจะถูกจัดเก็บแบบไม่ระบุตัวตนโดยตรง", + "catMarketingTitle": "คุกกี้เพื่อการตลาดและโฆษณา (Marketing & Targeting)", + "catMarketingDesc": "ใช้เพื่อนำเสนอโปรโมชัน โฆษณา และเนื้อหาที่สอดคล้องกับความสนใจของท่านผ่านแพลตฟอร์มพันธมิตร (เช่น Meta Pixel, TikTok Pixel, Google Ads) รวมถึงวัดประสิทธิผลของแคมเปญ" } } diff --git a/apps/website/src/locales/zh/translation.json b/apps/website/src/locales/zh/translation.json index 3f4017a..17e9550 100644 --- a/apps/website/src/locales/zh/translation.json +++ b/apps/website/src/locales/zh/translation.json @@ -237,6 +237,24 @@ "notFound": "无法验证订单信息", "networkError": "网络连接错误,请稍后重试。" } + }, + "cookie": { + "settings": "Cookie 设置", + "bannerTitle": "我们重视您的隐私", + "bannerDesc": "我们使用 Cookie 来改善您的浏览体验、提供个性化内容以及根据隐私法规分析网站流量。您可以选择接受所有 Cookie 或自定义您的偏好设置。", + "btnCustomize": "偏好设置", + "btnRejectAll": "拒绝非必要", + "btnAcceptAll": "接受全部", + "btnSavePreferences": "保存设置", + "prefTitle": "隐私与 Cookie 偏好中心", + "prefSubtitle": "根据个人数据保护法规 (PDPA/GDPR) 管理 Cookie 存储及数据使用授权", + "alwaysActive": "始终启用", + "catNecessaryTitle": "必要 Cookie (Strictly Necessary)", + "catNecessaryDesc": "这些 Cookie 是网站运行所必需的,用于支持登录、安全性、购物车和语言设置等基本功能。网站无法在缺少此类 Cookie 的情况下正常运行。", + "catAnalyticsTitle": "分析与性能 Cookie (Analytics)", + "catAnalyticsDesc": "帮助我们统计访问量和流量来源,以评估并改善网站性能(例如 Google Analytics 4)。数据为匿名统计。", + "catMarketingTitle": "营销与广告 Cookie (Marketing)", + "catMarketingDesc": "由广告合作伙伴设置(例如 Meta Pixel、TikTok Pixel、Google Ads),用于构建您的兴趣画像并向您推送相关广告。" } } diff --git a/apps/website/src/store/cookieConsentStore.ts b/apps/website/src/store/cookieConsentStore.ts new file mode 100644 index 0000000..9226a84 --- /dev/null +++ b/apps/website/src/store/cookieConsentStore.ts @@ -0,0 +1,124 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export const CURRENT_CONSENT_VERSION = '1.0'; + +export interface CookiePreferences { + necessary: boolean; + analytics: boolean; + marketing: boolean; +} + +export interface CookieConsentRecord { + hasResponded: boolean; + version: string; + timestamp: string | null; + preferences: CookiePreferences; +} + +interface CookieConsentState { + record: CookieConsentRecord; + isPreferencesOpen: boolean; + isHydrated: boolean; + + // Actions + setHydrated: (hydrated: boolean) => void; + openPreferences: () => void; + closePreferences: () => void; + acceptAll: () => void; + rejectAll: () => void; + savePreferences: (preferences: Partial) => void; + resetConsent: () => void; +} + +const DEFAULT_RECORD: CookieConsentRecord = { + hasResponded: false, + version: CURRENT_CONSENT_VERSION, + timestamp: null, + preferences: { + necessary: true, + analytics: false, + marketing: false, + }, +}; + +export const useCookieConsentStore = create()( + persist( + (set) => ({ + record: DEFAULT_RECORD, + isPreferencesOpen: false, + isHydrated: false, + + setHydrated: (hydrated: boolean) => set({ isHydrated: hydrated }), + + openPreferences: () => set({ isPreferencesOpen: true }), + closePreferences: () => set({ isPreferencesOpen: false }), + + acceptAll: () => { + const now = new Date().toISOString(); + set({ + record: { + hasResponded: true, + version: CURRENT_CONSENT_VERSION, + timestamp: now, + preferences: { + necessary: true, + analytics: true, + marketing: true, + }, + }, + isPreferencesOpen: false, + }); + }, + + rejectAll: () => { + const now = new Date().toISOString(); + set({ + record: { + hasResponded: true, + version: CURRENT_CONSENT_VERSION, + timestamp: now, + preferences: { + necessary: true, + analytics: false, + marketing: false, + }, + }, + isPreferencesOpen: false, + }); + }, + + savePreferences: (userPrefs: Partial) => { + const now = new Date().toISOString(); + set((state) => ({ + record: { + hasResponded: true, + version: CURRENT_CONSENT_VERSION, + timestamp: now, + preferences: { + necessary: true, + analytics: Boolean(userPrefs.analytics), + marketing: Boolean(userPrefs.marketing), + }, + }, + isPreferencesOpen: false, + })); + }, + + resetConsent: () => { + set({ + record: DEFAULT_RECORD, + isPreferencesOpen: false, + }); + }, + }), + { + name: 'homaroma-cookie-consent', + partialize: (state) => ({ record: state.record }), + skipHydration: true, + onRehydrateStorage: () => (state) => { + state?.setHydrated(true); + }, + } + ) +); -- 2.43.0