{buddies.map((buddy) => (
@@ -210,7 +212,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
{loading ? : (
<>
- Note Speichern
+ {t('tasting.saveTasting')}
>
)}
diff --git a/src/i18n/I18nContext.tsx b/src/i18n/I18nContext.tsx
new file mode 100644
index 0000000..1ee13e2
--- /dev/null
+++ b/src/i18n/I18nContext.tsx
@@ -0,0 +1,69 @@
+'use client';
+
+import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
+import { de } from './de';
+import { en } from './en';
+import { TranslationKeys } from './types';
+
+type Locale = 'de' | 'en';
+
+interface I18nContextType {
+ locale: Locale;
+ setLocale: (locale: Locale) => void;
+ t: (path: string) => string;
+}
+
+const translations: Record = { de, en };
+
+const I18nContext = createContext(undefined);
+
+export const I18nProvider = ({ children }: { children: ReactNode }) => {
+ const [locale, setLocaleState] = useState('de');
+
+ useEffect(() => {
+ const savedLocale = localStorage.getItem('locale') as Locale;
+ if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) {
+ setLocaleState(savedLocale);
+ } else {
+ // Try to detect browser language
+ const browserLang = navigator.language.split('-')[0];
+ if (browserLang === 'en') {
+ setLocaleState('en');
+ }
+ }
+ }, []);
+
+ const setLocale = (newLocale: Locale) => {
+ setLocaleState(newLocale);
+ localStorage.setItem('locale', newLocale);
+ };
+
+ const t = (path: string): string => {
+ const keys = path.split('.');
+ let current: any = translations[locale];
+
+ for (const key of keys) {
+ if (current[key] === undefined) {
+ console.warn(`Translation missing for key: ${path} in locale: ${locale}`);
+ return path;
+ }
+ current = current[key];
+ }
+
+ return current;
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useI18n = () => {
+ const context = useContext(I18nContext);
+ if (!context) {
+ throw new Error('useI18n must be used within an I18nProvider');
+ }
+ return context;
+};
diff --git a/src/i18n/de.ts b/src/i18n/de.ts
new file mode 100644
index 0000000..e9338a1
--- /dev/null
+++ b/src/i18n/de.ts
@@ -0,0 +1,162 @@
+import { TranslationKeys } from './types';
+
+export const de: TranslationKeys = {
+ common: {
+ save: 'Speichern',
+ cancel: 'Abbrechen',
+ edit: 'Bearbeiten',
+ delete: 'Löschen',
+ loading: 'Wird geladen...',
+ error: 'Fehler',
+ success: 'Erfolg',
+ search: 'Suchen',
+ back: 'Zurück',
+ confirm: 'Bestätigen',
+ check: 'Prüfen',
+ link: 'Verknüpfen',
+ none: 'Keine',
+ },
+ home: {
+ title: 'Whisky Vault',
+ logout: 'Abmelden',
+ stats: {
+ title: 'Deine Bar-Statistiken',
+ totalValue: 'Gesamtwert',
+ activeBottles: 'In der Bar',
+ avgRating: 'Ø Bewertung',
+ topDistillery: 'Top Brennerei',
+ },
+ dramOfDay: {
+ button: 'Dram of the Day',
+ rollAgain: 'Noch mal würfeln',
+ suggestion: 'Wie wäre es heute mit einem...',
+ noOpenBottles: 'Keine offenen Flaschen gefunden! Vielleicht Zeit für ein neues Tasting? 🥃',
+ title: 'Dein heutiger Dram',
+ viewBottle: 'Flasche anschauen',
+ },
+ searchPlaceholder: 'Flaschen oder Noten suchen...',
+ noBottles: 'Keine Flaschen gefunden. Zeit für einen Einkauf! 🥃',
+ collection: 'Deine Sammlung',
+ reTry: 'Erneut versuchen',
+ all: 'Alle',
+ },
+ grid: {
+ searchPlaceholder: 'Suchen nach Name oder Distille...',
+ noResults: 'Keine Flaschen gefunden, die deinen Filtern entsprechen. 🔎',
+ sortBy: {
+ createdAt: 'Neueste zuerst',
+ lastTasted: 'Zuletzt verkostet',
+ name: 'Alphabetisch',
+ },
+ filter: {
+ category: 'Kategorie',
+ distillery: 'Brennerei',
+ status: 'Status',
+ },
+ addSession: 'ZU SESSION HINZUFÜGEN',
+ addedOn: 'Hinzugefügt am',
+ reviewRequired: 'REVIEW',
+ unknownBottle: 'Unbekannte Flasche',
+ },
+ bottle: {
+ details: 'Details',
+ distillery: 'Brennerei',
+ category: 'Kategorie',
+ abv: 'Alkoholgehalt',
+ age: 'Alter',
+ years: 'Jahre',
+ lastTasted: 'Zuletzt verkostet',
+ neverTasted: 'Noch nie',
+ purchasePrice: 'Kaufpreis',
+ distilled: 'Destilliert',
+ bottled: 'Abgefüllt',
+ batch: 'Batch / Code',
+ status: {
+ sealed: 'Versiegelt',
+ open: 'Offen',
+ sampled: 'Sample',
+ empty: 'Leer',
+ },
+ whiskybaseId: 'Whiskybase ID',
+ tastingNotes: 'Tasting Notes',
+ tastingNotesDesc: 'Hier findest du deine bisherigen Eindrücke.',
+ noNotes: 'Noch keine Noten vorhanden.',
+ editDetails: 'Details bearbeiten',
+ editTitle: 'Details korrigieren',
+ autoSearch: 'Automatisch suchen',
+ applyId: 'ID Übernehmen',
+ saveChanges: 'Änderungen speichern',
+ noMatchFound: 'Keinen Treffer gefunden.',
+ priceLabel: 'Kaufpreis',
+ nameLabel: 'Name',
+ distilleryLabel: 'Brennerei',
+ categoryLabel: 'Kategorie',
+ abvLabel: 'ABV%',
+ ageLabel: 'Alter',
+ distilledLabel: 'Destilliert',
+ bottledLabel: 'Abgefüllt',
+ batchLabel: 'Batch / Code',
+ bottleStatus: 'Flaschenstatus',
+ },
+ camera: {
+ scanBottle: 'Flasche scannen',
+ uploadImage: 'Bild hochladen',
+ analyzing: 'Analysiere Flasche...',
+ analysisError: 'Analyse fehlgeschlagen',
+ matchFound: 'Flasche erkannt!',
+ notAWhisky: 'Das sieht nicht nach Whisky aus.',
+ lowConfidence: 'Ich bin mir unsicher. Bitte Details prüfen.',
+ saveToVault: 'In den Vault legen',
+ tastingNow: 'Jetzt verkosten',
+ backToList: 'Zurück zur Liste',
+ whiskybaseSearch: 'Whiskybase-Link suchen',
+ searchingWb: 'Suche auf Whiskybase...',
+ wbMatchFound: 'Treffer gefunden',
+ magicShot: 'Magic Shot',
+ saveSuccess: 'Erfolgreich gespeichert!',
+ later: 'Später (Zurück zur Liste)',
+ openingCamera: 'Kamera öffnen',
+ saving: 'Wird gespeichert...',
+ nextBottle: 'Nächste Flasche',
+ newPhoto: 'Neu aufnehmen',
+ inVault: 'Im Vault speichern',
+ offlineNotice: 'Offline! Foto wurde gemerkt – wird automatisch analysiert, sobald du wieder Netz hast. 📡',
+ alreadyInVault: 'Bereits im Vault!',
+ alreadyInVaultDesc: 'Du hast diesen Whisky bereits in deiner Sammlung. Willst du direkt zur Flasche gehen?',
+ saveAnyway: 'Trotzdem als neue Flasche speichern',
+ analysisSuccess: 'Bild erfolgreich analysiert',
+ results: 'Ergebnisse',
+ toVault: 'Zum Whisky im Vault',
+ authRequired: 'Bitte melde dich an, um Flaschen zu speichern.',
+ processingError: 'Verarbeitung fehlgeschlagen. Bitte erneut versuchen.',
+ },
+ tasting: {
+ addNote: 'Neue Note hinzufügen',
+ isSample: 'Ich trinke ein Sample',
+ isBottle: 'Ich trinke aus der Flasche',
+ rating: 'Bewertung',
+ nose: 'Nase',
+ palate: 'Gaumen',
+ finish: 'Abgang',
+ notesPlaceholder: 'Was riechst und schmeckst du?',
+ overall: 'Gesamteindruck',
+ saveTasting: 'Tasting speichern',
+ participants: 'Teilnehmer',
+ addParticipant: 'Mitbuddy hinzufügen',
+ },
+ buddy: {
+ title: 'Deine Buddies',
+ addBuddy: 'Buddy hinzufügen',
+ placeholder: 'Name des Buddies...',
+ noBuddies: 'Noch keine Buddies hinzugefügt.',
+ },
+ session: {
+ title: 'Tasting Sessions',
+ activeSession: 'Aktive Session',
+ allSessions: 'Alle Sessions',
+ newSession: 'Neue Session starten',
+ sessionName: 'Name der Session',
+ noSessions: 'Noch keine Sessions vorhanden.',
+ expiryWarning: 'Diese Session läuft bald ab.',
+ },
+};
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
new file mode 100644
index 0000000..804a287
--- /dev/null
+++ b/src/i18n/en.ts
@@ -0,0 +1,162 @@
+import { TranslationKeys } from './types';
+
+export const en: TranslationKeys = {
+ common: {
+ save: 'Save',
+ cancel: 'Cancel',
+ edit: 'Edit',
+ delete: 'Delete',
+ loading: 'Loading...',
+ error: 'Error',
+ success: 'Success',
+ search: 'Search',
+ back: 'Back',
+ confirm: 'Confirm',
+ check: 'Check',
+ link: 'Link',
+ none: 'None',
+ },
+ home: {
+ title: 'Whisky Vault',
+ logout: 'Logout',
+ stats: {
+ title: 'Your Bar Statistics',
+ totalValue: 'Total Value',
+ activeBottles: 'In the Bar',
+ avgRating: 'Avg Rating',
+ topDistillery: 'Top Distillery',
+ },
+ dramOfDay: {
+ button: 'Dram of the Day',
+ rollAgain: 'Not today, roll again',
+ suggestion: 'How about a...',
+ noOpenBottles: 'No open bottles found! Maybe time for a new tasting? 🥃',
+ title: 'Your Dram for today',
+ viewBottle: 'View Bottle',
+ },
+ searchPlaceholder: 'Search bottles or notes...',
+ noBottles: 'No bottles found. Time to go shopping! 🥃',
+ collection: 'Your Collection',
+ reTry: 'Retry',
+ all: 'All',
+ },
+ grid: {
+ searchPlaceholder: 'Search by name or distillery...',
+ noResults: 'No bottles found matching your filters. 🔎',
+ sortBy: {
+ createdAt: 'Newest first',
+ lastTasted: 'Last tasted',
+ name: 'Alphabetical',
+ },
+ filter: {
+ category: 'Category',
+ distillery: 'Distillery',
+ status: 'Status',
+ },
+ addSession: 'ADD TO SESSION',
+ addedOn: 'Added on',
+ reviewRequired: 'REVIEW',
+ unknownBottle: 'Unknown Bottle',
+ },
+ bottle: {
+ details: 'Details',
+ distillery: 'Distillery',
+ category: 'Category',
+ abv: 'ABV',
+ age: 'Age',
+ years: 'years',
+ lastTasted: 'Last Tasted',
+ neverTasted: 'Never',
+ purchasePrice: 'Purchase Price',
+ distilled: 'Distilled',
+ bottled: 'Bottled',
+ batch: 'Batch / Code',
+ status: {
+ sealed: 'Sealed',
+ open: 'Open',
+ sampled: 'Sample',
+ empty: 'Empty',
+ },
+ whiskybaseId: 'Whiskybase ID',
+ tastingNotes: 'Tasting Notes',
+ tastingNotesDesc: 'Your previous impressions and notes.',
+ noNotes: 'No notes yet.',
+ editDetails: 'Edit Details',
+ editTitle: 'Fix Details',
+ autoSearch: 'Auto Search',
+ applyId: 'Apply ID',
+ saveChanges: 'Save Changes',
+ noMatchFound: 'No match found.',
+ priceLabel: 'Purchase Price',
+ nameLabel: 'Name',
+ distilleryLabel: 'Distillery',
+ categoryLabel: 'Category',
+ abvLabel: 'ABV%',
+ ageLabel: 'Age',
+ distilledLabel: 'Distilled',
+ bottledLabel: 'Bottled',
+ batchLabel: 'Batch / Code',
+ bottleStatus: 'Bottle Status',
+ },
+ camera: {
+ scanBottle: 'Scan Bottle',
+ uploadImage: 'Upload Image',
+ analyzing: 'Analyzing bottle...',
+ analysisError: 'Analysis failed',
+ matchFound: 'Bottle identified!',
+ notAWhisky: "Doesn't look like whisky.",
+ lowConfidence: 'Unsure about details. Please check.',
+ saveToVault: 'Save to Vault',
+ tastingNow: 'Tasting Now',
+ backToList: 'Back to List',
+ whiskybaseSearch: 'Search Whiskybase',
+ searchingWb: 'Searching Whiskybase...',
+ wbMatchFound: 'Match found',
+ magicShot: 'Magic Shot',
+ saveSuccess: 'Successfully saved!',
+ later: 'Later (Back to List)',
+ openingCamera: 'Open Camera',
+ saving: 'Saving...',
+ nextBottle: 'Next Bottle',
+ newPhoto: 'Take New Photo',
+ inVault: 'Save in Vault',
+ offlineNotice: "Offline! Photo captured – it'll be analyzed automatically once you're back online. 📡",
+ alreadyInVault: 'Already in Vault!',
+ alreadyInVaultDesc: 'You already have this whisky in your collection. Want to go directly to the bottle?',
+ saveAnyway: 'Save as new bottle anyway',
+ analysisSuccess: 'Image analyzed successfully',
+ results: 'Results',
+ toVault: 'Go to bottle in Vault',
+ authRequired: 'Please sign in to save bottles.',
+ processingError: 'Processing failed. Please try again.',
+ },
+ tasting: {
+ addNote: 'Add Tasting Note',
+ isSample: "I'm drinking a sample",
+ isBottle: "I'm drinking from the bottle",
+ rating: 'Rating',
+ nose: 'Nose',
+ palate: 'Palate',
+ finish: 'Finish',
+ notesPlaceholder: 'What do you smell and taste?',
+ overall: 'Overall Impression',
+ saveTasting: 'Save Tasting',
+ participants: 'Participants',
+ addParticipant: 'Add Buddy',
+ },
+ buddy: {
+ title: 'Your Buddies',
+ addBuddy: 'Add Buddy',
+ placeholder: 'Buddy name...',
+ noBuddies: 'No buddies added yet.',
+ },
+ session: {
+ title: 'Tasting Sessions',
+ activeSession: 'Active Session',
+ allSessions: 'All Sessions',
+ newSession: 'Start New Session',
+ sessionName: 'Session Name',
+ noSessions: 'No sessions yet.',
+ expiryWarning: 'This session will expire soon.',
+ },
+};
diff --git a/src/i18n/types.ts b/src/i18n/types.ts
new file mode 100644
index 0000000..5b18470
--- /dev/null
+++ b/src/i18n/types.ts
@@ -0,0 +1,160 @@
+export type TranslationKeys = {
+ common: {
+ save: string;
+ cancel: string;
+ edit: string;
+ delete: string;
+ loading: string;
+ error: string;
+ success: string;
+ search: string;
+ back: string;
+ confirm: string;
+ check: string;
+ link: string;
+ none: string;
+ };
+ home: {
+ title: string;
+ logout: string;
+ stats: {
+ title: string;
+ totalValue: string;
+ activeBottles: string;
+ avgRating: string;
+ topDistillery: string;
+ };
+ dramOfDay: {
+ button: string;
+ rollAgain: string;
+ suggestion: string;
+ noOpenBottles: string;
+ title: string;
+ viewBottle: string;
+ };
+ searchPlaceholder: string;
+ noBottles: string;
+ collection: string;
+ reTry: string;
+ all: string;
+ };
+ grid: {
+ searchPlaceholder: string;
+ noResults: string;
+ sortBy: {
+ createdAt: string;
+ lastTasted: string;
+ name: string;
+ };
+ filter: {
+ category: string;
+ distillery: string;
+ status: string;
+ };
+ addSession: string;
+ addedOn: string;
+ reviewRequired: string;
+ unknownBottle: string;
+ };
+ bottle: {
+ details: string;
+ distillery: string;
+ category: string;
+ abv: string;
+ age: string;
+ years: string;
+ lastTasted: string;
+ neverTasted: string;
+ purchasePrice: string;
+ distilled: string;
+ bottled: string;
+ batch: string;
+ status: {
+ sealed: string;
+ open: string;
+ sampled: string;
+ empty: string;
+ };
+ whiskybaseId: string;
+ tastingNotes: string;
+ tastingNotesDesc: string;
+ noNotes: string;
+ editDetails: string;
+ editTitle: string;
+ autoSearch: string;
+ applyId: string;
+ saveChanges: string;
+ noMatchFound: string;
+ priceLabel: string;
+ nameLabel: string;
+ distilleryLabel: string;
+ categoryLabel: string;
+ abvLabel: string;
+ ageLabel: string;
+ distilledLabel: string;
+ bottledLabel: string;
+ batchLabel: string;
+ bottleStatus: string;
+ };
+ camera: {
+ scanBottle: string;
+ uploadImage: string;
+ analyzing: string;
+ analysisError: string;
+ matchFound: string;
+ notAWhisky: string;
+ lowConfidence: string;
+ saveToVault: string;
+ tastingNow: string;
+ backToList: string;
+ whiskybaseSearch: string;
+ searchingWb: string;
+ wbMatchFound: string;
+ magicShot: string;
+ saveSuccess: string;
+ later: string;
+ openingCamera: string;
+ saving: string;
+ nextBottle: string;
+ newPhoto: string;
+ inVault: string;
+ offlineNotice: string;
+ alreadyInVault: string;
+ alreadyInVaultDesc: string;
+ saveAnyway: string;
+ analysisSuccess: string;
+ results: string;
+ toVault: string;
+ authRequired: string;
+ processingError: string;
+ };
+ tasting: {
+ addNote: string;
+ isSample: string;
+ isBottle: string;
+ rating: string;
+ nose: string;
+ palate: string;
+ finish: string;
+ notesPlaceholder: string;
+ overall: string;
+ saveTasting: string;
+ participants: string;
+ addParticipant: string;
+ };
+ buddy: {
+ title: string;
+ addBuddy: string;
+ placeholder: string;
+ noBuddies: string;
+ };
+ session: {
+ title: string;
+ activeSession: string;
+ allSessions: string;
+ newSession: string;
+ sessionName: string;
+ noSessions: string;
+ expiryWarning: string;
+ };
+};