From e1f826357d1e8cff7bd3c2811698734f578836f5 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Sun, 21 Dec 2025 13:09:21 +0100 Subject: [PATCH] feat: Add project-scoped language for unsubscribe footer and contact-facing pages --- apps/api/src/controllers/Contacts.ts | 4 + apps/api/src/services/ContactService.ts | 19 +++ apps/api/src/services/EmailService.ts | 19 ++- apps/web/src/pages/manage/[id].tsx | 69 ++++++-- apps/web/src/pages/settings/index.tsx | 37 ++++- apps/web/src/pages/subscribe/[id].tsx | 56 ++++++- apps/web/src/pages/unsubscribe/[id].tsx | 61 +++++-- .../migration.sql | 2 + packages/db/prisma/schema.prisma | 3 + packages/shared/src/i18n/index.ts | 157 ++++++++++++++++++ packages/shared/src/i18n/languages.ts | 21 +++ packages/shared/src/i18n/locales/en.json | 45 +++++ packages/shared/src/i18n/locales/nl.json | 45 +++++ packages/shared/src/index.ts | 1 + packages/shared/src/schemas/index.ts | 1 + 15 files changed, 499 insertions(+), 41 deletions(-) create mode 100644 packages/db/prisma/migrations/20251221114634_add_language_to_project/migration.sql create mode 100644 packages/shared/src/i18n/index.ts create mode 100644 packages/shared/src/i18n/languages.ts create mode 100644 packages/shared/src/i18n/locales/en.json create mode 100644 packages/shared/src/i18n/locales/nl.json diff --git a/apps/api/src/controllers/Contacts.ts b/apps/api/src/controllers/Contacts.ts index 305385f..afa9729 100644 --- a/apps/api/src/controllers/Contacts.ts +++ b/apps/api/src/controllers/Contacts.ts @@ -211,10 +211,14 @@ export class Contacts { const contact = await ContactService.getById(contactId); + // Fetch project to get language preference + const project = await ContactService.getProjectByContactId(contactId); + return res.status(200).json({ id: contact.id, email: contact.email, subscribed: contact.subscribed, + language: project?.language || 'en', }); } diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index 3e91ea8..e821fd5 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -339,6 +339,25 @@ export class ContactService { return contact; } + /** + * Get project by contact ID + * Used to fetch project settings for public endpoints + */ + public static async getProjectByContactId(contactId: string): Promise<{language: string} | null> { + const contact = await prisma.contact.findUnique({ + where: {id: contactId}, + select: { + project: { + select: { + language: true, + }, + }, + }, + }); + + return contact?.project || null; + } + /** * PUBLIC: Subscribe a contact */ diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 3d8cc8c..771b55b 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -5,7 +5,7 @@ import signale from 'signale'; import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; -import {renderTemplate} from '@plunk/shared'; +import {renderTemplate, createTranslatorSync} from '@plunk/shared'; import {BillingLimitService} from './BillingLimitService.js'; import {DomainService} from './DomainService.js'; @@ -603,19 +603,28 @@ export class EmailService { let html = content; const unsubscribeHtml = includeUnsubscribe - ? ` + ? (() => { + // Get translator for project's language + const translator = createTranslatorSync(project.language || 'en'); + const unsubscribeText = translator.t('email.footer.unsubscribeText', { + projectName: project.name, + }); + const updatePreferencesText = translator.t('email.footer.updatePreferences'); + + return `
-

- You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please - update your preferences. + ${unsubscribeText} + ${updatePreferencesText}.

` + `; + })() : ''; // Add Plunk badge if billing is enabled and project has no subscription (free tier) diff --git a/apps/web/src/pages/manage/[id].tsx b/apps/web/src/pages/manage/[id].tsx index 3f9cf1f..c21534c 100644 --- a/apps/web/src/pages/manage/[id].tsx +++ b/apps/web/src/pages/manage/[id].tsx @@ -1,4 +1,5 @@ import {Button, Card, CardContent} from '@plunk/ui'; +import {createTranslator, type Translator} from '@plunk/shared'; import {AnimatePresence, motion} from 'framer-motion'; import {useRouter} from 'next/router'; import React, {useEffect, useState} from 'react'; @@ -9,6 +10,7 @@ interface ContactInfo { id: string; email: string; subscribed: boolean; + language: string; } export default function Manage() { @@ -16,6 +18,7 @@ export default function Manage() { const {id} = router.query; const [contact, setContact] = useState(null); + const [translator, setTranslator] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [updating, setUpdating] = useState(false); @@ -29,6 +32,11 @@ export default function Manage() { setLoading(true); const data = await network.fetch('GET', `/contacts/public/${id}`); setContact(data); + + // Load translations for the project's language + const t = await createTranslator(data.language || 'en'); + setTranslator(t); + setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load contact information'); @@ -41,7 +49,7 @@ export default function Manage() { }, [id]); const handleToggleSubscription = async () => { - if (!id || typeof id !== 'string' || !contact) return; + if (!id || typeof id !== 'string' || !contact || !translator) return; try { setUpdating(true); @@ -51,7 +59,11 @@ export default function Manage() { const data = await network.fetch('POST', endpoint); setContact(data); - setSaveMessage(data.subscribed ? 'Subscribed successfully!' : 'Unsubscribed successfully!'); + setSaveMessage( + data.subscribed + ? translator.t('pages.manage.subscribedSuccess') + : translator.t('pages.manage.unsubscribedSuccess'), + ); setError(null); // Clear success message after 3 seconds @@ -63,7 +75,8 @@ export default function Manage() { } }; - if (loading) { + // Don't render until translations are loaded + if (!translator) { return (
@@ -92,6 +105,35 @@ export default function Manage() { ); } + if (loading) { + return ( +
+
+ + +
+ + + + +

{translator.t('pages.common.loading')}

+
+
+
+
+
+ ); + } + if (error && !contact) { return (
@@ -112,7 +154,7 @@ export default function Manage() {
-

Error

+

{translator.t('pages.common.error')}

{error}

@@ -129,20 +171,20 @@ export default function Manage() {
-

Manage Preferences

+

{translator.t('pages.manage.title')}

- Manage email preferences for {contact?.email} + {translator.t('pages.manage.description', {email: contact?.email || ''})}

-

Email Subscription

+

{translator.t('pages.manage.subscriptionLabel')}

{contact?.subscribed - ? 'You are currently subscribed to receive emails' - : 'You are currently unsubscribed from emails'} + ? translator.t('pages.manage.subscribedStatus') + : translator.t('pages.manage.unsubscribedStatus')}

) : ( )}
-

- This page allows you to manage your email preferences. Your subscription status is updated in - real-time. -

+

{translator.t('pages.manage.disclaimer')}

diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx index 1d812bc..72f0dfe 100644 --- a/apps/web/src/pages/settings/index.tsx +++ b/apps/web/src/pages/settings/index.tsx @@ -1,7 +1,7 @@ import {useEffect, useState} from 'react'; import {useForm} from 'react-hook-form'; import {zodResolver} from '@hookform/resolvers/zod'; -import {ProjectSchemas} from '@plunk/shared'; +import {ProjectSchemas, SUPPORTED_LANGUAGES} from '@plunk/shared'; import {TrackingMode} from '@plunk/db'; import { Alert, @@ -181,6 +181,7 @@ export default function Settings() { defaultValues: { name: activeProject?.name || '', tracking: activeProject?.tracking ?? TrackingMode.ENABLED, + language: activeProject?.language || 'en', }, }); @@ -190,6 +191,7 @@ export default function Settings() { form.reset({ name: activeProject.name, tracking: activeProject.tracking ?? TrackingMode.ENABLED, + language: activeProject.language || 'en', }); } }, [activeProject, form]); @@ -464,6 +466,39 @@ export default function Settings() { /> )} + {/* Language Selection */} + ( + + Customer Language + + + Language for customer-facing pages (unsubscribe, preferences) and email footers. + + + + )} + /> +
diff --git a/apps/web/src/pages/unsubscribe/[id].tsx b/apps/web/src/pages/unsubscribe/[id].tsx index 00e13cc..7efe6e3 100644 --- a/apps/web/src/pages/unsubscribe/[id].tsx +++ b/apps/web/src/pages/unsubscribe/[id].tsx @@ -1,4 +1,5 @@ import {Button, Card, CardContent} from '@plunk/ui'; +import {createTranslator, type Translator} from '@plunk/shared'; import {AnimatePresence, motion} from 'framer-motion'; import {useRouter} from 'next/router'; import React, {useEffect, useState} from 'react'; @@ -9,6 +10,7 @@ interface ContactInfo { id: string; email: string; subscribed: boolean; + language: string; } export default function Unsubscribe() { @@ -16,6 +18,7 @@ export default function Unsubscribe() { const {id} = router.query; const [contact, setContact] = useState(null); + const [translator, setTranslator] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [unsubscribing, setUnsubscribing] = useState(false); @@ -29,6 +32,11 @@ export default function Unsubscribe() { setLoading(true); const data = await network.fetch('GET', `/contacts/public/${id}`); setContact(data); + + // Load translations for the project's language + const t = await createTranslator(data.language || 'en'); + setTranslator(t); + setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load contact information'); @@ -56,7 +64,8 @@ export default function Unsubscribe() { } }; - if (loading) { + // Don't render until translations are loaded + if (!translator) { return (
@@ -85,6 +94,35 @@ export default function Unsubscribe() { ); } + if (loading) { + return ( +
+
+ + +
+ + + + +

{translator.t('pages.common.loading')}

+
+
+
+
+
+ ); + } + if (error && !contact) { return (
@@ -105,7 +143,7 @@ export default function Unsubscribe() {
-

Error

+

{translator.t('pages.common.error')}

{error}

@@ -140,17 +178,17 @@ export default function Unsubscribe() { -

You're unsubscribed

+

{translator.t('pages.unsubscribe.successTitle')}

- {contact?.email} has been unsubscribed. You won't receive any more emails from us. + {translator.t('pages.unsubscribe.successDescription', {email: contact?.email || ''})}

- Changed your mind?{' '} + {translator.t('pages.unsubscribe.changedMind')}{' '}

@@ -168,10 +206,9 @@ export default function Unsubscribe() {
-

Unsubscribe

+

{translator.t('pages.unsubscribe.title')}

- We're sorry to see you go. Are you sure you want to unsubscribe {contact?.email}{' '} - from receiving emails? + {translator.t('pages.unsubscribe.description', {email: contact?.email || ''})}

@@ -210,14 +247,14 @@ export default function Unsubscribe() { d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> - Unsubscribing... + {translator.t('pages.unsubscribe.buttonLoading')}
) : ( - 'Unsubscribe' + translator.t('pages.unsubscribe.button') )}
diff --git a/packages/db/prisma/migrations/20251221114634_add_language_to_project/migration.sql b/packages/db/prisma/migrations/20251221114634_add_language_to_project/migration.sql new file mode 100644 index 0000000..d527892 --- /dev/null +++ b/packages/db/prisma/migrations/20251221114634_add_language_to_project/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "projects" ADD COLUMN "language" TEXT NOT NULL DEFAULT 'en'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 913ac33..f49b2c2 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -57,6 +57,9 @@ model Project { // Email Tracking tracking TrackingMode @default(ENABLED) // Open and click tracking mode + // Localization + language String @default("en") // Language code for customer-facing content (ISO 639-1) + // Relations members Membership[] contacts Contact[] diff --git a/packages/shared/src/i18n/index.ts b/packages/shared/src/i18n/index.ts new file mode 100644 index 0000000..63d057c --- /dev/null +++ b/packages/shared/src/i18n/index.ts @@ -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; + subscribe: Record; + manage: Record; + common: Record; + }; + email: { + footer: Record; + }; +} + +// Static mapping of language codes to translations +const translationsMap: Record = { + en: enTranslations, + nl: nlTranslations, +}; + +// In-memory cache for loaded translations +const translationCache = new Map(); + +/** + * Load translations for a given language code (async) + * Uses static imports for reliable resolution + */ +export async function loadTranslations(languageCode: string): Promise { + // 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 { + 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: "user@example.com" }) + */ + t(key: string, values?: Record): 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 { + 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); +} diff --git a/packages/shared/src/i18n/languages.ts b/packages/shared/src/i18n/languages.ts new file mode 100644 index 0000000..2a8d73f --- /dev/null +++ b/packages/shared/src/i18n/languages.ts @@ -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); +} diff --git a/packages/shared/src/i18n/locales/en.json b/packages/shared/src/i18n/locales/en.json new file mode 100644 index 0000000..65f7949 --- /dev/null +++ b/packages/shared/src/i18n/locales/en.json @@ -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" + } + } +} diff --git a/packages/shared/src/i18n/locales/nl.json b/packages/shared/src/i18n/locales/nl.json new file mode 100644 index 0000000..fb7f602 --- /dev/null +++ b/packages/shared/src/i18n/locales/nl.json @@ -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" + } + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e42acf1..a0d74fb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './schemas/index.js'; export * from './operators.js'; export * from './template.js'; +export * from './i18n/index.js'; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 22e4089..117c020 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -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;