- 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() {
{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 || ''})}