feat: Add project-scoped language for unsubscribe footer and contact-facing pages

This commit is contained in:
Dries Augustyns
2025-12-21 13:09:21 +01:00
parent 8a136dde55
commit e1f826357d
15 changed files with 499 additions and 41 deletions
+157
View File
@@ -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);
}
+21
View File
@@ -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);
}
+45
View File
@@ -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"
}
}
}
+45
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export * from './schemas/index.js';
export * from './operators.js';
export * from './template.js';
export * from './i18n/index.js';
+1
View File
@@ -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;