feat(i18n): add internationalization support with English and German translations

- Integrated i18next for language detection and translation management.
- Added translation files for English and German languages.
- Implemented translation keys for notifications, reminders, and common UI elements.
- Updated main application entry point to include i18n initialization.
- Styled language selection dropdown in settings.
- Enhanced package dependencies to include i18next and react-i18next.
This commit is contained in:
Daniel Volz
2025-12-22 10:55:53 +01:00
parent f1ee8e6fdf
commit fc7852bafe
13 changed files with 1242 additions and 257 deletions
+169
View File
@@ -0,0 +1,169 @@
// Backend translations for notifications
export type Language = "en" | "de";
type TranslationKeys = {
// Stock reminder email
stockReminder: {
subject: string;
title: string;
description: string;
alertSingle: string;
alertMultiple: string;
tableHeaders: {
medication: string;
pills: string;
days: string;
runsOut: string;
};
footer: string;
};
// Intake reminder email
intakeReminder: {
subject: string;
title: string;
description: string;
alertSingle: string;
alertMultiple: string;
tableHeaders: {
medication: string;
dosage: string;
time: string;
};
pills: string;
footer: string;
};
// Push notifications
push: {
stockTitle: string;
stockTitleMultiple: string;
intakeTitle: string;
pillsLeft: string;
daysLeft: string;
pillsAt: string;
};
// Common
common: {
pill: string;
pills: string;
day: string;
days: string;
soon: string;
};
};
const translations: Record<Language, TranslationKeys> = {
en: {
stockReminder: {
subject: "MedAssist Auto-Reminder: {count} Medication{s} Running Low",
title: "⚠️ MedAssist - Automatic Reorder Reminder",
description: "The following medications are running low and need to be reordered:",
alertSingle: "⚠️ 1 medication running low!",
alertMultiple: "⚠️ {count} medications running low!",
tableHeaders: {
medication: "Medication",
pills: "Pills",
days: "Days",
runsOut: "Runs Out",
},
footer: "🤖 Automatic reminder from MedAssist",
},
intakeReminder: {
subject: "MedAssist: Medication Reminder - {medications}",
title: "💊 MedAssist - Intake Reminder",
description: "Time to take your medication in {minutes} minutes:",
alertSingle: "💊 1 medication scheduled",
alertMultiple: "💊 {count} medications scheduled",
tableHeaders: {
medication: "Medication",
dosage: "Dosage",
time: "Time",
},
pills: "pills",
footer: "MedAssist Medication Planner",
},
push: {
stockTitle: "MedAssist: 1 Medication Running Low",
stockTitleMultiple: "MedAssist: {count} Medications Running Low",
intakeTitle: "Medication Reminder in {minutes} min",
pillsLeft: "{count} pills",
daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}",
},
common: {
pill: "pill",
pills: "pills",
day: "day",
days: "days",
soon: "soon",
},
},
de: {
stockReminder: {
subject: "MedAssist Auto-Erinnerung: {count} Medikament{e} wird knapp",
title: "⚠️ MedAssist - Automatische Nachbestell-Erinnerung",
description: "Die folgenden Medikamente gehen zur Neige und sollten nachbestellt werden:",
alertSingle: "⚠️ 1 Medikament wird knapp!",
alertMultiple: "⚠️ {count} Medikamente werden knapp!",
tableHeaders: {
medication: "Medikament",
pills: "Tabletten",
days: "Tage",
runsOut: "Aufgebraucht",
},
footer: "🤖 Automatische Erinnerung von MedAssist",
},
intakeReminder: {
subject: "MedAssist: Einnahme-Erinnerung - {medications}",
title: "💊 MedAssist - Einnahme-Erinnerung",
description: "Zeit für Ihre Medikamente in {minutes} Minuten:",
alertSingle: "💊 1 Medikament geplant",
alertMultiple: "💊 {count} Medikamente geplant",
tableHeaders: {
medication: "Medikament",
dosage: "Dosis",
time: "Uhrzeit",
},
pills: "Tabletten",
footer: "MedAssist Medikamentenplaner",
},
push: {
stockTitle: "MedAssist: 1 Medikament wird knapp",
stockTitleMultiple: "MedAssist: {count} Medikamente werden knapp",
intakeTitle: "Einnahme-Erinnerung in {minutes} Min.",
pillsLeft: "{count} Tabletten",
daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}",
},
common: {
pill: "Tablette",
pills: "Tabletten",
day: "Tag",
days: "Tage",
soon: "bald",
},
},
};
export function getTranslations(language: Language): TranslationKeys {
return translations[language] || translations.en;
}
// Helper function to replace placeholders in strings
export function t(template: string, params: Record<string, string | number> = {}): string {
let result = template;
for (const [key, value] of Object.entries(params)) {
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
}
return result;
}
// Get date locale for toLocaleDateString
export function getDateLocale(language: Language): string {
switch (language) {
case "de":
return "de-DE";
case "en":
default:
return "en-US";
}
}
+14 -1
View File
@@ -3,6 +3,7 @@ import nodemailer from "nodemailer";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { getReminderState } from "../services/reminder-scheduler.js";
import type { Language } from "../i18n/translations.js";
type SettingsBody = {
emailEnabled: boolean;
@@ -19,6 +20,8 @@ type SettingsBody = {
emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
// Language setting
language: Language;
};
type TestEmailBody = {
@@ -48,6 +51,8 @@ type NotificationSettings = {
emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
// Language setting
language: Language;
};
function loadNotificationSettings(): NotificationSettings {
@@ -69,6 +74,8 @@ function loadNotificationSettings(): NotificationSettings {
emailIntakeReminders: saved.emailIntakeReminders ?? true,
shoutrrrStockReminders: saved.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: saved.shoutrrrIntakeReminders ?? true,
// Language setting (default to English)
language: saved.language ?? "en",
};
}
} catch {
@@ -88,6 +95,7 @@ function loadNotificationSettings(): NotificationSettings {
emailIntakeReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
language: "en",
};
}
@@ -120,6 +128,8 @@ export async function settingsRoutes(app: FastifyInstance) {
emailIntakeReminders: notification.emailIntakeReminders,
shoutrrrStockReminders: notification.shoutrrrStockReminders,
shoutrrrIntakeReminders: notification.shoutrrrIntakeReminders,
// Language setting
language: notification.language,
// SMTP settings (admin-configured, from .env)
smtpHost: process.env.SMTP_HOST ?? "",
smtpPort: parseInt(process.env.SMTP_PORT ?? "587"),
@@ -153,6 +163,8 @@ export async function settingsRoutes(app: FastifyInstance) {
emailIntakeReminders: body.emailIntakeReminders ?? true,
shoutrrrStockReminders: body.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true,
// Language setting
language: body.language ?? "en",
});
return reply.send({ success: true });
@@ -240,7 +252,8 @@ export async function sendShoutrrrNotification(urlStr: string, title: string, me
let body: string | undefined;
// Remove emojis from title for header compatibility (ntfy doesn't support unicode in headers)
const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|/gu, "").trim();
// Match common emojis, pictographs, symbols, and variation selectors
const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{2000}-\u{206F}]||/gu, "").trim();
// Handle different URL formats
if (urlStr.startsWith("ntfy://")) {
@@ -4,6 +4,7 @@ import { medications } from "../db/schema.js";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { loadNotificationSettings, sendShoutrrrNotification } from "../routes/settings.js";
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
type Slice = { usage: number; every: number; start: string };
@@ -107,7 +108,7 @@ function getUpcomingIntakes(medName: string, slices: Slice[], minutesBefore: num
return upcoming;
}
async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[]): Promise<{ success: boolean; error?: string }> {
async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[], language: Language): Promise<{ success: boolean; error?: string }> {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
@@ -119,36 +120,41 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[])
return { success: false, error: "SMTP not configured" };
}
const tr = getTranslations(language);
const tableRows = intakes
.map(
(intake) => `
<tr>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb;">${intake.medName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;"><strong>${intake.usage}</strong> pills</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;"><strong>${intake.usage}</strong> ${tr.intakeReminder.pills}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">${intake.intakeTimeStr}</td>
</tr>
`
)
.join("");
const alertText = intakes.length === 1
? tr.intakeReminder.alertSingle
: t(tr.intakeReminder.alertMultiple, { count: intakes.length });
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">💊 MedAssist - Intake Reminder</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">Time to take your medication in ${REMINDER_MINUTES_BEFORE} minutes:</p>
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.intakeReminder.title}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #eff6ff; border: 1px solid #bfdbfe;">
<p style="margin: 0; color: #1e40af; font-weight: 500; font-size: 13px;">
💊 ${intakes.length} medication${intakes.length > 1 ? "s" : ""} scheduled
${alertText}
</p>
</div>
<table style="width: 100%; border-collapse: collapse; background: white;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280;">Dosage</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280;">Time</th>
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280;">${tr.intakeReminder.tableHeaders.medication}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280;">${tr.intakeReminder.tableHeaders.dosage}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280;">${tr.intakeReminder.tableHeaders.time}</th>
</tr>
</thead>
<tbody>
@@ -158,20 +164,22 @@ async function sendIntakeReminderEmail(email: string, intakes: UpcomingIntake[])
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
MedAssist Medication Planner
${tr.intakeReminder.footer}
</p>
</div>
</div>
`;
const plainText = `MedAssist - Intake Reminder
const plainText = `${tr.intakeReminder.title}
Time to take your medication in ${REMINDER_MINUTES_BEFORE} minutes:
${t(tr.intakeReminder.description, { minutes: REMINDER_MINUTES_BEFORE })}
${intakes.map((i) => `${i.medName}: ${i.usage} pills at ${i.intakeTimeStr}`).join("\n")}
${intakes.map((i) => `${i.medName}: ${i.usage} ${tr.intakeReminder.pills} ${tr.intakeReminder.tableHeaders.time.toLowerCase()}: ${i.intakeTimeStr}`).join("\n")}
---
MedAssist Medication Planner`;
${tr.intakeReminder.footer}`;
const subject = t(tr.intakeReminder.subject, { medications: intakes.map(i => i.medName).join(", ") });
try {
const transporter = nodemailer.createTransport({
@@ -187,7 +195,7 @@ MedAssist Medication Planner`;
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `💊 MedAssist: Medication Reminder - ${intakes.map(i => i.medName).join(", ")}`,
subject: `💊 ${subject}`,
text: plainText,
html,
});
@@ -201,6 +209,8 @@ MedAssist Medication Planner`;
async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
const settings = loadNotificationSettings();
const language = settings.language;
const tr = getTranslations(language);
// Check if any intake reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
@@ -249,7 +259,7 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void
// Send email if enabled for intake reminders
if (emailEnabled) {
const result = await sendIntakeReminderEmail(settings.notificationEmail, newReminders);
const result = await sendIntakeReminderEmail(settings.notificationEmail, newReminders, language);
emailSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] Email sent successfully`);
@@ -260,9 +270,9 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void
// Send Shoutrrr notification if enabled for intake reminders
if (shoutrrrEnabled) {
const title = `Medication Reminder in ${REMINDER_MINUTES_BEFORE} min`;
const title = t(tr.push.intakeTitle, { minutes: REMINDER_MINUTES_BEFORE });
const message = newReminders
.map((i) => `- ${i.medName}: ${i.usage} pills at ${i.intakeTimeStr}`)
.map((i) => `- ${i.medName}: ${t(tr.push.pillsAt, { count: i.usage, time: i.intakeTimeStr })}`)
.join("\n");
const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message);
+37 -22
View File
@@ -4,6 +4,7 @@ import { medications } from "../db/schema.js";
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve } from "path";
import { loadNotificationSettings, sendShoutrrrNotification } from "../routes/settings.js";
import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js";
type Slice = { usage: number; every: number; start: string };
@@ -22,6 +23,8 @@ type NotificationSettings = {
emailIntakeReminders: boolean;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
// Language setting
language: Language;
};
type ReminderState = {
@@ -201,13 +204,13 @@ function calculateDailyUsage(slices: Slice[]): number {
return slices.reduce((sum, s) => sum + s.usage / s.every, 0);
}
function calculateDepletionInfo(med: { count: number; slices: Slice[] }): { daysLeft: number | null; depletionDate: string | null } {
function calculateDepletionInfo(med: { count: number; slices: Slice[] }, language: Language): { daysLeft: number | null; depletionDate: string | null } {
const dailyUsage = calculateDailyUsage(med.slices);
if (dailyUsage <= 0) return { daysLeft: null, depletionDate: null };
const daysLeft = Math.floor(med.count / dailyUsage);
const depletionMs = Date.now() + daysLeft * 86_400_000;
const depletionDate = new Date(depletionMs).toLocaleDateString("en-US", {
const depletionDate = new Date(depletionMs).toLocaleDateString(getDateLocale(language), {
weekday: "short",
day: "2-digit",
month: "short",
@@ -223,14 +226,14 @@ type LowStockItem = {
depletionDate: string | null;
};
async function getMedicationsNeedingReminder(reminderDaysBefore: number): Promise<LowStockItem[]> {
async function getMedicationsNeedingReminder(reminderDaysBefore: number, language: Language): Promise<LowStockItem[]> {
const rows = await db.select().from(medications).orderBy(medications.id);
const lowStock: LowStockItem[] = [];
for (const row of rows) {
const slices = parseSlices(row);
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: row.count, slices });
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: row.count, slices }, language);
// Check if medication runs out within reminderDaysBefore days
if (daysLeft !== null && daysLeft <= reminderDaysBefore) {
@@ -246,7 +249,7 @@ async function getMedicationsNeedingReminder(reminderDaysBefore: number): Promis
return lowStock;
}
async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promise<{ success: boolean; error?: string }> {
async function sendReminderEmail(email: string, lowStock: LowStockItem[], language: Language): Promise<{ success: boolean; error?: string }> {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
@@ -258,6 +261,7 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promi
return { success: false, error: "SMTP not configured" };
}
const tr = getTranslations(language);
const tableRows = lowStock
.map(
(row) => `
@@ -271,15 +275,19 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promi
)
.join("");
const alertText = lowStock.length === 1
? tr.stockReminder.alertSingle
: t(tr.stockReminder.alertMultiple, { count: lowStock.length });
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">⚠️ MedAssist - Automatic Reorder Reminder</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">The following medications are running low and need to be reordered:</p>
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${tr.stockReminder.title}</h2>
<p style="color: #6b7280; margin: 0 0 16px; font-size: 13px;">${tr.stockReminder.description}</p>
<div style="padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; background: #fef2f2; border: 1px solid #fecaca;">
<p style="margin: 0; color: #991b1b; font-weight: 500; font-size: 13px;">
⚠️ ${lowStock.length} medication${lowStock.length > 1 ? "s" : ""} running low!
${alertText}
</p>
</div>
@@ -287,10 +295,10 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promi
<table style="width: 100%; border-collapse: collapse; background: white; min-width: 400px;">
<thead>
<tr style="background: #f3f4f6;">
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Medication</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Pills</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Days</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">Runs Out</th>
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.medication}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.pills}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.days}</th>
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; white-space: nowrap;">${tr.stockReminder.tableHeaders.runsOut}</th>
</tr>
</thead>
<tbody>
@@ -301,20 +309,23 @@ async function sendReminderEmail(email: string, lowStock: LowStockItem[]): Promi
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
🤖 Automatic reminder from MedAssist
${tr.stockReminder.footer}
</p>
</div>
</div>
`;
const plainText = `MedAssist - Automatic Reorder Reminder
const plainText = `${tr.stockReminder.title}
The following medications are running low:
${tr.stockReminder.description}
${lowStock.map((r) => `${r.name}: ${r.medsLeft} pills left, ${r.daysLeft ?? 0} days remaining, runs out ${r.depletionDate ?? "soon"}`).join("\n")}
${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
---
Automatic reminder from MedAssist`;
${tr.stockReminder.footer}`;
const subjectPlural = lowStock.length === 1 ? "" : (language === "de" ? "e" : "s");
const subject = t(tr.stockReminder.subject, { count: lowStock.length, s: subjectPlural, e: subjectPlural });
try {
const transporter = nodemailer.createTransport({
@@ -330,7 +341,7 @@ Automatic reminder from MedAssist`;
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `⚠️ MedAssist Auto-Reminder: ${lowStock.length} Medication${lowStock.length > 1 ? "s" : ""} Running Low`,
subject: `⚠️ ${subject}`,
text: plainText,
html,
});
@@ -344,6 +355,8 @@ Automatic reminder from MedAssist`;
async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise<void> {
const settings = loadNotificationSettings();
const language = settings.language;
const tr = getTranslations(language);
// Check if any stock reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailStockReminders;
@@ -358,7 +371,7 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone
// Get all medications that need a reminder
const allLowStock = await getMedicationsNeedingReminder(settings.reminderDaysBefore);
const allLowStock = await getMedicationsNeedingReminder(settings.reminderDaysBefore, language);
if (allLowStock.length === 0) {
// No low stock - clear the notified list (medications have been restocked)
@@ -408,7 +421,7 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
// Send email if enabled
if (emailEnabled) {
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify);
const result = await sendReminderEmail(settings.notificationEmail, medsToNotify, language);
emailSuccess = result.success;
if (result.success) {
logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`);
@@ -419,9 +432,11 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error
// Send Shoutrrr notification if enabled
if (shoutrrrEnabled) {
const title = `⚠️ MedAssist: ${medsToNotify.length} Medication${medsToNotify.length > 1 ? "s" : ""} Running Low`;
const title = medsToNotify.length === 1
? tr.push.stockTitle
: t(tr.push.stockTitleMultiple, { count: medsToNotify.length });
const message = medsToNotify
.map((m) => `${m.name}: ${m.medsLeft} pills, ${m.daysLeft ?? 0} days left`)
.map((m) => `${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`)
.join("\n");
const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message);
+99 -1
View File
@@ -8,8 +8,11 @@
"name": "medassist-frontend",
"version": "0.1.0",
"dependencies": {
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.11.0",
"zod": "^3.23.8"
},
@@ -257,6 +260,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -1457,6 +1469,56 @@
"node": ">=6.9.0"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "24.2.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz",
"integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.26.10"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1621,6 +1683,32 @@
"react": "^18.3.1"
}
},
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.4.0",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -1767,8 +1855,9 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -1884,6 +1973,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+3
View File
@@ -10,8 +10,11 @@
"lint": "echo 'add lint config'"
},
"dependencies": {
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.11.0",
"zod": "^3.23.8"
},
+256 -215
View File
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
{
"nav": {
"dashboard": "Übersicht",
"medications": "Medikamente",
"planner": "Planer",
"settings": "Einstellungen",
"schedule": "Zeitplan"
},
"header": {
"eyebrow": {
"overview": "MedAssist · Übersicht",
"inventory": "MedAssist · Inventar",
"planner": "MedAssist · Planer",
"settings": "MedAssist · Einstellungen",
"schedule": "MedAssist · Zeitplan"
}
},
"dashboard": {
"reorder": {
"title": "Nachbestell-Erinnerung",
"badge": "Bestandsüberwachung",
"noMeds": "Noch keine Medikamente konfiguriert.",
"allGood": "Alles in Ordnung, genug Vorrat.",
"sendReminder": "🔔 Erinnerung jetzt senden"
},
"overview": {
"title": "Medikamentenübersicht",
"badge": "Bestand"
},
"schedules": {
"title": "Kommende Einnahmen",
"1month": "1 Monat",
"3months": "3 Monate",
"6months": "6 Monate"
},
"reminders": {
"active": "Automatische Erinnerungen aktiv",
"allStockOk": "Bestand OK",
"allOk": "Alles OK",
"lastReminder": "Letzte Erinnerung",
"nextIn": "Nächste",
"inDays": "in {{days}} Tagen",
"noRemindersNeeded": "keine Erinnerungen nötig",
"needReorder": "{{count}} Medikament nachbestellen",
"needReorder_other": "{{count}} Medikamente nachbestellen",
"waitingFirstCheck": "warte auf erste Prüfung"
}
},
"table": {
"name": "Name",
"pills": "Tabletten",
"days": "Tage",
"currentPills": "Aktuelle Tabletten",
"daysLeft": "Tage übrig",
"status": "Bestand",
"runsOut": "Aufgebraucht",
"autoRemind": "Auto-Erinnerung",
"expiry": "Ablaufdatum"
},
"medications": {
"list": {
"title": "Medikamentenliste",
"entries": "{{count}} Einträge",
"entries_one": "{{count}} Eintrag",
"entries_other": "{{count}} Einträge"
},
"details": {
"packs": "Packungen",
"blisters": "Blister pro Packung",
"pillsPerBlister": "Tabletten pro Blister",
"loose": "Lose",
"total": "Gesamt"
}
},
"form": {
"editEntry": "Eintrag bearbeiten",
"newEntry": "Neuer Eintrag",
"badge": "Packungen + lose Tabletten",
"commercialName": "Handelsname",
"genericName": "Wirkstoff",
"takenBy": "Eingenommen von",
"packs": "Packungen",
"blistersPerPack": "Blister pro Packung",
"pillsPerBlister": "Tabletten pro Blister",
"loosePills": "Lose Tabletten",
"pillWeight": "Tablettengewicht (mg)",
"total": "Gesamt (Tabletten)",
"expiryDate": "Ablaufdatum",
"notes": "Notizen",
"medicationImage": "Medikamentenbild",
"removeImage": "Bild entfernen",
"placeholders": {
"commercial": "z.B. Ozempic",
"generic": "z.B. Semaglutid (optional)",
"takenBy": "z.B. Max, Anna (optional)",
"weight": "z.B. 240",
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
},
"slices": {
"title": "Einnahmeplan",
"remind": "Erinnern",
"remindTooltip": "Erhalte eine Benachrichtigung 15 Minuten vor jeder geplanten Einnahme",
"addIntake": "Einnahme",
"usage": "Dosis (Tabletten)",
"everyDays": "Alle (Tage)",
"every": "alle",
"from": "ab",
"start": "Start (Datum/Uhrzeit)"
}
},
"planner": {
"title": "Bedarfsrechner",
"badge": "Vorrat planen",
"from": "Von",
"until": "Bis",
"calculate": "Berechnen",
"calculating": "Wird berechnet...",
"sendEmail": "📧 Per E-Mail senden",
"table": {
"medication": "Medikament",
"usage": "Verbrauch",
"blistersNeeded": "Blister benötigt",
"blisters": "Blister",
"available": "Verfügbar"
}
},
"settings": {
"loading": "Einstellungen werden geladen...",
"language": {
"title": "Sprache",
"select": "Sprache auswählen"
},
"notifications": {
"title": "Benachrichtigungen",
"channels": "Kanäle",
"email": "E-Mail",
"push": "Push",
"stockReminders": "Bestands-Erinnerungen",
"intakeReminders": "Einnahme-Erinnerungen",
"enableHint": "Aktivieren Sie mindestens einen Kanal, um Benachrichtigungen zu erhalten."
},
"email": {
"recipient": "Empfänger",
"notConfigured": "Nicht konfiguriert"
},
"push": {
"url": "URL",
"supports": "Unterstützt ntfy, Discord, Telegram, Slack"
},
"schedule": {
"stockCheck": "Bestandsprüfung",
"dailyAt6": "Täglich um 6:00 Uhr",
"intakeCheck": "Einnahmeprüfung",
"15minBefore": "15 Min. vor geplanter Zeit",
"nextCheck": "Nächste Bestandsprüfung",
"lastSent": "Zuletzt gesendet"
},
"stock": {
"title": "Bestand",
"threshold": "Erinnerungsschwelle",
"remindWhen": "Erinnern wenn Vorrat unter",
"repeatDaily": "Täglich wiederholen",
"repeatTooltip": "Wenn aktiviert, wird täglich eine Erinnerung gesendet solange der Bestand niedrig ist. Andernfalls nur einmal pro Medikament bis zum Auffüllen.",
"display": "Anzeige",
"lowStockDays": "Niedriger Bestand (Tage)",
"lowStockTooltip": "Gelbe Warnung ab diesem Schwellenwert",
"highStockDays": "Hoher Bestand (Tage)",
"highStockTooltip": "Grün mit Stern ab diesem Schwellenwert"
},
"saveSettings": "Einstellungen speichern"
},
"modal": {
"for": "für",
"at": "um",
"stockInfo": "Bestandsinformationen",
"totalPills": "Tabletten gesamt",
"packs": "Packungen",
"blistersPerPack": "Blister/Packung",
"pillsPerBlister": "Tabletten/Blister",
"loosePills": "Lose Tabletten",
"pillWeight": "Tablettengewicht",
"expiryDate": "Ablaufdatum",
"intakeSchedule": "Einnahmeplan",
"coverageStatus": "Reichweite",
"daysLeft": "Tage übrig",
"runsOut": "Aufgebraucht",
"notes": "Notizen",
"exportCalendar": "In Kalender exportieren",
"exportTooltip": "Zeitplan in Kalender exportieren",
"editMedication": "Medikament bearbeiten",
"userMedications": "Medikamente von {{name}}",
"noMedsForUser": "Keine Medikamente für {{name}} gefunden"
},
"status": {
"outOfStock": "Leer",
"lowStock": "Niedrig",
"normal": "Normal",
"highStock": "Hoch",
"noSchedule": "Kein Zeitplan",
"enough": "Ausreichend",
"noPillsLeft": "⚠ Keine Tabletten mehr",
"stockOk": "✓ Bestand OK"
},
"tooltips": {
"intakeReminders": "Einnahme-Erinnerungen aktiviert",
"hasNotes": "Hat Notizen",
"lightMode": "Zum hellen Modus wechseln",
"darkMode": "Zum dunklen Modus wechseln"
},
"dose": {
"takenBy": "eingenommen von",
"markAsTaken": "Als eingenommen markieren"
},
"common": {
"loading": "Wird geladen...",
"sending": "Wird gesendet...",
"saving": "Wird gespeichert...",
"saved": "Gespeichert ✓",
"save": "Speichern",
"cancel": "Abbrechen",
"close": "Schließen",
"edit": "Bearbeiten",
"delete": "Löschen",
"remove": "Entfernen",
"reset": "Zurücksetzen",
"test": "Test",
"undo": "Rückgängig",
"optional": "optional",
"pill": "Tablette",
"pills": "Tabletten",
"day": "Tag",
"days": "Tage",
"blisters": "Blister",
"total": "gesamt"
}
}
+236
View File
@@ -0,0 +1,236 @@
{
"nav": {
"dashboard": "Dashboard",
"medications": "Medications",
"planner": "Planner",
"settings": "Settings",
"schedule": "Schedule"
},
"header": {
"eyebrow": {
"overview": "MedAssist · Overview",
"inventory": "MedAssist · Inventory",
"planner": "MedAssist · Planner",
"settings": "MedAssist · Configuration",
"schedule": "MedAssist · Schedule"
}
},
"dashboard": {
"reorder": {
"title": "Reorder Reminder",
"badge": "Stock watch",
"noMeds": "No medications configured yet.",
"allGood": "All good, enough stock.",
"sendReminder": "🔔 Send Reminder Now"
},
"overview": {
"title": "Medication Overview",
"badge": "Stock"
},
"schedules": {
"title": "Upcoming Schedules",
"1month": "1 month",
"3months": "3 months",
"6months": "6 months"
},
"reminders": {
"active": "Automatic reminders active",
"allStockOk": "All stock OK",
"allOk": "All OK",
"lastReminder": "Last reminder",
"nextIn": "Next",
"inDays": "in {{days}} days",
"noRemindersNeeded": "no reminders needed",
"needReorder": "{{count}} med needs reorder",
"needReorder_other": "{{count}} meds need reorder",
"waitingFirstCheck": "waiting for first check"
}
},
"table": {
"name": "Name",
"pills": "Pills",
"days": "Days",
"currentPills": "Current pills",
"daysLeft": "Days left",
"status": "Stock",
"runsOut": "Runs out",
"autoRemind": "Auto-remind",
"expiry": "Expiry"
},
"medications": {
"list": {
"title": "Medication list",
"entries": "{{count}} entries",
"entries_one": "{{count}} entry",
"entries_other": "{{count}} entries"
},
"details": {
"packs": "Packs",
"blisters": "Blisters per pack",
"pillsPerBlister": "Pills per blister",
"loose": "Loose",
"total": "Total"
}
},
"form": {
"editEntry": "Edit entry",
"newEntry": "New entry",
"badge": "Packs + loose pills",
"commercialName": "Commercial Name",
"genericName": "Generic Name",
"takenBy": "Taken by",
"packs": "Packs",
"blistersPerPack": "Blisters per pack",
"pillsPerBlister": "Pills per blister",
"loosePills": "Loose pills",
"pillWeight": "Pill weight (mg)",
"total": "Total (pills)",
"expiryDate": "Expiry Date",
"notes": "Notes",
"medicationImage": "Medication Image",
"removeImage": "Remove Image",
"placeholders": {
"commercial": "e.g. Ozempic",
"generic": "e.g. Semaglutide (optional)",
"takenBy": "e.g. John, Sarah (optional)",
"weight": "e.g. 240",
"notes": "e.g. Take with food, avoid alcohol... (optional)"
},
"slices": {
"title": "Intake schedule",
"remind": "Remind",
"remindTooltip": "Receive a notification 15 minutes before each scheduled intake",
"addIntake": "Intake",
"usage": "Usage (pills)",
"everyDays": "Every (days)",
"every": "every",
"from": "from",
"start": "Start (date/time)"
}
},
"planner": {
"title": "Demand Calculator",
"badge": "Plan your supply",
"from": "From",
"until": "Until",
"calculate": "Calculate",
"calculating": "Calculating...",
"sendEmail": "📧 Send via Email",
"table": {
"medication": "Medication",
"usage": "Usage",
"blistersNeeded": "Blisters needed",
"blisters": "Blisters",
"available": "Available"
}
},
"settings": {
"loading": "Loading settings...",
"language": {
"title": "Language",
"select": "Select language"
},
"notifications": {
"title": "Notifications",
"channels": "Channels",
"email": "Email",
"push": "Push",
"stockReminders": "Stock Reminders",
"intakeReminders": "Intake Reminders",
"enableHint": "Enable at least one channel below to receive notifications."
},
"email": {
"recipient": "Recipient",
"notConfigured": "Not configured"
},
"push": {
"url": "URL",
"supports": "Supports ntfy, Discord, Telegram, Slack"
},
"schedule": {
"stockCheck": "Stock check",
"dailyAt6": "Daily at 6:00 AM",
"intakeCheck": "Intake check",
"15minBefore": "15 min before scheduled time",
"nextCheck": "Next stock check",
"lastSent": "Last sent"
},
"stock": {
"title": "Stock",
"threshold": "Reminder Threshold",
"remindWhen": "Remind when supply drops below",
"repeatDaily": "Repeat daily",
"repeatTooltip": "When enabled, sends reminders every day while stock is low. Otherwise, only notifies once per medication until restocked.",
"display": "Display",
"lowStockDays": "Low Stock (days)",
"lowStockTooltip": "Yellow warning color threshold",
"highStockDays": "High Stock (days)",
"highStockTooltip": "Green with star threshold"
},
"saveSettings": "Save Settings"
},
"modal": {
"for": "for",
"at": "at",
"stockInfo": "Stock Information",
"totalPills": "Total Pills",
"packs": "Packs",
"blistersPerPack": "Blisters/Pack",
"pillsPerBlister": "Pills/Blister",
"loosePills": "Loose Pills",
"pillWeight": "Pill Weight",
"expiryDate": "Expiry Date",
"intakeSchedule": "Intake Schedule",
"coverageStatus": "Coverage Status",
"daysLeft": "Days Left",
"runsOut": "Runs Out",
"notes": "Notes",
"exportCalendar": "Export to Calendar",
"exportTooltip": "Export schedule to calendar",
"editMedication": "Edit Medication",
"userMedications": "{{name}}'s Medications",
"noMedsForUser": "No medications found for {{name}}"
},
"status": {
"outOfStock": "Empty",
"lowStock": "Low",
"normal": "Normal",
"highStock": "High",
"noSchedule": "No Schedule",
"enough": "Enough",
"noPillsLeft": "⚠ No pills left",
"stockOk": "✓ Stock OK"
},
"tooltips": {
"intakeReminders": "Intake reminders enabled",
"hasNotes": "Has notes",
"lightMode": "Switch to light mode",
"darkMode": "Switch to dark mode"
},
"dose": {
"takenBy": "taken by",
"markAsTaken": "Mark as taken"
},
"common": {
"loading": "Loading...",
"sending": "Sending...",
"saving": "Saving...",
"saved": "Saved ✓",
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"edit": "Edit",
"delete": "Delete",
"remove": "Remove",
"reset": "Reset",
"test": "Test",
"undo": "Undo",
"optional": "optional",
"pill": "pill",
"pills": "pills",
"day": "day",
"days": "days",
"blisters": "blisters",
"total": "total"
}
}
+30
View File
@@ -0,0 +1,30 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './en.json';
import de from './de.json';
const resources = {
en: { translation: en },
de: { translation: de },
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
supportedLngs: ['en', 'de'],
interpolation: {
escapeValue: false, // React already escapes
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'medassist-language',
},
});
export default i18n;
+1
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
import "./i18n";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
+36
View File
@@ -825,6 +825,42 @@ textarea {
gap: 0.75rem;
}
.setting-row.language-row {
justify-content: flex-start;
gap: 1.5rem;
}
.language-select {
width: auto;
min-width: 160px;
max-width: 200px;
padding: 0.6rem 2rem 0.6rem 0.75rem;
font-size: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
color: var(--text-primary);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
.language-select:hover {
border-color: var(--accent);
}
.language-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.language-select option {
padding: 0.5rem;
}
.setting-info {
flex: 1;
}
+98 -1
View File
@@ -43,8 +43,11 @@
"name": "medassist-frontend",
"version": "0.1.0",
"dependencies": {
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.11.0",
"zod": "^3.23.8"
},
@@ -980,6 +983,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -4091,6 +4103,15 @@
"node": ">=18.0.0"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -4111,6 +4132,47 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/i18next": {
"version": "24.2.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz",
"integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.26.10"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -4697,6 +4759,32 @@
"react": "^18.3.1"
}
},
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.4.0",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5222,7 +5310,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -5354,6 +5442,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",