feat(i18n): add German and English email templates for invitations and verifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s

This commit is contained in:
2026-03-02 18:55:38 +01:00
parent 9be3be7712
commit 4a4ec0a3a3
4 changed files with 122 additions and 9 deletions

View File

@@ -11,7 +11,7 @@ const cache = {};
function load(lang) {
if (cache[lang]) return cache[lang];
try {
cache[lang] = require(path.resolve(__dirname, '../../src/i18n', `${lang}.json`));
cache[lang] = require(path.resolve(__dirname, '../i18n', `${lang}.json`));
return cache[lang];
} catch {
if (lang !== 'en') return load('en');

39
server/i18n/de.json Normal file
View File

@@ -0,0 +1,39 @@
{
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "Einladung anzeigen",
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
"verify": {
"subject": "{appName} - E-Mail-Adresse bestätigen",
"intro": "Bitte bestätige deine E-Mail-Adresse, indem du auf den Button klickst:",
"button": "E-Mail bestätigen",
"validity": "Dieser Link ist 24 Stunden gültig.",
"footer": "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren."
},
"invite": {
"subject": "{appName} - Du wurdest eingeladen",
"title": "Du wurdest eingeladen! 🎉",
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
"prompt": "Klicke auf den Button, um dich zu registrieren:",
"button": "Konto erstellen",
"validity": "Dieser Link ist 7 Tage gültig.",
"footer": "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren."
},
"federationInvite": {
"subject": "{appName} - Meeting-Einladung von {fromUser}",
"intro": "Du hast eine Meeting-Einladung von {fromUser} erhalten.",
"roomLabel": "Raum:"
},
"calendarInvite": {
"subject": "{appName} - Kalendereinladung von {fromUser}",
"intro": "Du hast eine Kalendereinladung von {fromUser} erhalten."
},
"calendarDeleted": {
"subject": "{appName} - Kalendereintrag abgesagt: {title}",
"intro": "Der folgende Kalendereintrag wurde vom Organisator ({fromUser}) gelöscht und ist nicht mehr verfügbar:",
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
}
}
}

39
server/i18n/en.json Normal file
View File

@@ -0,0 +1,39 @@
{
"email": {
"greeting": "Hey {name} 👋",
"viewInvitation": "View Invitation",
"invitationFooter": "Open the link above to accept or decline the invitation.",
"linkHint": "Or copy this link in your browser:",
"verify": {
"subject": "{appName} - Verify your email",
"intro": "Please verify your email address by clicking the button below:",
"button": "Verify Email",
"validity": "This link is valid for 24 hours.",
"footer": "If you didn't register, please ignore this email."
},
"invite": {
"subject": "{appName} - You've been invited",
"title": "You've been invited! 🎉",
"intro": "You have been invited to create an account on {appName}.",
"prompt": "Click the button below to register:",
"button": "Create Account",
"validity": "This link is valid for 7 days.",
"footer": "If you didn't expect this invitation, you can safely ignore this email."
},
"federationInvite": {
"subject": "{appName} - Meeting invitation from {fromUser}",
"intro": "You have received a meeting invitation from {fromUser}.",
"roomLabel": "Room:"
},
"calendarInvite": {
"subject": "{appName} - Calendar invitation from {fromUser}",
"intro": "You have received a calendar invitation from {fromUser}."
},
"calendarDeleted": {
"subject": "{appName} - Calendar event cancelled: {title}",
"intro": "The following calendar event was deleted by the organiser ({fromUser}) and is no longer available:",
"note": "The event has been automatically removed from your calendar.",
"footer": "This message was sent automatically by {appName}."
}
}
}

View File

@@ -5,16 +5,38 @@ import api from '../services/api';
// Lazily created Audio instance — reused across calls to avoid memory churn
let _audio = null;
function playNotificationSound() {
try {
let _audioUnlocked = false;
function getAudio() {
if (!_audio) {
_audio = new Audio('/sounds/notification.mp3');
_audio.volume = 0.5;
}
// Reset to start so rapid arrivals always play from beginning
_audio.currentTime = 0;
_audio.play().catch(() => {
// Autoplay blocked (user hasn't interacted yet) or file missing — silent fail
return _audio;
}
/** Called once on the first user gesture to silently play→pause the element,
* which "unlocks" it so later timer-based .play() calls are not blocked. */
function unlockAudio() {
if (_audioUnlocked) return;
_audioUnlocked = true;
const audio = getAudio();
audio.muted = true;
audio.play().then(() => {
audio.pause();
audio.muted = false;
audio.currentTime = 0;
}).catch(() => {
audio.muted = false;
});
}
function playNotificationSound() {
try {
const audio = getAudio();
audio.currentTime = 0;
audio.play().catch(() => {
// Autoplay still blocked — silent fail
});
} catch {
// Ignore any other errors (e.g. unsupported format)
@@ -61,6 +83,19 @@ export function NotificationProvider({ children }) {
}
}, [user]);
// Unlock audio playback on the first real user interaction.
// Browsers block audio from timer callbacks unless the element was previously
// "touched" inside a gesture handler — this one-time listener does exactly that.
useEffect(() => {
const events = ['click', 'keydown', 'pointerdown'];
const handler = () => {
unlockAudio();
events.forEach(e => window.removeEventListener(e, handler));
};
events.forEach(e => window.addEventListener(e, handler, { once: true }));
return () => events.forEach(e => window.removeEventListener(e, handler));
}, []);
useEffect(() => {
if (!user) {
setNotifications([]);