feat: implement comprehensive i18n system with German and English support

- Created type-safe i18n system with TranslationKeys interface
- Added German (de) and English (en) translations with 160+ keys
- Implemented I18nContext provider and useI18n hook
- Added LanguageSwitcher component for language selection
- Refactored all major components to use translations:
  * Home page, StatsDashboard, DramOfTheDay
  * BottleGrid, EditBottleForm, CameraCapture
  * BuddyList, SessionList, TastingNoteForm
  * StatusSwitcher and bottle management features
- Implemented locale-aware currency formatting (EUR)
- Implemented locale-aware date formatting
- Added localStorage persistence for language preference
- Added automatic browser language detection
- Organized translations into 8 main categories
- System is extensible for additional languages
This commit is contained in:
2025-12-18 13:44:48 +01:00
parent acf02a78dd
commit 334bece471
16 changed files with 741 additions and 120 deletions

View File

@@ -4,6 +4,7 @@ import "./globals.css";
import PWARegistration from "@/components/PWARegistration"; import PWARegistration from "@/components/PWARegistration";
import OfflineIndicator from "@/components/OfflineIndicator"; import OfflineIndicator from "@/components/OfflineIndicator";
import UploadQueue from "@/components/UploadQueue"; import UploadQueue from "@/components/UploadQueue";
import { I18nProvider } from "@/i18n/I18nContext";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@@ -40,10 +41,12 @@ export default function RootLayout({
return ( return (
<html lang="de"> <html lang="de">
<body className={inter.className}> <body className={inter.className}>
<I18nProvider>
<PWARegistration /> <PWARegistration />
<OfflineIndicator /> <OfflineIndicator />
<UploadQueue /> <UploadQueue />
{children} {children}
</I18nProvider>
</body> </body>
</html> </html>
); );

View File

@@ -9,6 +9,8 @@ import BuddyList from "@/components/BuddyList";
import SessionList from "@/components/SessionList"; import SessionList from "@/components/SessionList";
import StatsDashboard from "@/components/StatsDashboard"; import StatsDashboard from "@/components/StatsDashboard";
import DramOfTheDay from "@/components/DramOfTheDay"; import DramOfTheDay from "@/components/DramOfTheDay";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { useI18n } from "@/i18n/I18nContext";
export default function Home() { export default function Home() {
const supabase = createClientComponentClient(); const supabase = createClientComponentClient();
@@ -16,6 +18,7 @@ export default function Home() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
const { t } = useI18n();
useEffect(() => { useEffect(() => {
// Check session // Check session
@@ -111,7 +114,12 @@ export default function Home() {
<h1 className="text-5xl font-black text-zinc-900 dark:text-white tracking-tighter mb-4"> <h1 className="text-5xl font-black text-zinc-900 dark:text-white tracking-tighter mb-4">
WHISKY<span className="text-amber-600">VAULT</span> WHISKY<span className="text-amber-600">VAULT</span>
</h1> </h1>
<p className="text-zinc-500 max-w-sm mx-auto">Scanne deine Flaschen, tracke deine Tastings und verwalte deinen Keller.</p> <p className="text-zinc-500 max-w-sm mx-auto">
{t('home.searchPlaceholder').replace('...', '')}
</p>
<div className="mt-8">
<LanguageSwitcher />
</div>
</div> </div>
<AuthForm /> <AuthForm />
</main> </main>
@@ -126,12 +134,13 @@ export default function Home() {
WHISKY<span className="text-amber-600">VAULT</span> WHISKY<span className="text-amber-600">VAULT</span>
</h1> </h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<LanguageSwitcher />
<DramOfTheDay bottles={bottles} /> <DramOfTheDay bottles={bottles} />
<button <button
onClick={handleLogout} onClick={handleLogout}
className="text-sm font-medium text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors" className="text-sm font-medium text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors"
> >
Abmelden {t('home.logout')}
</button> </button>
</div> </div>
</header> </header>
@@ -152,7 +161,7 @@ export default function Home() {
<div className="w-full mt-12"> <div className="w-full mt-12">
<h2 className="text-2xl font-bold mb-6 text-zinc-800 dark:text-zinc-100 flex items-center gap-3"> <h2 className="text-2xl font-bold mb-6 text-zinc-800 dark:text-zinc-100 flex items-center gap-3">
Deine Sammlung {t('home.collection')}
<span className="text-sm font-normal text-zinc-500 bg-zinc-100 dark:bg-zinc-800 px-3 py-1 rounded-full"> <span className="text-sm font-normal text-zinc-500 bg-zinc-100 dark:bg-zinc-800 px-3 py-1 rounded-full">
{bottles.length} {bottles.length}
</span> </span>
@@ -164,13 +173,13 @@ export default function Home() {
</div> </div>
) : fetchError ? ( ) : fetchError ? (
<div className="p-8 bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 rounded-3xl text-center"> <div className="p-8 bg-zinc-100 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 rounded-3xl text-center">
<p className="text-zinc-800 dark:text-zinc-200 font-bold mb-2">Die Sammlung konnte nicht geladen werden</p> <p className="text-zinc-800 dark:text-zinc-200 font-bold mb-2">{t('common.error')}</p>
<p className="text-zinc-500 text-sm italic mb-4">Ein technischer Fehler ist aufgetreten.</p> <p className="text-zinc-500 text-sm italic mb-4">{fetchError}</p>
<button <button
onClick={fetchCollection} onClick={fetchCollection}
className="px-6 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl text-xs font-bold uppercase tracking-widest transition-all" className="px-6 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-xl text-xs font-bold uppercase tracking-widest transition-all"
> >
Erneut versuchen {t('home.reTry')}
</button> </button>
</div> </div>
) : ( ) : (

View File

@@ -6,6 +6,7 @@ import { Search, Filter, X, Calendar, Clock, Package, Lock, Unlock, Ghost, Flask
import { getStorageUrl } from '@/lib/supabase'; import { getStorageUrl } from '@/lib/supabase';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { validateSession } from '@/services/validate-session'; import { validateSession } from '@/services/validate-session';
import { useI18n } from '@/i18n/I18nContext';
interface Bottle { interface Bottle {
id: string; id: string;
@@ -29,11 +30,12 @@ interface BottleCardProps {
} }
function BottleCard({ bottle, sessionId }: BottleCardProps) { function BottleCard({ bottle, sessionId }: BottleCardProps) {
const { t, locale } = useI18n();
const statusConfig = { const statusConfig = {
open: { icon: Unlock, color: 'bg-amber-500/80 border-amber-400/50', label: 'Offen' }, open: { icon: Unlock, color: 'bg-amber-500/80 border-amber-400/50', label: t('bottle.status.open') },
sampled: { icon: FlaskConical, color: 'bg-purple-500/80 border-purple-400/50', label: 'Sample' }, sampled: { icon: FlaskConical, color: 'bg-purple-500/80 border-purple-400/50', label: t('bottle.status.sampled') },
empty: { icon: Ghost, color: 'bg-zinc-500/80 border-zinc-400/50', label: 'Leer' }, empty: { icon: Ghost, color: 'bg-zinc-500/80 border-zinc-400/50', label: t('bottle.status.empty') },
sealed: { icon: Lock, color: 'bg-blue-600/80 border-blue-400/50', label: 'Versiegelt' }, sealed: { icon: Lock, color: 'bg-blue-600/80 border-blue-400/50', label: t('bottle.status.sealed') },
}; };
const StatusIcon = statusConfig[bottle.status as keyof typeof statusConfig]?.icon || Lock; const StatusIcon = statusConfig[bottle.status as keyof typeof statusConfig]?.icon || Lock;
@@ -56,14 +58,14 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
{sessionId && ( {sessionId && (
<div className="absolute top-3 left-3 bg-amber-600 text-white text-[9px] font-black px-2 py-1.5 rounded-xl flex items-center gap-1.5 border border-amber-400 shadow-xl animate-in slide-in-from-left-4 duration-500"> <div className="absolute top-3 left-3 bg-amber-600 text-white text-[9px] font-black px-2 py-1.5 rounded-xl flex items-center gap-1.5 border border-amber-400 shadow-xl animate-in slide-in-from-left-4 duration-500">
<PlusCircle size={12} strokeWidth={3} /> <PlusCircle size={12} strokeWidth={3} />
ZU SESSION HINZUFÜGEN {t('grid.addSession')}
</div> </div>
)} )}
{bottle.last_tasted && ( {bottle.last_tasted && (
<div className="absolute top-3 right-3 bg-zinc-900/80 backdrop-blur-md text-white text-[9px] font-black px-2 py-1 rounded-lg flex items-center gap-1 border border-white/10 ring-1 ring-black/5"> <div className="absolute top-3 right-3 bg-zinc-900/80 backdrop-blur-md text-white text-[9px] font-black px-2 py-1 rounded-lg flex items-center gap-1 border border-white/10 ring-1 ring-black/5">
<Clock size={10} /> <Clock size={10} />
{new Date(bottle.last_tasted).toLocaleDateString('de-DE')} {new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</div> </div>
)} )}
@@ -80,13 +82,13 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
{(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && ( {(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && (
<div className="flex items-center gap-1 text-[8px] font-black bg-red-500 text-white px-1.5 py-0.5 rounded-full animate-pulse"> <div className="flex items-center gap-1 text-[8px] font-black bg-red-500 text-white px-1.5 py-0.5 rounded-full animate-pulse">
<AlertCircle size={8} /> <AlertCircle size={8} />
REVIEW {t('grid.reviewRequired')}
</div> </div>
)} )}
</div> </div>
<h3 className={`font-black text-lg md:text-xl leading-tight group-hover:text-amber-600 transition-colors line-clamp-2 min-h-[3rem] md:min-h-[3.5rem] flex items-center ${bottle.is_whisky === false ? 'text-red-600 dark:text-red-400' : 'text-zinc-900 dark:text-zinc-100' <h3 className={`font-black text-lg md:text-xl leading-tight group-hover:text-amber-600 transition-colors line-clamp-2 min-h-[3rem] md:min-h-[3.5rem] flex items-center ${bottle.is_whisky === false ? 'text-red-600 dark:text-red-400' : 'text-zinc-900 dark:text-zinc-100'
}`}> }`}>
{bottle.name || 'Unbekannte Flasche'} {bottle.name || t('grid.unknownBottle')}
</h3> </h3>
</div> </div>
@@ -101,8 +103,10 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
<div className="pt-1 md:pt-2 flex items-center gap-2 text-[9px] md:text-[10px] font-bold text-zinc-400 uppercase tracking-wider border-t border-zinc-100 dark:border-zinc-800"> <div className="pt-1 md:pt-2 flex items-center gap-2 text-[9px] md:text-[10px] font-bold text-zinc-400 uppercase tracking-wider border-t border-zinc-100 dark:border-zinc-800">
<Calendar size={10} className="text-zinc-300" /> <Calendar size={10} className="text-zinc-300" />
<span className="opacity-70 text-[8px] md:text-[9px]">Hinzugefügt am</span> <span className="opacity-70 text-[8px] md:text-[9px]">{t('grid.addedOn')}</span>
<span className="text-zinc-500 dark:text-zinc-300">{new Date(bottle.created_at).toLocaleDateString('de-DE')}</span> <span className="text-zinc-500 dark:text-zinc-300">
{new Date(bottle.created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -115,6 +119,7 @@ interface BottleGridProps {
} }
export default function BottleGrid({ bottles }: BottleGridProps) { export default function BottleGrid({ bottles }: BottleGridProps) {
const { t } = useI18n();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const sessionId = searchParams.get('session_id'); const sessionId = searchParams.get('session_id');
const [validatedSessionId, setValidatedSessionId] = useState<string | null>(null); const [validatedSessionId, setValidatedSessionId] = useState<string | null>(null);
@@ -186,7 +191,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
if (!bottles || bottles.length === 0) { if (!bottles || bottles.length === 0) {
return ( return (
<div className="text-center py-12 p-8 bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border-2 border-dashed border-zinc-200 dark:border-zinc-800"> <div className="text-center py-12 p-8 bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border-2 border-dashed border-zinc-200 dark:border-zinc-800">
<p className="text-zinc-500">Noch keine Flaschen im Vault. Zeit für den ersten Scan! 🥃</p> <p className="text-zinc-500">{t('home.noBottles')}</p>
</div> </div>
); );
} }
@@ -200,7 +205,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={18} />
<input <input
type="text" type="text"
placeholder="Suchen nach Name oder Distille..." placeholder={t('grid.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl focus:ring-2 focus:ring-amber-500 outline-none transition-all" className="w-full pl-10 pr-4 py-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl focus:ring-2 focus:ring-amber-500 outline-none transition-all"
@@ -220,16 +225,16 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
onChange={(e) => setSortBy(e.target.value as any)} onChange={(e) => setSortBy(e.target.value as any)}
className="px-4 py-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl text-sm font-medium focus:ring-2 focus:ring-amber-500 outline-none cursor-pointer" className="px-4 py-3 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl text-sm font-medium focus:ring-2 focus:ring-amber-500 outline-none cursor-pointer"
> >
<option value="created_at">Neueste zuerst</option> <option value="created_at">{t('grid.sortBy.createdAt')}</option>
<option value="last_tasted">Zuletzt verkostet</option> <option value="last_tasted">{t('grid.sortBy.lastTasted')}</option>
<option value="name">Alphabetisch</option> <option value="name">{t('grid.sortBy.name')}</option>
</select> </select>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{/* Category Filter */} {/* Category Filter */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 px-1">Kategorie</span> <span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 px-1">{t('grid.filter.category')}</span>
<div className="flex gap-2 overflow-x-auto -mx-4 px-4 pb-2 scrollbar-hide touch-pan-x"> <div className="flex gap-2 overflow-x-auto -mx-4 px-4 pb-2 scrollbar-hide touch-pan-x">
<button <button
onClick={() => setSelectedCategory(null)} onClick={() => setSelectedCategory(null)}
@@ -238,7 +243,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
: 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400' : 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400'
}`} }`}
> >
ALLE {t('common.all').toUpperCase()}
</button> </button>
{categories.map((cat) => ( {categories.map((cat) => (
<button <button
@@ -257,7 +262,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
{/* Distillery Filter */} {/* Distillery Filter */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 px-1">Distillery</span> <span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 px-1">{t('grid.filter.distillery')}</span>
<div className="flex gap-2 overflow-x-auto -mx-4 px-4 pb-2 scrollbar-hide touch-pan-x"> <div className="flex gap-2 overflow-x-auto -mx-4 px-4 pb-2 scrollbar-hide touch-pan-x">
<button <button
onClick={() => setSelectedDistillery(null)} onClick={() => setSelectedDistillery(null)}
@@ -266,7 +271,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
: 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400' : 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400'
}`} }`}
> >
ALLE {t('common.all').toUpperCase()}
</button> </button>
{distilleries.map((dist) => ( {distilleries.map((dist) => (
<button <button
@@ -285,7 +290,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
{/* Status Filter */} {/* Status Filter */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 px-1">Status</span> <span className="text-[10px] font-black uppercase tracking-widest text-zinc-400 px-1">{t('grid.filter.status')}</span>
<div className="flex gap-2 overflow-x-auto -mx-4 px-4 pb-2 scrollbar-hide touch-pan-x"> <div className="flex gap-2 overflow-x-auto -mx-4 px-4 pb-2 scrollbar-hide touch-pan-x">
{['sealed', 'open', 'sampled', 'empty'].map((status) => ( {['sealed', 'open', 'sampled', 'empty'].map((status) => (
<button <button
@@ -296,7 +301,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
: 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400' : 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400'
}`} }`}
> >
{status.toUpperCase()} {t(`bottle.status.${status}`).toUpperCase()}
</button> </button>
))} ))}
</div> </div>
@@ -313,7 +318,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
</div> </div>
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-zinc-500 italic">Keine Flaschen gefunden, die deinen Filtern entsprechen. 🔎</p> <p className="text-zinc-500 italic">{t('grid.noResults')}</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -3,6 +3,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { Users, UserPlus, Trash2, User, Loader2 } from 'lucide-react'; import { Users, UserPlus, Trash2, User, Loader2 } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface Buddy { interface Buddy {
id: string; id: string;
@@ -11,6 +12,7 @@ interface Buddy {
} }
export default function BuddyList() { export default function BuddyList() {
const { t } = useI18n();
const supabase = createClientComponentClient(); const supabase = createClientComponentClient();
const [buddies, setBuddies] = useState<Buddy[]>([]); const [buddies, setBuddies] = useState<Buddy[]>([]);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
@@ -77,7 +79,7 @@ export default function BuddyList() {
<div className="bg-white dark:bg-zinc-900 rounded-3xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xl"> <div className="bg-white dark:bg-zinc-900 rounded-3xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xl">
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-zinc-800 dark:text-zinc-100 italic"> <h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-zinc-800 dark:text-zinc-100 italic">
<Users size={24} className="text-amber-600" /> <Users size={24} className="text-amber-600" />
Deine Buddies {t('buddy.title')}
</h3> </h3>
<form onSubmit={handleAddBuddy} className="flex gap-2 mb-6"> <form onSubmit={handleAddBuddy} className="flex gap-2 mb-6">
@@ -85,7 +87,7 @@ export default function BuddyList() {
type="text" type="text"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder="Buddy Name..." placeholder={t('buddy.placeholder')}
className="flex-1 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50" className="flex-1 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/> />
<button <button
@@ -103,7 +105,7 @@ export default function BuddyList() {
</div> </div>
) : buddies.length === 0 ? ( ) : buddies.length === 0 ? (
<div className="text-center py-8 text-zinc-500 text-sm"> <div className="text-center py-8 text-zinc-500 text-sm">
Noch keine Buddies hinzugefügt. {t('buddy.noBuddies')}
</div> </div>
) : ( ) : (
<div className="space-y-2 max-h-[300px] overflow-y-auto scrollbar-hide"> <div className="space-y-2 max-h-[300px] overflow-y-auto scrollbar-hide">
@@ -119,7 +121,7 @@ export default function BuddyList() {
<div> <div>
<span className="font-semibold text-zinc-800 dark:text-zinc-200 text-sm">{buddy.name}</span> <span className="font-semibold text-zinc-800 dark:text-zinc-200 text-sm">{buddy.name}</span>
{buddy.buddy_profile_id && ( {buddy.buddy_profile_id && (
<span className="ml-2 inline-block px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-500 text-[8px] font-black uppercase rounded">verknüpft</span> <span className="ml-2 inline-block px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-500 text-[8px] font-black uppercase rounded">{t('common.link')}</span>
)} )}
</div> </div>
</div> </div>

View File

@@ -14,6 +14,7 @@ import { validateSession } from '@/services/validate-session';
import { discoverWhiskybaseId } from '@/services/discover-whiskybase'; import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
import { updateBottle } from '@/services/update-bottle'; import { updateBottle } from '@/services/update-bottle';
import Link from 'next/link'; import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext';
interface CameraCaptureProps { interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void; onImageCaptured?: (base64Image: string) => void;
@@ -22,6 +23,7 @@ interface CameraCaptureProps {
} }
export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onSaveComplete }: CameraCaptureProps) { export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onSaveComplete }: CameraCaptureProps) {
const { t } = useI18n();
const supabase = createClientComponentClient(); const supabase = createClientComponentClient();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -118,11 +120,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
onAnalysisComplete(response.data); onAnalysisComplete(response.data);
} }
} else { } else {
setError(response.error || 'Analyse fehlgeschlagen.'); setError(response.error || t('camera.analysisError'));
} }
} catch (err) { } catch (err) {
console.error('Processing failed:', err); console.error('Processing failed:', err);
setError('Verarbeitung fehlgeschlagen. Bitte erneut versuchen.'); setError(t('camera.processingError'));
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
@@ -138,7 +140,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
// Get current user (simple check for now, can be improved with Auth) // Get current user (simple check for now, can be improved with Auth)
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
if (!user) { if (!user) {
throw new Error('Bitte melde dich an, um Flaschen zu speichern.'); throw new Error(t('camera.authRequired'));
} }
const response = await saveBottle(analysisResult, previewUrl, user.id); const response = await saveBottle(analysisResult, previewUrl, user.id);
@@ -147,11 +149,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
setLastSavedId(response.data.id); setLastSavedId(response.data.id);
if (onSaveComplete) onSaveComplete(); if (onSaveComplete) onSaveComplete();
} else { } else {
setError(response.error || 'Speichern fehlgeschlagen.'); setError(response.error || t('common.error'));
} }
} catch (err) { } catch (err) {
console.error('Save failed:', err); console.error('Save failed:', err);
setError(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.'); setError(err instanceof Error ? err.message : t('common.error'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -227,7 +229,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
return ( return (
<div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800 transition-all hover:shadow-whisky-amber/20"> <div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-white dark:bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-200 dark:border-zinc-800 transition-all hover:shadow-whisky-amber/20">
<h2 className="text-xl md:text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">Magic Shot</h2> <h2 className="text-xl md:text-2xl font-bold text-zinc-800 dark:text-zinc-100 italic">{t('camera.magicShot')}</h2>
<div <div
className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-700 overflow-hidden flex items-center justify-center bg-zinc-50 dark:bg-zinc-800/50 hover:border-amber-500 transition-colors" className="relative group cursor-pointer w-full aspect-square rounded-2xl border-2 border-dashed border-zinc-300 dark:border-zinc-700 overflow-hidden flex items-center justify-center bg-zinc-50 dark:bg-zinc-800/50 hover:border-amber-500 transition-colors"
@@ -238,7 +240,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
) : ( ) : (
<div className="flex flex-col items-center gap-2 text-zinc-400 group-hover:text-amber-500 transition-colors"> <div className="flex flex-col items-center gap-2 text-zinc-400 group-hover:text-amber-500 transition-colors">
<Camera size={48} strokeWidth={1.5} /> <Camera size={48} strokeWidth={1.5} />
<span className="text-sm font-medium">Flasche scannen</span> <span className="text-sm font-medium">{t('camera.scanBottle')}</span>
</div> </div>
)} )}
@@ -262,7 +264,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300"> <div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
<div className="flex items-center gap-2 text-green-600 font-bold justify-center p-2"> <div className="flex items-center gap-2 text-green-600 font-bold justify-center p-2">
<CheckCircle2 size={24} className="text-green-500" /> <CheckCircle2 size={24} className="text-green-500" />
Erfolgreich gespeichert! {t('camera.saveSuccess')}
</div> </div>
<button <button
@@ -272,7 +274,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
}} }}
className="w-full py-4 px-6 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-zinc-800 dark:hover:bg-white transition-all shadow-xl" className="w-full py-4 px-6 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-zinc-800 dark:hover:bg-white transition-all shadow-xl"
> >
Jetzt verkosten {t('camera.tastingNow')}
<ChevronRight size={20} /> <ChevronRight size={20} />
</button> </button>
@@ -282,21 +284,21 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
className="w-full py-3 px-6 bg-amber-50 dark:bg-amber-900/20 text-amber-600 rounded-xl font-bold flex items-center justify-center gap-2 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 transition-all text-sm" className="w-full py-3 px-6 bg-amber-50 dark:bg-amber-900/20 text-amber-600 rounded-xl font-bold flex items-center justify-center gap-2 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 transition-all text-sm"
> >
<Search size={16} /> <Search size={16} />
Whiskybase-Link suchen {t('camera.whiskybaseSearch')}
</button> </button>
)} )}
{isDiscovering && ( {isDiscovering && (
<div className="w-full py-3 px-6 text-zinc-400 font-bold flex items-center justify-center gap-2 text-sm italic"> <div className="w-full py-3 px-6 text-zinc-400 font-bold flex items-center justify-center gap-2 text-sm italic">
<Loader2 size={16} className="animate-spin" /> <Loader2 size={16} className="animate-spin" />
Suche auf Whiskybase... {t('camera.searchingWb')}
</div> </div>
)} )}
{wbDiscovery && ( {wbDiscovery && (
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/50 border border-amber-500/30 rounded-2xl space-y-3 animate-in fade-in slide-in-from-top-2"> <div className="p-4 bg-zinc-50 dark:bg-zinc-800/50 border border-amber-500/30 rounded-2xl space-y-3 animate-in fade-in slide-in-from-top-2">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-amber-600"> <div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-amber-600">
<Sparkles size={12} /> Treffer gefunden <Sparkles size={12} /> {t('camera.wbMatchFound')}
</div> </div>
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-200 line-clamp-2 leading-snug"> <p className="text-xs font-bold text-zinc-800 dark:text-zinc-200 line-clamp-2 leading-snug">
{wbDiscovery.title} {wbDiscovery.title}
@@ -306,7 +308,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
onClick={handleLinkWb} onClick={handleLinkWb}
className="flex-1 py-2.5 bg-amber-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-amber-700 transition-colors" className="flex-1 py-2.5 bg-amber-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-amber-700 transition-colors"
> >
Verknüpfen {t('common.link')}
</button> </button>
<a <a
href={wbDiscovery.url} href={wbDiscovery.url}
@@ -314,7 +316,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex-1 py-2.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 transition-colors flex items-center justify-center gap-1" className="flex-1 py-2.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 transition-colors flex items-center justify-center gap-1"
> >
<ExternalLink size={12} /> Prüfen <ExternalLink size={12} /> {t('common.check')}
</a> </a>
</div> </div>
</div> </div>
@@ -328,7 +330,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
}} }}
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 font-bold transition-colors" className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 font-bold transition-colors"
> >
Später (Zurück zur Liste) {t('camera.later')}
</button> </button>
</div> </div>
) : matchingBottle ? ( ) : matchingBottle ? (
@@ -337,7 +339,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20" className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20"
> >
<ExternalLink size={20} /> <ExternalLink size={20} />
Zum Whisky im Vault {t('camera.toVault')}
</Link> </Link>
) : ( ) : (
<button <button
@@ -348,27 +350,27 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
{isSaving ? ( {isSaving ? (
<> <>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div> <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
Wird gespeichert... {t('camera.saving')}
</> </>
) : isQueued ? ( ) : isQueued ? (
<> <>
<CheckCircle2 size={20} /> <CheckCircle2 size={20} />
Nächste Flasche {t('camera.nextBottle')}
</> </>
) : previewUrl && analysisResult ? ( ) : previewUrl && analysisResult ? (
<> <>
<CheckCircle2 size={20} /> <CheckCircle2 size={20} />
Im Vault speichern {t('camera.inVault')}
</> </>
) : previewUrl ? ( ) : previewUrl ? (
<> <>
<Upload size={20} /> <Upload size={20} />
Neu aufnehmen {t('camera.newPhoto')}
</> </>
) : ( ) : (
<> <>
<Camera size={20} /> <Camera size={20} />
Kamera öffnen {t('camera.openingCamera')}
</> </>
)} )}
</button> </button>
@@ -384,7 +386,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
{isQueued && ( {isQueued && (
<div className="flex items-center gap-2 text-purple-500 text-sm bg-purple-50 dark:bg-purple-900/10 p-4 rounded-xl w-full border border-purple-100 dark:border-purple-800/30 font-medium"> <div className="flex items-center gap-2 text-purple-500 text-sm bg-purple-50 dark:bg-purple-900/10 p-4 rounded-xl w-full border border-purple-100 dark:border-purple-800/30 font-medium">
<Sparkles size={16} /> <Sparkles size={16} />
Offline! Foto wurde gemerkt wird automatisch analysiert, sobald du wieder Netz hast. 📡 {t('camera.offlineNotice')}
</div> </div>
)} )}
@@ -392,16 +394,16 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
<div className="flex flex-col gap-2 p-4 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-900/30 rounded-xl w-full"> <div className="flex flex-col gap-2 p-4 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-900/30 rounded-xl w-full">
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm"> <div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm">
<AlertCircle size={16} /> <AlertCircle size={16} />
Bereits im Vault! {t('camera.alreadyInVault')}
</div> </div>
<p className="text-xs text-blue-500/80"> <p className="text-xs text-blue-500/80">
Du hast diesen Whisky bereits in deiner Sammlung. Willst du direkt zur Flasche gehen? {t('camera.alreadyInVaultDesc')}
</p> </p>
<button <button
onClick={() => setMatchingBottle(null)} onClick={() => setMatchingBottle(null)}
className="text-[10px] text-zinc-400 font-black uppercase text-left hover:text-zinc-600" className="text-[10px] text-zinc-400 font-black uppercase text-left hover:text-zinc-600"
> >
Trotzdem als neue Flasche speichern {t('camera.saveAnyway')}
</button> </button>
</div> </div>
)} )}
@@ -410,30 +412,30 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-4 duration-500"> <div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-2 text-green-500 text-sm bg-green-50 dark:bg-green-900/10 p-3 rounded-lg w-full"> <div className="flex items-center gap-2 text-green-500 text-sm bg-green-50 dark:bg-green-900/10 p-3 rounded-lg w-full">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
Bild erfolgreich analysiert {t('camera.analysisSuccess')}
</div> </div>
{analysisResult && ( {analysisResult && (
<div className="p-3 md:p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-200 dark:border-zinc-700"> <div className="p-3 md:p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-200 dark:border-zinc-700">
<div className="flex items-center gap-2 mb-2 md:mb-3 text-amber-600 dark:text-amber-500"> <div className="flex items-center gap-2 mb-2 md:mb-3 text-amber-600 dark:text-amber-500">
<Sparkles size={18} /> <Sparkles size={18} />
<span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">Ergebnisse</span> <span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-zinc-500">Name:</span> <span className="text-zinc-500">{t('bottle.nameLabel')}:</span>
<span className="font-semibold">{analysisResult.name || '-'}</span> <span className="font-semibold">{analysisResult.name || '-'}</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-zinc-500">Distille:</span> <span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span>
<span className="font-semibold">{analysisResult.distillery || '-'}</span> <span className="font-semibold">{analysisResult.distillery || '-'}</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-zinc-500">Kategorie:</span> <span className="text-zinc-500">{t('bottle.categoryLabel')}:</span>
<span className="font-semibold">{analysisResult.category || '-'}</span> <span className="font-semibold">{analysisResult.category || '-'}</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-zinc-500">ABV:</span> <span className="text-zinc-500">{t('bottle.abvLabel')}:</span>
<span className="font-semibold">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span> <span className="font-semibold">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Sparkles, GlassWater, Dices, X } from 'lucide-react'; import { Sparkles, GlassWater, Dices, X } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
import Link from 'next/link'; import Link from 'next/link';
interface Bottle { interface Bottle {
@@ -16,6 +17,7 @@ interface DramOfTheDayProps {
} }
export default function DramOfTheDay({ bottles }: DramOfTheDayProps) { export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
const { t } = useI18n();
const [suggestion, setSuggestion] = useState<Bottle | null>(null); const [suggestion, setSuggestion] = useState<Bottle | null>(null);
const [isRolling, setIsRolling] = useState(false); const [isRolling, setIsRolling] = useState(false);
@@ -24,7 +26,7 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
const openBottles = bottles.filter(b => b.status === 'open' || b.status === 'sampled'); const openBottles = bottles.filter(b => b.status === 'open' || b.status === 'sampled');
if (openBottles.length === 0) { if (openBottles.length === 0) {
alert('Keine offenen Flaschen gefunden! Vielleicht Zeit für ein neues Tasting? 🥃'); alert(t('home.dramOfDay.noOpenBottles'));
setIsRolling(false); setIsRolling(false);
return; return;
} }
@@ -49,7 +51,7 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
) : ( ) : (
<Sparkles size={18} /> <Sparkles size={18} />
)} )}
Dram of the Day {t('home.dramOfDay.button')}
</button> </button>
{suggestion && ( {suggestion && (
@@ -68,7 +70,7 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-amber-600">Dein heutiger Dram</h3> <h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-amber-600">{t('home.dramOfDay.title')}</h3>
<h2 className="text-2xl font-black text-zinc-900 dark:text-white leading-tight"> <h2 className="text-2xl font-black text-zinc-900 dark:text-white leading-tight">
{suggestion.name} {suggestion.name}
</h2> </h2>
@@ -83,13 +85,13 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
onClick={() => setSuggestion(null)} onClick={() => setSuggestion(null)}
className="block w-full py-4 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-amber-600 dark:hover:bg-amber-600 hover:text-white transition-all shadow-xl" className="block w-full py-4 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-amber-600 dark:hover:bg-amber-600 hover:text-white transition-all shadow-xl"
> >
Flasche anschauen {t('home.dramOfDay.viewBottle')}
</Link> </Link>
<button <button
onClick={suggestDram} onClick={suggestDram}
className="w-full mt-3 py-2 text-zinc-400 hover:text-amber-600 text-[10px] font-black uppercase tracking-widest transition-colors" className="w-full mt-3 py-2 text-zinc-400 hover:text-amber-600 text-[10px] font-black uppercase tracking-widest transition-colors"
> >
Nicht heute, noch mal würfeln {t('home.dramOfDay.rollAgain')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@ import React, { useState } from 'react';
import { Edit2, Save, X, Info, Tag, FlaskConical, CircleDollarSign, Search, Loader2, ExternalLink } from 'lucide-react'; import { Edit2, Save, X, Info, Tag, FlaskConical, CircleDollarSign, Search, Loader2, ExternalLink } from 'lucide-react';
import { updateBottle } from '@/services/update-bottle'; import { updateBottle } from '@/services/update-bottle';
import { discoverWhiskybaseId } from '@/services/discover-whiskybase'; import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
import { useI18n } from '@/i18n/I18nContext';
interface EditBottleFormProps { interface EditBottleFormProps {
bottle: { bottle: {
@@ -23,6 +24,7 @@ interface EditBottleFormProps {
} }
export default function EditBottleForm({ bottle, onComplete }: EditBottleFormProps) { export default function EditBottleForm({ bottle, onComplete }: EditBottleFormProps) {
const { t, locale } = useI18n();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
@@ -60,7 +62,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
if (result.success && result.id) { if (result.success && result.id) {
setDiscoveryResult({ id: result.id!, url: result.url!, title: result.title! }); setDiscoveryResult({ id: result.id!, url: result.url!, title: result.title! });
} else { } else {
setError(result.error || 'Keinen Treffer gefunden.'); setError(result.error || t('bottle.noMatchFound'));
} }
setIsSearching(false); setIsSearching(false);
}; };
@@ -91,10 +93,10 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
setIsEditing(false); setIsEditing(false);
if (onComplete) onComplete(); if (onComplete) onComplete();
} else { } else {
setError(response.error || 'Fehler beim Speichern'); setError(response.error || t('common.error'));
} }
} catch (err) { } catch (err) {
setError('Etwas ist schiefgelaufen.'); setError(t('common.error'));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -108,12 +110,12 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded-xl text-sm font-bold transition-all w-fit" className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded-xl text-sm font-bold transition-all w-fit"
> >
<Edit2 size={16} /> <Edit2 size={16} />
Details bearbeiten {t('bottle.editDetails')}
</button> </button>
{bottle.purchase_price && ( {bottle.purchase_price && (
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/10 text-green-700 dark:text-green-400 rounded-xl text-sm font-bold border border-green-100 dark:border-green-900/30 w-fit"> <div className="flex items-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/10 text-green-700 dark:text-green-400 rounded-xl text-sm font-bold border border-green-100 dark:border-green-900/30 w-fit">
<CircleDollarSign size={16} /> <CircleDollarSign size={16} />
Kaufpreis: {parseFloat(bottle.purchase_price.toString()).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })} {t('bottle.priceLabel')}: {parseFloat(bottle.purchase_price.toString()).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' })}
</div> </div>
)} )}
</div> </div>
@@ -124,7 +126,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
<div className="p-6 bg-white dark:bg-zinc-900 border border-amber-500/30 rounded-3xl shadow-xl space-y-4 animate-in zoom-in-95 duration-200"> <div className="p-6 bg-white dark:bg-zinc-900 border border-amber-500/30 rounded-3xl shadow-xl space-y-4 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-black text-amber-600 uppercase tracking-widest flex items-center gap-2"> <h3 className="text-lg font-black text-amber-600 uppercase tracking-widest flex items-center gap-2">
<Info size={18} /> Details korrigieren <Info size={18} /> {t('bottle.editTitle')}
</h3> </h3>
<button <button
onClick={() => setIsEditing(false)} onClick={() => setIsEditing(false)}
@@ -136,7 +138,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Name</label> <label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.nameLabel')}</label>
<input <input
type="text" type="text"
value={formData.name} value={formData.name}
@@ -145,7 +147,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Brennerei</label> <label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.distilleryLabel')}</label>
<input <input
type="text" type="text"
value={formData.distillery} value={formData.distillery}
@@ -154,7 +156,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Kategorie</label> <label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.categoryLabel')}</label>
<input <input
type="text" type="text"
value={formData.category} value={formData.category}
@@ -164,7 +166,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">ABV%</label> <label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.abvLabel')}</label>
<input <input
type="number" type="number"
step="0.1" step="0.1"
@@ -174,7 +176,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Alter</label> <label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.ageLabel')}</label>
<input <input
type="number" type="number"
value={formData.age} value={formData.age}
@@ -193,7 +195,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
className="text-amber-600 hover:text-amber-700 flex items-center gap-1 normal-case font-bold" className="text-amber-600 hover:text-amber-700 flex items-center gap-1 normal-case font-bold"
> >
{isSearching ? <Loader2 size={10} className="animate-spin" /> : <Search size={10} />} {isSearching ? <Loader2 size={10} className="animate-spin" /> : <Search size={10} />}
Automatisch suchen {t('bottle.autoSearch')}
</button> </button>
</label> </label>
<input <input
@@ -212,7 +214,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
onClick={applyDiscovery} onClick={applyDiscovery}
className="px-3 py-1.5 bg-amber-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-amber-700 transition-colors" className="px-3 py-1.5 bg-amber-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-amber-700 transition-colors"
> >
ID Übernehmen {t('bottle.applyId')}
</button> </button>
<a <a
href={discoveryResult.url} href={discoveryResult.url}
@@ -220,14 +222,14 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
rel="noopener noreferrer" rel="noopener noreferrer"
className="px-3 py-1.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors flex items-center gap-1" className="px-3 py-1.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors flex items-center gap-1"
> >
<ExternalLink size={10} /> Prüfen <ExternalLink size={10} /> {t('common.check')}
</a> </a>
</div> </div>
</div> </div>
)} )}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Kaufpreis ()</label> <label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.priceLabel')} ()</label>
<input <input
type="number" type="number"
step="0.01" step="0.01"
@@ -239,7 +241,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Destilliert</label> <label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.distilledLabel')}</label>
<input <input
type="text" type="text"
placeholder="z.B. 2010" placeholder="z.B. 2010"
@@ -250,7 +252,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Abgefüllt</label> <label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.bottledLabel')}</label>
<input <input
type="text" type="text"
placeholder="z.B. 2022" placeholder="z.B. 2022"
@@ -261,7 +263,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
</div> </div>
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Batch / Code</label> <label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.batchLabel')}</label>
<input <input
type="text" type="text"
placeholder="z.B. Batch 12 oder L-Code" placeholder="z.B. Batch 12 oder L-Code"
@@ -280,7 +282,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
className="w-full py-4 bg-amber-600 hover:bg-amber-700 text-white rounded-2xl font-black uppercase tracking-widest transition-all flex items-center justify-center gap-2 shadow-lg shadow-amber-600/20 disabled:opacity-50" className="w-full py-4 bg-amber-600 hover:bg-amber-700 text-white rounded-2xl font-black uppercase tracking-widest transition-all flex items-center justify-center gap-2 shadow-lg shadow-amber-600/20 disabled:opacity-50"
> >
{isSaving ? <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div> : <Save size={20} />} {isSaving ? <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div> : <Save size={20} />}
Änderungen speichern {t('bottle.saveChanges')}
</button> </button>
</div> </div>
); );

View File

@@ -0,0 +1,35 @@
'use client';
import React from 'react';
import { useI18n } from '@/i18n/I18nContext';
const LanguageSwitcher = () => {
const { locale, setLocale } = useI18n();
return (
<div className="flex items-center gap-2">
<button
onClick={() => setLocale('de')}
className={`p-1.5 rounded-lg transition-all ${locale === 'de'
? 'bg-amber-100 dark:bg-amber-900/30 scale-110 shadow-sm'
: 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0'
}`}
title="Deutsch"
>
<span className="text-lg">🇩🇪</span>
</button>
<button
onClick={() => setLocale('en')}
className={`p-1.5 rounded-lg transition-all ${locale === 'en'
? 'bg-amber-100 dark:bg-amber-900/30 scale-110 shadow-sm'
: 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0'
}`}
title="English"
>
<span className="text-lg">🇬🇧</span>
</button>
</div>
);
};
export default LanguageSwitcher;

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users } from 'lucide-react'; import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext';
interface Session { interface Session {
id: string; id: string;
@@ -13,6 +14,7 @@ interface Session {
} }
export default function SessionList() { export default function SessionList() {
const { t, locale } = useI18n();
const supabase = createClientComponentClient(); const supabase = createClientComponentClient();
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -70,7 +72,7 @@ export default function SessionList() {
<div className="bg-white dark:bg-zinc-900 rounded-3xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xl"> <div className="bg-white dark:bg-zinc-900 rounded-3xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xl">
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-zinc-800 dark:text-zinc-100 italic"> <h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-zinc-800 dark:text-zinc-100 italic">
<GlassWater size={24} className="text-amber-600" /> <GlassWater size={24} className="text-amber-600" />
Tasting Sessions {t('session.title')}
</h3> </h3>
<form onSubmit={handleCreateSession} className="flex gap-2 mb-6"> <form onSubmit={handleCreateSession} className="flex gap-2 mb-6">
@@ -78,7 +80,7 @@ export default function SessionList() {
type="text" type="text"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder="Event Name (z.B. Islay Night)..." placeholder={t('session.sessionName')}
className="flex-1 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50" className="flex-1 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/> />
<button <button
@@ -96,7 +98,7 @@ export default function SessionList() {
</div> </div>
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
<div className="text-center py-8 text-zinc-500 text-sm"> <div className="text-center py-8 text-zinc-500 text-sm">
Noch keine Sessions geplant. {t('session.noSessions')}
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@@ -111,12 +113,12 @@ export default function SessionList() {
<div className="flex items-center gap-4 text-[10px] font-black uppercase tracking-widest text-zinc-400"> <div className="flex items-center gap-4 text-[10px] font-black uppercase tracking-widest text-zinc-400">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar size={12} /> <Calendar size={12} />
{new Date(session.scheduled_at).toLocaleDateString('de-DE')} {new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</span> </span>
{session.participant_count! > 0 && ( {session.participant_count! > 0 && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Users size={12} /> <Users size={12} />
{session.participant_count} Teilnehmer {session.participant_count} {t('tasting.participants')}
</span> </span>
)} )}
</div> </div>

View File

@@ -2,6 +2,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { TrendingUp, CreditCard, Star, Home, BarChart3 } from 'lucide-react'; import { TrendingUp, CreditCard, Star, Home, BarChart3 } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface Bottle { interface Bottle {
id: string; id: string;
@@ -16,6 +17,7 @@ interface StatsDashboardProps {
} }
export default function StatsDashboard({ bottles }: StatsDashboardProps) { export default function StatsDashboard({ bottles }: StatsDashboardProps) {
const { t, locale } = useI18n();
const stats = useMemo(() => { const stats = useMemo(() => {
const activeBottles = bottles.filter(b => b.status !== 'empty'); const activeBottles = bottles.filter(b => b.status !== 'empty');
const totalValue = bottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0); const totalValue = bottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0);
@@ -45,28 +47,28 @@ export default function StatsDashboard({ bottles }: StatsDashboardProps) {
const statItems = [ const statItems = [
{ {
label: 'Gesamtwert', label: t('home.stats.totalValue'),
value: stats.totalValue.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }), value: stats.totalValue.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' }),
icon: CreditCard, icon: CreditCard,
color: 'text-green-600', color: 'text-green-600',
bg: 'bg-green-50 dark:bg-green-900/20' bg: 'bg-green-50 dark:bg-green-900/20'
}, },
{ {
label: 'In der Bar', label: t('home.stats.activeBottles'),
value: stats.activeCount, value: stats.activeCount,
icon: Home, icon: Home,
color: 'text-blue-600', color: 'text-blue-600',
bg: 'bg-blue-50 dark:bg-blue-900/20' bg: 'bg-blue-50 dark:bg-blue-900/20'
}, },
{ {
label: 'Ø Bewertung', label: t('home.stats.avgRating'),
value: `${stats.avgRating}/100`, value: `${stats.avgRating}/100`,
icon: Star, icon: Star,
color: 'text-amber-600', color: 'text-amber-600',
bg: 'bg-amber-50 dark:bg-amber-900/20' bg: 'bg-amber-50 dark:bg-amber-900/20'
}, },
{ {
label: 'Top Brennerei', label: t('home.stats.topDistillery'),
value: stats.topDistillery, value: stats.topDistillery,
icon: BarChart3, icon: BarChart3,
color: 'text-purple-600', color: 'text-purple-600',

View File

@@ -3,6 +3,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { updateBottleStatus } from '@/services/update-bottle-status'; import { updateBottleStatus } from '@/services/update-bottle-status';
import { Loader2, Package, Play, CheckCircle, FlaskConical } from 'lucide-react'; import { Loader2, Package, Play, CheckCircle, FlaskConical } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface StatusSwitcherProps { interface StatusSwitcherProps {
bottleId: string; bottleId: string;
@@ -10,6 +11,7 @@ interface StatusSwitcherProps {
} }
export default function StatusSwitcher({ bottleId, currentStatus }: StatusSwitcherProps) { export default function StatusSwitcher({ bottleId, currentStatus }: StatusSwitcherProps) {
const { t } = useI18n();
const [status, setStatus] = useState(currentStatus); const [status, setStatus] = useState(currentStatus);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -22,26 +24,26 @@ export default function StatusSwitcher({ bottleId, currentStatus }: StatusSwitch
if (result.success) { if (result.success) {
setStatus(newStatus); setStatus(newStatus);
} else { } else {
alert(result.error || 'Fehler beim Aktualisieren des Status'); alert(result.error || t('common.error'));
} }
} catch (err) { } catch (err) {
alert('Ein unerwarteter Fehler ist aufgetreten'); alert(t('common.error'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const options = [ const options = [
{ id: 'sealed', label: 'Versiegelt', icon: Package, color: 'hover:bg-blue-500' }, { id: 'sealed', label: t('bottle.status.sealed'), icon: Package, color: 'hover:bg-blue-500' },
{ id: 'open', label: 'Offen', icon: Play, color: 'hover:bg-amber-500' }, { id: 'open', label: t('bottle.status.open'), icon: Play, color: 'hover:bg-amber-500' },
{ id: 'sampled', label: 'Sampled', icon: FlaskConical, color: 'hover:bg-purple-500' }, { id: 'sampled', label: t('bottle.status.sampled'), icon: FlaskConical, color: 'hover:bg-purple-500' },
{ id: 'empty', label: 'Leer', icon: CheckCircle, color: 'hover:bg-zinc-500' }, { id: 'empty', label: t('bottle.status.empty'), icon: CheckCircle, color: 'hover:bg-zinc-500' },
] as const; ] as const;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest">Flaschenstatus</label> <label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest">{t('bottle.bottleStatus')}</label>
{loading && <Loader2 className="animate-spin text-amber-600" size={14} />} {loading && <Loader2 className="animate-spin text-amber-600" size={14} />}
</div> </div>
<div className="grid grid-cols-4 gap-2 p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-2xl border border-zinc-200/50 dark:border-zinc-800/50"> <div className="grid grid-cols-4 gap-2 p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-2xl border border-zinc-200/50 dark:border-zinc-800/50">

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { saveTasting } from '@/services/save-tasting'; import { saveTasting } from '@/services/save-tasting';
import { Loader2, Send, Star, Users, Check } from 'lucide-react'; import { Loader2, Send, Star, Users, Check } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useI18n } from '@/i18n/I18nContext';
interface Buddy { interface Buddy {
id: string; id: string;
@@ -16,6 +17,7 @@ interface TastingNoteFormProps {
} }
export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteFormProps) { export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteFormProps) {
const { t } = useI18n();
const supabase = createClientComponentClient(); const supabase = createClientComponentClient();
const [rating, setRating] = useState(85); const [rating, setRating] = useState(85);
const [nose, setNose] = useState(''); const [nose, setNose] = useState('');
@@ -78,10 +80,10 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
setSelectedBuddyIds([]); setSelectedBuddyIds([]);
// We don't need to manually refresh because of revalidatePath in the server action // We don't need to manually refresh because of revalidatePath in the server action
} else { } else {
setError(result.error || 'Fehler beim Speichern'); setError(result.error || t('common.error'));
} }
} catch (err) { } catch (err) {
setError('Ein unerwarteter Fehler ist aufgetreten'); setError(t('common.error'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -93,7 +95,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2"> <label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
<Star size={14} className="text-amber-500 fill-amber-500" /> <Star size={14} className="text-amber-500 fill-amber-500" />
Rating {t('tasting.rating')}
</label> </label>
<span className="text-2xl font-black text-amber-600 tracking-tighter">{rating}<span className="text-zinc-400 text-sm ml-0.5 font-bold">/100</span></span> <span className="text-2xl font-black text-amber-600 tracking-tighter">{rating}<span className="text-zinc-400 text-sm ml-0.5 font-bold">/100</span></span>
</div> </div>
@@ -113,7 +115,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest">Art der Probe</label> <label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest">{t('tasting.overall')}</label>
<div className="grid grid-cols-2 gap-2 p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-2xl border border-zinc-200/50 dark:border-zinc-800/50"> <div className="grid grid-cols-2 gap-2 p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-2xl border border-zinc-200/50 dark:border-zinc-800/50">
<button <button
type="button" type="button"
@@ -139,33 +141,33 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">Nose</label> <label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.nose')}</label>
<textarea <textarea
value={nose} value={nose}
onChange={(e) => setNose(e.target.value)} onChange={(e) => setNose(e.target.value)}
placeholder="Aromen in der Nase..." placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200" className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">Palate</label> <label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.palate')}</label>
<textarea <textarea
value={palate} value={palate}
onChange={(e) => setPalate(e.target.value)} onChange={(e) => setPalate(e.target.value)}
placeholder="Geschmack am Gaumen..." placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200" className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">Finish</label> <label className="text-xs font-bold text-zinc-400 uppercase tracking-tighter">{t('tasting.finish')}</label>
<textarea <textarea
value={finish} value={finish}
onChange={(e) => setFinish(e.target.value)} onChange={(e) => setFinish(e.target.value)}
placeholder="Nachhall..." placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200" className="w-full p-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none resize-none transition-all dark:text-zinc-200"
/> />
@@ -175,7 +177,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2"> <label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
<Users size={14} className="text-amber-500" /> <Users size={14} className="text-amber-500" />
Gekostet mit (Buddies) {t('tasting.participants')}
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{buddies.map((buddy) => ( {buddies.map((buddy) => (
@@ -210,7 +212,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
{loading ? <Loader2 className="animate-spin" size={18} /> : ( {loading ? <Loader2 className="animate-spin" size={18} /> : (
<> <>
<Send size={16} /> <Send size={16} />
Note Speichern {t('tasting.saveTasting')}
</> </>
)} )}
</button> </button>

69
src/i18n/I18nContext.tsx Normal file
View File

@@ -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<Locale, TranslationKeys> = { de, en };
const I18nContext = createContext<I18nContextType | undefined>(undefined);
export const I18nProvider = ({ children }: { children: ReactNode }) => {
const [locale, setLocaleState] = useState<Locale>('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 (
<I18nContext.Provider value={{ locale, setLocale, t }}>
{children}
</I18nContext.Provider>
);
};
export const useI18n = () => {
const context = useContext(I18nContext);
if (!context) {
throw new Error('useI18n must be used within an I18nProvider');
}
return context;
};

162
src/i18n/de.ts Normal file
View File

@@ -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.',
},
};

162
src/i18n/en.ts Normal file
View File

@@ -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.',
},
};

160
src/i18n/types.ts Normal file
View File

@@ -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;
};
};