/** * Banner i18n — locale detection and string translation. * * Loads translations from CDN or uses built-in defaults. * Supports string interpolation via {{key}} placeholders. */ export interface TranslationStrings { title: string; description: string; acceptAll: string; rejectAll: string; managePreferences: string; savePreferences: string; privacyPolicyLink: string; closeLabel: string; categoryNecessary: string; categoryNecessaryDesc: string; categoryFunctional: string; categoryFunctionalDesc: string; categoryAnalytics: string; categoryAnalyticsDesc: string; categoryMarketing: string; categoryMarketingDesc: string; categoryPersonalisation: string; categoryPersonalisationDesc: string; cookieCount: string; } /** Built-in English (GB) translations — used as fallback. */ export const DEFAULT_TRANSLATIONS: TranslationStrings = { title: 'We use cookies', description: 'We use cookies and similar technologies to enhance your browsing experience, analyse site traffic, and personalise content. You can choose which categories to allow. [Privacy Policy]({{privacy_policy}}) [Terms & Conditions]({{terms}})', acceptAll: 'Accept all', rejectAll: 'Reject all', managePreferences: 'Manage preferences', savePreferences: 'Save preferences', privacyPolicyLink: 'Privacy Policy', closeLabel: 'Close', categoryNecessary: 'Necessary', categoryNecessaryDesc: 'Essential for the website to function. Always active.', categoryFunctional: 'Functional', categoryFunctionalDesc: 'Enable enhanced functionality and personalisation.', categoryAnalytics: 'Analytics', categoryAnalyticsDesc: 'Help us understand how visitors interact with the site.', categoryMarketing: 'Marketing', categoryMarketingDesc: 'Used to deliver personalised advertisements.', categoryPersonalisation: 'Personalisation', categoryPersonalisationDesc: 'Enable content personalisation based on your profile.', cookieCount: '{{count}} cookies used on this site', }; /** Built-in Thai translations — no API call needed. */ export const THAI_TRANSLATIONS: TranslationStrings = { title: 'เรามีการใช้คุ๊กกี้เพื่อเก็บข้อมูล', description: 'เว็บไซต์นี้ใช้คุกกี้เพื่อปรับปรุงประสบการณ์การใช้งาน วิเคราะห์การเข้าชม และแสดงโฆษณาที่เหมาะกับคุณ คุณสามารถเลือกได้ว่าจะอนุญาตคุกกี้ประเภทใด คุกกี้ที่จำเป็นจะถูกเปิดใช้งานเสมอเพื่อให้เว็บไซต์ทำงานได้ [นโยบายความเป็นส่วนตัว]({{privacy_policy}}) [ข้อกำหนดและเงื่อนไข]({{terms}})', acceptAll: 'ยอมรับทั้งหมด', rejectAll: 'ปฏิเสธทั้งหมด', managePreferences: 'ตั้งค่า', savePreferences: 'บันทึก', privacyPolicyLink: 'นโยบายความเป็นส่วนตัว', closeLabel: 'ปิด', categoryNecessary: 'จำเป็น', categoryNecessaryDesc: 'คุกกี้ที่จำเป็นสำหรับการทำงานพื้นฐานของเว็บไซต์ เช่น การจดจำตะกร้าสินค้า การเข้าสู่ระบบ และความปลอดภัย — เปิดใช้งานเสมอเพราะเว็บไซต์ไม่สามารถทำงานได้ถ้าไม่มี', categoryFunctional: 'ฟังก์ชั่น', categoryFunctionalDesc: 'คุกกี้การตลาดใช้เพื่อให้เว็บไชต์มีฟังก์ชั่นพิเศษ ถ้าปิดการทำงานอาจจะทำให้บางฟังก์ชั่นของเว็บไซต์ใช้งานไม่ได้', categoryAnalytics: 'การวิเคราะห์', categoryAnalyticsDesc: 'คุกกี้เหล่านี้ช่วยให้เราเข้าใจว่าผู้เข้าชมใช้งานเว็บไซต์อย่างไร เช่น หน้าไหนที่เข้าบ่อย คลิกที่ไหน - ข้อมูลเหล่านี้ช่วยเราพัฒนาเว็บไซต์ให้ดีขึ้นเรื่อยๆ', categoryMarketing: 'การตลาด', categoryMarketingDesc: 'คุกกี้การตลาดใช้ติดตามพฤติกรรมการเข้าชมเว็บไซต์เพื่อแสดงโฆษณาที่เกี่ยวข้องกับความสนใจของคุณบนแพลตฟอร์มอื่นด้วย เช่น Facebook และ Google', categoryPersonalisation: 'ส่วนตัว', categoryPersonalisationDesc: 'คุกกี้การตลาดใช้สำหรับเก็บข้อมูลส่วนตัว', cookieCount: 'มีคุกกี้ {{count}} บนเว็บไซต์นี้', }; /** Built-in translations that don't require an API call. */ const BUILT_IN_TRANSLATIONS: Record = { en: DEFAULT_TRANSLATIONS, th: THAI_TRANSLATIONS, }; /** * Detect the user's preferred locale. * * Priority: 1) explicit data-locale attribute, 2) navigator.language, * 3) document lang attribute, 4) 'en'. */ export function detectLocale(): string { // Check for explicit override on the script tag const scriptEl = document.querySelector('script[data-site-id]'); const explicit = scriptEl?.getAttribute('data-locale'); if (explicit) return normaliseLocale(explicit); // Browser language if (typeof navigator !== 'undefined' && navigator.language) { return normaliseLocale(navigator.language); } // Document lang attribute const docLang = document.documentElement.lang; if (docLang) return normaliseLocale(docLang); return 'en'; } /** * Normalise a locale string to a two-letter language code. * e.g. 'en-GB' → 'en', 'fr-FR' → 'fr' */ export function normaliseLocale(locale: string): string { return locale.split('-')[0].toLowerCase(); } /** * Fetch translations for a locale from the public API endpoint. * Returns null if not found or on error. */ export async function fetchTranslations( apiBase: string, siteId: string, locale: string, ): Promise | null> { try { const resp = await fetch(`${apiBase}/api/v1/translations/${siteId}/${locale}`); if (!resp.ok) return null; // Public API returns the raw strings dict. Accept a wrapped `{ strings }` // shape too for backwards compatibility with older mocks/clients. const data = await resp.json() as Partial | { strings?: Partial }; return 'strings' in data && data.strings ? data.strings : data as Partial; } catch { return null; } } /** * Load translations: use built-in if available, otherwise fetch from API. */ export async function loadTranslations( apiBase: string, siteId: string, locale: string, ): Promise { // Built-in translations (en, th, etc.) — no API call needed const builtIn = BUILT_IN_TRANSLATIONS[locale]; if (builtIn) { return { ...builtIn }; } const remote = await fetchTranslations(apiBase, siteId, locale); if (!remote) { return { ...DEFAULT_TRANSLATIONS }; } // Merge remote over defaults so missing keys fall back to English return { ...DEFAULT_TRANSLATIONS, ...remote }; } /** * Interpolate placeholders in a translation string. * e.g. interpolate('{{count}} cookies', { count: '12' }) → '12 cookies' */ export function interpolate( template: string, values: Record, ): string { return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => values[key] ?? ''); } /** * Render markdown-style links as HTML anchor tags and strip orphaned links. * * Converts `[text](url)` to `text`. * If the URL is empty (because the config value wasn't set), the entire * `[text]()` fragment is removed so no broken links appear. */ export function renderLinks(html: string, linkClass: string = 'consentos-banner__link'): string { // Remove links with empty URLs (including surrounding whitespace) let result = html.replace(/\s*\[([^\]]*)\]\(\s*\)\s*/g, ''); // Convert remaining markdown links to tags result = result.replace( /\[([^\]]+)\]\(([^)]+)\)/g, `$1`, ); return result; }