feat: Add project-scoped language for unsubscribe footer and contact-facing pages
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import type {Language} from './languages.js';
|
||||
// Static imports for all translation files
|
||||
import enTranslations from './locales/en.json' with {type: 'json'};
|
||||
import nlTranslations from './locales/nl.json' with {type: 'json'};
|
||||
|
||||
export {
|
||||
SUPPORTED_LANGUAGES,
|
||||
DEFAULT_LANGUAGE,
|
||||
isValidLanguageCode,
|
||||
getLanguageByCode,
|
||||
type Language,
|
||||
} from './languages.js';
|
||||
|
||||
export type TranslationKey = string;
|
||||
|
||||
export interface Translations {
|
||||
pages: {
|
||||
unsubscribe: Record<string, string>;
|
||||
subscribe: Record<string, string>;
|
||||
manage: Record<string, string>;
|
||||
common: Record<string, string>;
|
||||
};
|
||||
email: {
|
||||
footer: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
// Static mapping of language codes to translations
|
||||
const translationsMap: Record<string, Translations> = {
|
||||
en: enTranslations,
|
||||
nl: nlTranslations,
|
||||
};
|
||||
|
||||
// In-memory cache for loaded translations
|
||||
const translationCache = new Map<string, Translations>();
|
||||
|
||||
/**
|
||||
* Load translations for a given language code (async)
|
||||
* Uses static imports for reliable resolution
|
||||
*/
|
||||
export async function loadTranslations(languageCode: string): Promise<Translations> {
|
||||
// Check cache first
|
||||
if (translationCache.has(languageCode)) {
|
||||
return translationCache.get(languageCode)!;
|
||||
}
|
||||
|
||||
// Get translations from static map
|
||||
const translations = translationsMap[languageCode];
|
||||
|
||||
if (translations) {
|
||||
translationCache.set(languageCode, translations);
|
||||
return translations;
|
||||
}
|
||||
|
||||
// Fallback to English if language not found
|
||||
console.warn(`Translation file not found for ${languageCode}, falling back to English`);
|
||||
if (languageCode !== 'en') {
|
||||
return loadTranslations('en');
|
||||
}
|
||||
|
||||
throw new Error(`Failed to load translations for ${languageCode}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous translation loader (requires pre-built translations)
|
||||
* For API server use (compiled at build time)
|
||||
*/
|
||||
export function loadTranslationsSync(languageCode: string): Translations {
|
||||
if (translationCache.has(languageCode)) {
|
||||
return translationCache.get(languageCode)!;
|
||||
}
|
||||
|
||||
// Get translations from static map
|
||||
const translations = translationsMap[languageCode];
|
||||
|
||||
if (translations) {
|
||||
translationCache.set(languageCode, translations);
|
||||
return translations;
|
||||
}
|
||||
|
||||
// Fallback to English if language not found
|
||||
if (languageCode !== 'en') {
|
||||
return loadTranslationsSync('en');
|
||||
}
|
||||
|
||||
throw new Error(`Failed to load translations for ${languageCode}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple template string replacement
|
||||
* Example: interpolate("Hello {name}", { name: "World" }) => "Hello World"
|
||||
*/
|
||||
export function interpolate(template: string, values: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key) => values[key] || `{${key}}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested translation value by dot notation path
|
||||
* Example: getTranslation(translations, "pages.unsubscribe.title")
|
||||
*/
|
||||
export function getTranslation(translations: Translations, path: string): string {
|
||||
const keys = path.split('.');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let value: any = translations;
|
||||
|
||||
for (const key of keys) {
|
||||
value = value?.[key];
|
||||
if (value === undefined) {
|
||||
console.warn(`Translation key not found: ${path}`);
|
||||
return path; // Return the key itself as fallback
|
||||
}
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translation helper class for easier use
|
||||
*/
|
||||
export class Translator {
|
||||
private translations: Translations;
|
||||
private languageCode: string;
|
||||
|
||||
constructor(translations: Translations, languageCode: string) {
|
||||
this.translations = translations;
|
||||
this.languageCode = languageCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key with optional interpolation
|
||||
* Example: t("pages.unsubscribe.description", { email: "[email protected]" })
|
||||
*/
|
||||
t(key: string, values?: Record<string, string>): string {
|
||||
const translation = getTranslation(this.translations, key);
|
||||
return values ? interpolate(translation, values) : translation;
|
||||
}
|
||||
|
||||
getLanguageCode(): string {
|
||||
return this.languageCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a translator instance for a given language (async)
|
||||
*/
|
||||
export async function createTranslator(languageCode: string): Promise<Translator> {
|
||||
const translations = await loadTranslations(languageCode);
|
||||
return new Translator(translations, languageCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a translator instance for a given language (sync)
|
||||
*/
|
||||
export function createTranslatorSync(languageCode: string): Translator {
|
||||
const translations = loadTranslationsSync(languageCode);
|
||||
return new Translator(translations, languageCode);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface Language {
|
||||
code: string;
|
||||
name: string;
|
||||
nativeName: string;
|
||||
flag: string;
|
||||
}
|
||||
|
||||
export const SUPPORTED_LANGUAGES: Language[] = [
|
||||
{code: 'en', name: 'English', nativeName: 'English', flag: '🇺🇸'},
|
||||
{code: 'nl', name: 'Dutch', nativeName: 'Nederlands', flag: '🇳🇱'},
|
||||
];
|
||||
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
export function isValidLanguageCode(code: string): boolean {
|
||||
return SUPPORTED_LANGUAGES.some(lang => lang.code === code);
|
||||
}
|
||||
|
||||
export function getLanguageByCode(code: string): Language | undefined {
|
||||
return SUPPORTED_LANGUAGES.find(lang => lang.code === code);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "Unsubscribe",
|
||||
"description": "We're sorry to see you go. Are you sure you want to unsubscribe {email} from receiving emails?",
|
||||
"button": "Unsubscribe",
|
||||
"buttonLoading": "Unsubscribing...",
|
||||
"managePreferences": "Manage preferences instead",
|
||||
"successTitle": "You're unsubscribed",
|
||||
"successDescription": "{email} has been unsubscribed. You won't receive any more emails from us.",
|
||||
"changedMind": "Changed your mind?",
|
||||
"subscribeAgain": "Subscribe again"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "Subscribe to updates",
|
||||
"description": "Would you like to subscribe {email} to receive emails?",
|
||||
"button": "Subscribe",
|
||||
"buttonLoading": "Subscribing...",
|
||||
"successTitle": "You're subscribed!",
|
||||
"successDescription": "{email} is now subscribed to receive emails from us."
|
||||
},
|
||||
"manage": {
|
||||
"title": "Manage Preferences",
|
||||
"description": "Manage email preferences for {email}",
|
||||
"subscriptionLabel": "Email Subscription",
|
||||
"subscribedStatus": "You are currently subscribed to receive emails",
|
||||
"unsubscribedStatus": "You are currently unsubscribed from emails",
|
||||
"subscribedSuccess": "Subscribed successfully!",
|
||||
"unsubscribedSuccess": "Unsubscribed successfully!",
|
||||
"unsubscribeCompletely": "Unsubscribe completely",
|
||||
"subscribeToEmails": "Subscribe to emails",
|
||||
"disclaimer": "This page allows you to manage your email preferences. Your subscription status is updated in real-time."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "You received this email because you agreed to receive emails from {projectName}. If you no longer wish to receive emails like this, please",
|
||||
"updatePreferences": "update your preferences"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "Afmelden",
|
||||
"description": "Het spijt ons dat je vertrekt. Weet je zeker dat je {email} wilt afmelden voor het ontvangen van e-mails?",
|
||||
"button": "Afmelden",
|
||||
"buttonLoading": "Bezig met afmelden...",
|
||||
"managePreferences": "Beheer in plaats daarvan voorkeuren",
|
||||
"successTitle": "Je bent afgemeld",
|
||||
"successDescription": "{email} is afgemeld. Je ontvangt geen e-mails meer van ons.",
|
||||
"changedMind": "Van gedachten veranderd?",
|
||||
"subscribeAgain": "Opnieuw aanmelden"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "Aanmelden voor updates",
|
||||
"description": "Wil je {email} aanmelden om e-mails te ontvangen?",
|
||||
"button": "Aanmelden",
|
||||
"buttonLoading": "Bezig met aanmelden...",
|
||||
"successTitle": "Je bent aangemeld!",
|
||||
"successDescription": "{email} is nu aangemeld om e-mails van ons te ontvangen."
|
||||
},
|
||||
"manage": {
|
||||
"title": "Voorkeuren beheren",
|
||||
"description": "Beheer e-mailvoorkeuren voor {email}",
|
||||
"subscriptionLabel": "E-mailabonnement",
|
||||
"subscribedStatus": "Je bent momenteel aangemeld om e-mails te ontvangen",
|
||||
"unsubscribedStatus": "Je bent momenteel afgemeld voor e-mails",
|
||||
"subscribedSuccess": "Succesvol aangemeld!",
|
||||
"unsubscribedSuccess": "Succesvol afgemeld!",
|
||||
"unsubscribeCompletely": "Volledig afmelden",
|
||||
"subscribeToEmails": "Aanmelden voor e-mails",
|
||||
"disclaimer": "Op deze pagina kun je je e-mailvoorkeuren beheren. Je abonnementsstatus wordt in realtime bijgewerkt."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Bezig met laden...",
|
||||
"error": "Fout"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "Je ontvangt deze e-mail omdat je hebt ingestemd met het ontvangen van e-mails van {projectName}. Als je deze e-mails niet meer wilt ontvangen,",
|
||||
"updatePreferences": "werk je voorkeuren bij"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './schemas/index.js';
|
||||
export * from './operators.js';
|
||||
export * from './template.js';
|
||||
export * from './i18n/index.js';
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ProjectSchemas = {
|
||||
update: z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
tracking: z.nativeEnum(TrackingMode).optional(),
|
||||
language: z.string().length(2).regex(/^[a-z]{2}$/).optional(),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user