Files
consentos/apps/banner/src/i18n.ts
Kunthawat Greethong 276d9df3db
Some checks failed
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
fix: correct typo 'เว็บไซซ์' → 'เว็บไซต์' in Thai banner translation
2026-07-12 17:01:21 +07:00

188 lines
8.9 KiB
TypeScript

/**
* 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<string, TranslationStrings> = {
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<Partial<TranslationStrings> | 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<TranslationStrings> | { strings?: Partial<TranslationStrings> };
return 'strings' in data && data.strings ? data.strings : data as Partial<TranslationStrings>;
} 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<TranslationStrings> {
// 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, string>,
): 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 `<a href="url" ...>text</a>`.
* 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 <a> tags
result = result.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
`<a href="$2" target="_blank" rel="noopener" class="${linkClass}">$1</a>`,
);
return result;
}