- Remove missing theme-utils.js references from all HTML files - Fix double initialization in i18n.js causing language selector conflicts - Update service worker cache version to force fresh file loading - Language selector now persists when navigating via snowflake icon 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
282 lines
No EOL
7.7 KiB
JavaScript
282 lines
No EOL
7.7 KiB
JavaScript
/**
|
|
* Frontend internationalization (i18n) service
|
|
* Provides translation support for client-side JavaScript
|
|
*/
|
|
|
|
class I18nService {
|
|
constructor() {
|
|
this.translations = new Map();
|
|
this.defaultLocale = 'en';
|
|
this.availableLocales = ['en', 'es-MX'];
|
|
this.currentLocale = this.getStoredLocale() || this.detectBrowserLocale();
|
|
}
|
|
|
|
/**
|
|
* Load translation data from the server
|
|
*/
|
|
async loadTranslations() {
|
|
for (const locale of this.availableLocales) {
|
|
try {
|
|
const response = await fetch(`/api/i18n/${locale}`);
|
|
if (response.ok) {
|
|
const translations = await response.json();
|
|
this.translations.set(locale, translations);
|
|
console.log(`Loaded translations for locale: ${locale}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to load translations for locale ${locale}:`, error);
|
|
}
|
|
}
|
|
|
|
// Trigger translation update after loading
|
|
this.updatePageTranslations();
|
|
|
|
// Create language selector after translations are loaded
|
|
this.createLanguageSelector('language-selector-container');
|
|
}
|
|
|
|
/**
|
|
* Get a translated string by key path
|
|
*/
|
|
t(keyPath, locale = null, params = null) {
|
|
const targetLocale = locale || this.currentLocale;
|
|
const translations = this.translations.get(targetLocale) || this.translations.get(this.defaultLocale);
|
|
|
|
if (!translations) {
|
|
console.warn(`No translations found for locale: ${targetLocale}`);
|
|
return keyPath;
|
|
}
|
|
|
|
const keys = keyPath.split('.');
|
|
let value = translations;
|
|
|
|
for (const key of keys) {
|
|
value = value?.[key];
|
|
if (value === undefined) {
|
|
// Fallback to default locale if key not found
|
|
if (targetLocale !== this.defaultLocale) {
|
|
return this.t(keyPath, this.defaultLocale, params);
|
|
}
|
|
console.warn(`Translation key not found: ${keyPath} for locale: ${targetLocale}`);
|
|
return keyPath;
|
|
}
|
|
}
|
|
|
|
if (typeof value !== 'string') {
|
|
console.warn(`Translation value is not a string for key: ${keyPath}`);
|
|
return keyPath;
|
|
}
|
|
|
|
// Replace parameters if provided
|
|
if (params) {
|
|
return this.replaceParams(value, params);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Replace parameters in translation strings
|
|
*/
|
|
replaceParams(text, params) {
|
|
return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
return params[key] || match;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set the current locale and update the UI
|
|
*/
|
|
async setLocale(locale) {
|
|
if (!this.availableLocales.includes(locale)) {
|
|
console.warn(`Unsupported locale: ${locale}`);
|
|
return;
|
|
}
|
|
|
|
this.currentLocale = locale;
|
|
this.storeLocale(locale);
|
|
|
|
// Update HTML lang attribute
|
|
document.documentElement.lang = locale;
|
|
|
|
// Update page translations
|
|
this.updatePageTranslations();
|
|
|
|
// Trigger custom event for components to update
|
|
window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } }));
|
|
}
|
|
|
|
/**
|
|
* Get current locale
|
|
*/
|
|
getLocale() {
|
|
return this.currentLocale;
|
|
}
|
|
|
|
/**
|
|
* Get available locales
|
|
*/
|
|
getAvailableLocales() {
|
|
return [...this.availableLocales];
|
|
}
|
|
|
|
/**
|
|
* Get locale display name
|
|
*/
|
|
getLocaleDisplayName(locale) {
|
|
const displayNames = {
|
|
'en': 'English',
|
|
'es-MX': 'Español (México)'
|
|
};
|
|
return displayNames[locale] || locale;
|
|
}
|
|
|
|
/**
|
|
* Detect browser's preferred locale
|
|
*/
|
|
detectBrowserLocale() {
|
|
const browserLang = navigator.language || navigator.languages?.[0];
|
|
|
|
if (!browserLang) {
|
|
return this.defaultLocale;
|
|
}
|
|
|
|
// Check for exact match first
|
|
if (this.availableLocales.includes(browserLang)) {
|
|
return browserLang;
|
|
}
|
|
|
|
// Check for language match (e.g., "es" matches "es-MX")
|
|
const languageCode = browserLang.split('-')[0];
|
|
const matchingLocale = this.availableLocales.find(locale =>
|
|
locale.startsWith(languageCode)
|
|
);
|
|
|
|
return matchingLocale || this.defaultLocale;
|
|
}
|
|
|
|
/**
|
|
* Store locale preference in localStorage
|
|
*/
|
|
storeLocale(locale) {
|
|
try {
|
|
localStorage.setItem('preferredLocale', locale);
|
|
} catch (error) {
|
|
console.warn('Failed to store locale preference:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get stored locale preference from localStorage
|
|
*/
|
|
getStoredLocale() {
|
|
try {
|
|
return localStorage.getItem('preferredLocale');
|
|
} catch (error) {
|
|
console.warn('Failed to get stored locale preference:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update page translations using data-i18n attributes
|
|
*/
|
|
updatePageTranslations() {
|
|
// Update elements with data-i18n attribute
|
|
document.querySelectorAll('[data-i18n]').forEach(element => {
|
|
const key = element.getAttribute('data-i18n');
|
|
const translation = this.t(key);
|
|
|
|
if (element.tagName === 'INPUT' && (element.type === 'text' || element.type === 'password')) {
|
|
element.placeholder = translation;
|
|
} else if (element.hasAttribute('title')) {
|
|
element.title = translation;
|
|
} else {
|
|
element.textContent = translation;
|
|
}
|
|
});
|
|
|
|
// Update elements with data-i18n-placeholder attribute
|
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
|
|
const key = element.getAttribute('data-i18n-placeholder');
|
|
const translation = this.t(key);
|
|
element.placeholder = translation;
|
|
});
|
|
|
|
// Update elements with data-i18n-html attribute (for HTML content)
|
|
document.querySelectorAll('[data-i18n-html]').forEach(element => {
|
|
const key = element.getAttribute('data-i18n-html');
|
|
const translation = this.t(key);
|
|
element.innerHTML = translation;
|
|
});
|
|
|
|
// Update page title if it has translation
|
|
const titleElement = document.querySelector('title[data-i18n]');
|
|
if (titleElement) {
|
|
const key = titleElement.getAttribute('data-i18n');
|
|
document.title = this.t(key);
|
|
}
|
|
|
|
// Update meta description if it has translation
|
|
const metaDescription = document.querySelector('meta[name="description"][data-i18n]');
|
|
if (metaDescription) {
|
|
const key = metaDescription.getAttribute('data-i18n');
|
|
metaDescription.content = this.t(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create language selector dropdown
|
|
*/
|
|
createLanguageSelector(containerId) {
|
|
const container = document.getElementById(containerId);
|
|
if (!container) {
|
|
console.warn(`Container with id "${containerId}" not found`);
|
|
return;
|
|
}
|
|
|
|
// Clear container first in case it's being recreated
|
|
container.innerHTML = '';
|
|
|
|
const select = document.createElement('select');
|
|
select.id = 'language-selector';
|
|
select.className = 'language-selector';
|
|
|
|
// Use fallback title if translations not loaded yet
|
|
const titleText = this.translations.size > 0 ? this.t('common.selectLanguage') : 'Select language';
|
|
select.title = titleText;
|
|
|
|
// Add options for each available locale
|
|
this.availableLocales.forEach(locale => {
|
|
const option = document.createElement('option');
|
|
option.value = locale;
|
|
option.textContent = this.getLocaleDisplayName(locale);
|
|
option.selected = locale === this.currentLocale;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
// Add change event listener
|
|
select.addEventListener('change', (event) => {
|
|
this.setLocale(event.target.value);
|
|
});
|
|
|
|
container.appendChild(select);
|
|
}
|
|
}
|
|
|
|
// Create global instance
|
|
window.i18n = new I18nService();
|
|
|
|
// Helper function for easier access
|
|
window.t = function(keyPath, locale = null, params = null) {
|
|
return window.i18n.t(keyPath, locale, params);
|
|
};
|
|
|
|
// Auto-initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.i18n.loadTranslations();
|
|
});
|
|
} else {
|
|
window.i18n.loadTranslations();
|
|
} |