From 4a4ec0a3a3b9eeca2a2d1ec63e9f473895f348d7 Mon Sep 17 00:00:00 2001 From: Michelle Date: Mon, 2 Mar 2026 18:55:38 +0100 Subject: [PATCH] feat(i18n): add German and English email templates for invitations and verifications --- server/config/emaili18n.js | 2 +- server/i18n/de.json | 39 +++++++++++++++++++++ server/i18n/en.json | 39 +++++++++++++++++++++ src/contexts/NotificationContext.jsx | 51 +++++++++++++++++++++++----- 4 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 server/i18n/de.json create mode 100644 server/i18n/en.json diff --git a/server/config/emaili18n.js b/server/config/emaili18n.js index d51a723..1d3b45e 100644 --- a/server/config/emaili18n.js +++ b/server/config/emaili18n.js @@ -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'); diff --git a/server/i18n/de.json b/server/i18n/de.json new file mode 100644 index 0000000..e898b27 --- /dev/null +++ b/server/i18n/de.json @@ -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." + } + } +} diff --git a/server/i18n/en.json b/server/i18n/en.json new file mode 100644 index 0000000..384f871 --- /dev/null +++ b/server/i18n/en.json @@ -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}." + } + } +} diff --git a/src/contexts/NotificationContext.jsx b/src/contexts/NotificationContext.jsx index fd8b300..01b07a1 100644 --- a/src/contexts/NotificationContext.jsx +++ b/src/contexts/NotificationContext.jsx @@ -5,16 +5,38 @@ import api from '../services/api'; // Lazily created Audio instance — reused across calls to avoid memory churn let _audio = null; +let _audioUnlocked = false; + +function getAudio() { + if (!_audio) { + _audio = new Audio('/sounds/notification.mp3'); + _audio.volume = 0.5; + } + 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 { - 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 + 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([]);