diff --git a/src/client/lib/i18n.ts b/src/client/lib/i18n.ts
index df34245..4d12eca 100644
--- a/src/client/lib/i18n.ts
+++ b/src/client/lib/i18n.ts
@@ -9,6 +9,13 @@ import enSettings from "../locales/en/settings.json";
import enSetups from "../locales/en/setups.json";
import enThreads from "../locales/en/threads.json";
+import deCollection from "../locales/de/collection.json";
+import deCommon from "../locales/de/common.json";
+import deOnboarding from "../locales/de/onboarding.json";
+import deSettings from "../locales/de/settings.json";
+import deSetups from "../locales/de/setups.json";
+import deThreads from "../locales/de/threads.json";
+
i18n
.use(LanguageDetector)
.use(initReactI18next)
@@ -22,6 +29,14 @@ i18n
onboarding: enOnboarding,
settings: enSettings,
},
+ de: {
+ common: deCommon,
+ collection: deCollection,
+ threads: deThreads,
+ setups: deSetups,
+ onboarding: deOnboarding,
+ settings: deSettings,
+ },
},
supportedLngs: ["en", "de"],
fallbackLng: "en",
diff --git a/src/client/locales/de/collection.json b/src/client/locales/de/collection.json
new file mode 100644
index 0000000..f6e15de
--- /dev/null
+++ b/src/client/locales/de/collection.json
@@ -0,0 +1,31 @@
+{
+ "title": "Sammlung",
+ "gear": "Ausruestung",
+ "planning": "Planung",
+ "empty": {
+ "title": "Ihre Sammlung ist leer",
+ "description": "Beginnen Sie mit der Katalogisierung Ihrer Ausruestung, indem Sie Ihren ersten Gegenstand hinzufuegen. Verfolgen Sie Gewicht, Preis und organisieren Sie nach Kategorie.",
+ "addFirst": "Ersten Gegenstand hinzufuegen"
+ },
+ "form": {
+ "name": "Name",
+ "nameRequired": "Name *",
+ "namePlaceholder": "z.B. Osprey Talon 22",
+ "weight": "Gewicht (g)",
+ "weightPlaceholder": "z.B. 680",
+ "price": "Preis ($)",
+ "pricePlaceholder": "z.B. 129,99",
+ "quantity": "Menge",
+ "category": "Kategorie",
+ "notes": "Notizen",
+ "notesPlaceholder": "Zusaetzliche Notizen...",
+ "productLink": "Produktlink",
+ "urlPlaceholder": "https://..."
+ },
+ "classification": {
+ "ultralight": "Ultraleicht",
+ "light": "Leicht",
+ "medium": "Mittel",
+ "heavy": "Schwer"
+ }
+}
diff --git a/src/client/locales/de/common.json b/src/client/locales/de/common.json
new file mode 100644
index 0000000..c25da94
--- /dev/null
+++ b/src/client/locales/de/common.json
@@ -0,0 +1,80 @@
+{
+ "nav": {
+ "home": "Startseite",
+ "collection": "Sammlung",
+ "setups": "Setups",
+ "discover": "Entdecken",
+ "settings": "Einstellungen",
+ "search": "Suchen",
+ "searchPlaceholder": "Katalog durchsuchen...",
+ "profile": "Profil"
+ },
+ "actions": {
+ "save": "Speichern",
+ "cancel": "Abbrechen",
+ "delete": "Loeschen",
+ "edit": "Bearbeiten",
+ "create": "Erstellen",
+ "close": "Schliessen",
+ "back": "Zurueck",
+ "confirm": "Bestaetigen",
+ "continue": "Weiter",
+ "tryAgain": "Erneut versuchen",
+ "dismiss": "Schliessen",
+ "saving": "Wird gespeichert...",
+ "deleting": "Wird geloescht...",
+ "creating": "Wird erstellt...",
+ "loading": "Laden...",
+ "addItem": "Gegenstand hinzufuegen",
+ "saveChanges": "Aenderungen speichern",
+ "revoke": "Widerrufen",
+ "skipStep": "Diesen Schritt ueberspringen"
+ },
+ "errors": {
+ "somethingWentWrong": "Etwas ist schiefgelaufen",
+ "unexpectedError": "Ein unerwarteter Fehler ist aufgetreten",
+ "nameRequired": "Name ist erforderlich",
+ "positiveNumber": "Muss eine positive Zahl sein",
+ "validUrl": "Muss eine gueltige URL sein (https://...)"
+ },
+ "auth": {
+ "signIn": "Anmelden",
+ "signOut": "Abmelden",
+ "joinGearBox": "GearBox beitreten",
+ "signInToGearBox": "Bei GearBox anmelden",
+ "signInDescription": "Melden Sie sich an oder erstellen Sie ein Konto, um Ihre Sammlung zu verwalten.",
+ "createAccount": "Konto erstellen",
+ "redirectDescription": "Sie werden zur Anmeldung weitergeleitet."
+ },
+ "confirm": {
+ "deleteItem": "Gegenstand loeschen",
+ "deleteItemMessage": "Sind Sie sicher, dass Sie {{name}} loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.",
+ "deleteCandidate": "Kandidat loeschen",
+ "deleteCandidateMessage": "Sind Sie sicher, dass Sie {{name}} loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.",
+ "pickWinner": "Gewinner waehlen",
+ "pickWinnerMessage": "{{name}} als Gewinner waehlen? Der Gegenstand wird Ihrer Sammlung hinzugefuegt und der Thread archiviert."
+ },
+ "externalLink": {
+ "title": "Sie verlassen GearBox",
+ "redirectMessage": "Sie werden weitergeleitet zu:"
+ },
+ "fab": {
+ "addToCollection": "Zur Sammlung hinzufuegen",
+ "startNewThread": "Neuen Thread starten",
+ "newSetup": "Neues Setup"
+ },
+ "empty": {
+ "noResults": "Keine Ergebnisse gefunden",
+ "noItems": "Keine Gegenstaende gefunden"
+ },
+ "stats": {
+ "items": "Gegenstaende",
+ "totalWeight": "Gesamtgewicht",
+ "totalSpent": "Gesamtausgaben"
+ },
+ "filter": {
+ "showing": "{{filtered}} von {{total}} Gegenstaenden",
+ "searchItems": "Gegenstaende suchen...",
+ "allCategories": "Alle Kategorien"
+ }
+}
diff --git a/src/client/locales/de/onboarding.json b/src/client/locales/de/onboarding.json
new file mode 100644
index 0000000..d5a40cc
--- /dev/null
+++ b/src/client/locales/de/onboarding.json
@@ -0,0 +1,34 @@
+{
+ "welcome": {
+ "title": "Willkommen bei GearBox",
+ "subtitle": "Sagen Sie uns, was Sie interessiert, und wir helfen Ihnen, Ihre Sammlung mit Ausruestung einzurichten, die wirklich genutzt wird.",
+ "cta": "Los geht's"
+ },
+ "hobby": {
+ "title": "Was interessiert Sie?",
+ "subtitle": "Waehlen Sie eins oder mehrere — wir zeigen Ihnen beliebte Ausruestung fuer jedes.",
+ "continue": "Weiter"
+ },
+ "items": {
+ "title": "Beliebte Ausruestung fuer {{hobby}}",
+ "titleMultiple": "Beliebte Ausruestung fuer Ihre Hobbys",
+ "subtitle": "Tippen Sie auf Gegenstaende, die Sie bereits besitzen. Wir fuegen sie Ihrer Sammlung hinzu.",
+ "noCatalog": "Noch keine Ausruestung katalogisiert",
+ "noCatalogDescription": "Wir bauen unseren Katalog fuer dieses Hobby noch auf. Sie koennen diesen Schritt ueberspringen und spaeter manuell Ausruestung hinzufuegen.",
+ "reviewCount": "{{count}} Gegenstaende pruefen",
+ "reviewCount_one": "{{count}} Gegenstand pruefen"
+ },
+ "review": {
+ "title": "Ihre Startsammlung",
+ "itemsReady": "{{count}} Gegenstaende bereit zum Hinzufuegen",
+ "itemsReady_one": "{{count}} Gegenstand bereit zum Hinzufuegen",
+ "noItemsSelected": "Keine Gegenstaende ausgewaehlt — Sie koennen jederzeit spaeter Ausruestung aus dem Katalog hinzufuegen.",
+ "addToCollection": "Zu meiner Sammlung hinzufuegen",
+ "adding": "Wird hinzugefuegt..."
+ },
+ "done": {
+ "title": "Alles bereit!",
+ "subtitle": "Ihre Sammlung ist fertig. Durchstoebern Sie jederzeit den Katalog, um mehr Ausruestung zu entdecken.",
+ "cta": "Jetzt entdecken"
+ }
+}
diff --git a/src/client/locales/de/settings.json b/src/client/locales/de/settings.json
new file mode 100644
index 0000000..23805fe
--- /dev/null
+++ b/src/client/locales/de/settings.json
@@ -0,0 +1,32 @@
+{
+ "title": "Einstellungen",
+ "language": {
+ "title": "Sprache",
+ "description": "Aendern Sie die Anzeigesprache der App"
+ },
+ "weightUnit": {
+ "title": "Gewichtseinheit",
+ "description": "Waehlen Sie die Einheit fuer die Gewichtsanzeige in der App"
+ },
+ "currency": {
+ "title": "Waehrung",
+ "description": "Aendert das angezeigte Waehrungssymbol. Werte werden nicht umgerechnet."
+ },
+ "apiKeys": {
+ "title": "API-Schluessel",
+ "description": "API-Schluessel ermoeglichen programmatischen Zugriff auf GearBox (z.B. von Claude Desktop oder Skripten).",
+ "copyWarning": "Kopieren Sie diesen Schluessel jetzt — er wird nicht erneut angezeigt:",
+ "namePlaceholder": "Schluesselname (z.B. claude-desktop)"
+ },
+ "importExport": {
+ "title": "Import / Export",
+ "description": "Exportieren Sie Ihre Ausruestungssammlung als CSV-Datei oder importieren Sie Gegenstaende aus einer CSV.",
+ "export": "CSV exportieren",
+ "import": "CSV importieren",
+ "importing": "Wird importiert...",
+ "imported": "{{count}} Gegenstaende importiert.",
+ "imported_one": "{{count}} Gegenstand importiert.",
+ "newCategories": "Neue Kategorien: {{categories}}",
+ "noItemsFound": "Keine Gegenstaende in der CSV gefunden."
+ }
+}
diff --git a/src/client/locales/de/setups.json b/src/client/locales/de/setups.json
new file mode 100644
index 0000000..3eb2f45
--- /dev/null
+++ b/src/client/locales/de/setups.json
@@ -0,0 +1,43 @@
+{
+ "title": "Setups",
+ "create": "Neues Setup",
+ "empty": {
+ "title": "Noch keine Setups",
+ "description": "Erstellen Sie ein Setup, um Ausruestung fuer bestimmte Reisen oder Aktivitaeten zu organisieren."
+ },
+ "card": {
+ "items": "{{count}} Gegenstaende",
+ "items_one": "{{count}} Gegenstand",
+ "weight": "Gewicht",
+ "price": "Preis"
+ },
+ "share": {
+ "title": "Setup teilen",
+ "shareLinks": "Freigabelinks",
+ "createLink": "Link erstellen",
+ "noLinks": "Noch keine Freigabelinks",
+ "copyLink": "Link kopieren",
+ "revokeLink": "Link widerrufen",
+ "copied": "Kopiert!",
+ "noExpiration": "Kein Ablaufdatum",
+ "expired": "Abgelaufen",
+ "expiresToday": "Laeuft heute ab",
+ "expiresTomorrow": "Laeuft morgen ab",
+ "expiresInDays": "Laeuft in {{days}} Tagen ab",
+ "daysOption": "{{days}} Tage",
+ "deactivateWarning": "Bei Umstellung auf Privat werden alle Freigabelinks deaktiviert. Sie koennen durch Zurueckschalten reaktiviert werden."
+ },
+ "visibility": {
+ "private": "Privat",
+ "privateDescription": "Nur Sie haben Zugriff",
+ "link": "Link-Freigabe",
+ "linkDescription": "Jeder mit dem Link",
+ "public": "Oeffentlich",
+ "publicDescription": "Sichtbar auf Ihrem Profil"
+ },
+ "impact": {
+ "title": "Auswirkungsvorschau",
+ "adding": "Hinzufuegen",
+ "removing": "Entfernen"
+ }
+}
diff --git a/src/client/locales/de/threads.json b/src/client/locales/de/threads.json
new file mode 100644
index 0000000..f36d656
--- /dev/null
+++ b/src/client/locales/de/threads.json
@@ -0,0 +1,45 @@
+{
+ "title": "Recherche-Threads",
+ "create": {
+ "title": "Neuer Thread",
+ "threadName": "Thread-Name",
+ "namePlaceholder": "z.B. Leichter Schlafsack",
+ "category": "Kategorie",
+ "nameRequired": "Thread-Name ist erforderlich",
+ "selectCategory": "Bitte waehlen Sie eine Kategorie",
+ "createFailed": "Thread konnte nicht erstellt werden",
+ "createThread": "Thread erstellen"
+ },
+ "status": {
+ "active": "Aktiv",
+ "researching": "Recherche",
+ "ordered": "Bestellt",
+ "arrived": "Angekommen",
+ "resolved": "Abgeschlossen",
+ "archived": "Archiviert"
+ },
+ "candidate": {
+ "name": "Name",
+ "price": "Preis",
+ "weight": "Gewicht",
+ "url": "URL",
+ "pros": "Vorteile",
+ "cons": "Nachteile",
+ "notes": "Notizen",
+ "addCandidate": "Kandidat hinzufuegen"
+ },
+ "comparison": {
+ "weight": "Gewicht",
+ "price": "Preis",
+ "pros": "Vorteile",
+ "cons": "Nachteile"
+ },
+ "resolve": {
+ "title": "Gewinner waehlen",
+ "message": "{{name}} als Gewinner waehlen? Der Gegenstand wird Ihrer Sammlung hinzugefuegt und der Thread archiviert."
+ },
+ "empty": {
+ "noThreads": "Noch keine Recherche-Threads",
+ "noCandidates": "Noch keine Kandidaten"
+ }
+}
diff --git a/tests/i18n/locales.test.ts b/tests/i18n/locales.test.ts
new file mode 100644
index 0000000..6ec2a9c
--- /dev/null
+++ b/tests/i18n/locales.test.ts
@@ -0,0 +1,76 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, test } from "bun:test";
+
+const LOCALES_DIR = join(import.meta.dir, "../../src/client/locales");
+
+function flattenKeys(
+ obj: Record,
+ prefix = "",
+): string[] {
+ const keys: string[] = [];
+ for (const [key, value] of Object.entries(obj)) {
+ const fullKey = prefix ? `${prefix}.${key}` : key;
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
+ keys.push(
+ ...flattenKeys(value as Record, fullKey),
+ );
+ } else {
+ keys.push(fullKey);
+ }
+ }
+ return keys.sort();
+}
+
+function loadLocale(
+ locale: string,
+): Record> {
+ const dir = join(LOCALES_DIR, locale);
+ const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
+ const result: Record> = {};
+ for (const file of files) {
+ const ns = file.replace(".json", "");
+ result[ns] = JSON.parse(readFileSync(join(dir, file), "utf8"));
+ }
+ return result;
+}
+
+describe("locale key parity", () => {
+ const en = loadLocale("en");
+ const de = loadLocale("de");
+
+ test("en and de have the same namespaces", () => {
+ expect(Object.keys(en).sort()).toEqual(Object.keys(de).sort());
+ });
+
+ for (const ns of Object.keys(en)) {
+ test(`${ns}: every en key exists in de`, () => {
+ const enKeys = flattenKeys(en[ns]);
+ const deKeys = flattenKeys(de[ns]);
+ const missing = enKeys.filter((k) => !deKeys.includes(k));
+ expect(missing).toEqual([]);
+ });
+
+ test(`${ns}: every de key exists in en`, () => {
+ const enKeys = flattenKeys(en[ns]);
+ const deKeys = flattenKeys(de[ns]);
+ const orphan = deKeys.filter((k) => !enKeys.includes(k));
+ expect(orphan).toEqual([]);
+ });
+
+ test(`${ns}: no empty de values`, () => {
+ const deKeys = flattenKeys(de[ns]);
+ for (const key of deKeys) {
+ const value = key
+ .split(".")
+ .reduce(
+ (obj, k) => (obj as Record)?.[k],
+ de[ns] as unknown,
+ );
+ expect(
+ typeof value === "string" && value.length > 0,
+ ).toBe(true);
+ }
+ });
+ }
+});