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
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m29s
This commit is contained in:
@@ -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
39
server/i18n/de.json
Normal 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
39
server/i18n/en.json
Normal 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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user