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

@@ -6,6 +6,7 @@ import { Search, Filter, X, Calendar, Clock, Package, Lock, Unlock, Ghost, Flask
import { getStorageUrl } from '@/lib/supabase';
import { useSearchParams } from 'next/navigation';
import { validateSession } from '@/services/validate-session';
import { useI18n } from '@/i18n/I18nContext';
interface Bottle {
id: string;
@@ -29,11 +30,12 @@ interface BottleCardProps {
}
function BottleCard({ bottle, sessionId }: BottleCardProps) {
const { t, locale } = useI18n();
const statusConfig = {
open: { icon: Unlock, color: 'bg-amber-500/80 border-amber-400/50', label: 'Offen' },
sampled: { icon: FlaskConical, color: 'bg-purple-500/80 border-purple-400/50', label: 'Sample' },
empty: { icon: Ghost, color: 'bg-zinc-500/80 border-zinc-400/50', label: 'Leer' },
sealed: { icon: Lock, color: 'bg-blue-600/80 border-blue-400/50', label: 'Versiegelt' },
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: t('bottle.status.sampled') },
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: t('bottle.status.sealed') },
};
const StatusIcon = statusConfig[bottle.status as keyof typeof statusConfig]?.icon || Lock;
@@ -56,14 +58,14 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
{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">
<PlusCircle size={12} strokeWidth={3} />
ZU SESSION HINZUFÜGEN
{t('grid.addSession')}
</div>
)}
{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">
<Clock size={10} />
{new Date(bottle.last_tasted).toLocaleDateString('de-DE')}
{new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</div>
)}
@@ -80,13 +82,13 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
{(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">
<AlertCircle size={8} />
REVIEW
{t('grid.reviewRequired')}
</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'
}`}>
{bottle.name || 'Unbekannte Flasche'}
{bottle.name || t('grid.unknownBottle')}
</h3>
</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">
<Calendar size={10} className="text-zinc-300" />
<span className="opacity-70 text-[8px] md:text-[9px]">Hinzugefügt am</span>
<span className="text-zinc-500 dark:text-zinc-300">{new Date(bottle.created_at).toLocaleDateString('de-DE')}</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(locale === 'de' ? 'de-DE' : 'en-US')}
</span>
</div>
</div>
</div>
@@ -115,6 +119,7 @@ interface BottleGridProps {
}
export default function BottleGrid({ bottles }: BottleGridProps) {
const { t } = useI18n();
const searchParams = useSearchParams();
const sessionId = searchParams.get('session_id');
const [validatedSessionId, setValidatedSessionId] = useState<string | null>(null);
@@ -186,7 +191,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
if (!bottles || bottles.length === 0) {
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">
<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>
);
}
@@ -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} />
<input
type="text"
placeholder="Suchen nach Name oder Distille..."
placeholder={t('grid.searchPlaceholder')}
value={searchQuery}
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"
@@ -220,16 +225,16 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
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"
>
<option value="created_at">Neueste zuerst</option>
<option value="last_tasted">Zuletzt verkostet</option>
<option value="name">Alphabetisch</option>
<option value="created_at">{t('grid.sortBy.createdAt')}</option>
<option value="last_tasted">{t('grid.sortBy.lastTasted')}</option>
<option value="name">{t('grid.sortBy.name')}</option>
</select>
</div>
<div className="space-y-6">
{/* Category Filter */}
<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">
<button
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'
}`}
>
ALLE
{t('common.all').toUpperCase()}
</button>
{categories.map((cat) => (
<button
@@ -257,7 +262,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
{/* Distillery Filter */}
<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">
<button
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'
}`}
>
ALLE
{t('common.all').toUpperCase()}
</button>
{distilleries.map((dist) => (
<button
@@ -285,7 +290,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
{/* Status Filter */}
<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">
{['sealed', 'open', 'sampled', 'empty'].map((status) => (
<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'
}`}
>
{status.toUpperCase()}
{t(`bottle.status.${status}`).toUpperCase()}
</button>
))}
</div>
@@ -313,7 +318,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
</div>
) : (
<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>

View File

@@ -3,6 +3,7 @@
import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { Users, UserPlus, Trash2, User, Loader2 } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface Buddy {
id: string;
@@ -11,6 +12,7 @@ interface Buddy {
}
export default function BuddyList() {
const { t } = useI18n();
const supabase = createClientComponentClient();
const [buddies, setBuddies] = useState<Buddy[]>([]);
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">
<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" />
Deine Buddies
{t('buddy.title')}
</h3>
<form onSubmit={handleAddBuddy} className="flex gap-2 mb-6">
@@ -85,7 +87,7 @@ export default function BuddyList() {
type="text"
value={newName}
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"
/>
<button
@@ -103,7 +105,7 @@ export default function BuddyList() {
</div>
) : buddies.length === 0 ? (
<div className="text-center py-8 text-zinc-500 text-sm">
Noch keine Buddies hinzugefügt.
{t('buddy.noBuddies')}
</div>
) : (
<div className="space-y-2 max-h-[300px] overflow-y-auto scrollbar-hide">
@@ -119,7 +121,7 @@ export default function BuddyList() {
<div>
<span className="font-semibold text-zinc-800 dark:text-zinc-200 text-sm">{buddy.name}</span>
{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>

View File

@@ -14,6 +14,7 @@ import { validateSession } from '@/services/validate-session';
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
import { updateBottle } from '@/services/update-bottle';
import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext';
interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void;
@@ -22,6 +23,7 @@ interface CameraCaptureProps {
}
export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onSaveComplete }: CameraCaptureProps) {
const { t } = useI18n();
const supabase = createClientComponentClient();
const router = useRouter();
const searchParams = useSearchParams();
@@ -118,11 +120,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
onAnalysisComplete(response.data);
}
} else {
setError(response.error || 'Analyse fehlgeschlagen.');
setError(response.error || t('camera.analysisError'));
}
} catch (err) {
console.error('Processing failed:', err);
setError('Verarbeitung fehlgeschlagen. Bitte erneut versuchen.');
setError(t('camera.processingError'));
} finally {
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)
const { data: { user } } = await supabase.auth.getUser();
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);
@@ -147,11 +149,11 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
setLastSavedId(response.data.id);
if (onSaveComplete) onSaveComplete();
} else {
setError(response.error || 'Speichern fehlgeschlagen.');
setError(response.error || t('common.error'));
}
} catch (err) {
console.error('Save failed:', err);
setError(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.');
setError(err instanceof Error ? err.message : t('common.error'));
} finally {
setIsSaving(false);
}
@@ -227,7 +229,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
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">
<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
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">
<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>
)}
@@ -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 items-center gap-2 text-green-600 font-bold justify-center p-2">
<CheckCircle2 size={24} className="text-green-500" />
Erfolgreich gespeichert!
{t('camera.saveSuccess')}
</div>
<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"
>
Jetzt verkosten
{t('camera.tastingNow')}
<ChevronRight size={20} />
</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"
>
<Search size={16} />
Whiskybase-Link suchen
{t('camera.whiskybaseSearch')}
</button>
)}
{isDiscovering && (
<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" />
Suche auf Whiskybase...
{t('camera.searchingWb')}
</div>
)}
{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="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>
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-200 line-clamp-2 leading-snug">
{wbDiscovery.title}
@@ -306,7 +308,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
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"
>
Verknüpfen
{t('common.link')}
</button>
<a
href={wbDiscovery.url}
@@ -314,7 +316,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
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"
>
<ExternalLink size={12} /> Prüfen
<ExternalLink size={12} /> {t('common.check')}
</a>
</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"
>
Später (Zurück zur Liste)
{t('camera.later')}
</button>
</div>
) : 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"
>
<ExternalLink size={20} />
Zum Whisky im Vault
{t('camera.toVault')}
</Link>
) : (
<button
@@ -348,27 +350,27 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
{isSaving ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
Wird gespeichert...
{t('camera.saving')}
</>
) : isQueued ? (
<>
<CheckCircle2 size={20} />
Nächste Flasche
{t('camera.nextBottle')}
</>
) : previewUrl && analysisResult ? (
<>
<CheckCircle2 size={20} />
Im Vault speichern
{t('camera.inVault')}
</>
) : previewUrl ? (
<>
<Upload size={20} />
Neu aufnehmen
{t('camera.newPhoto')}
</>
) : (
<>
<Camera size={20} />
Kamera öffnen
{t('camera.openingCamera')}
</>
)}
</button>
@@ -384,7 +386,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
{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">
<Sparkles size={16} />
Offline! Foto wurde gemerkt wird automatisch analysiert, sobald du wieder Netz hast. 📡
{t('camera.offlineNotice')}
</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 items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm">
<AlertCircle size={16} />
Bereits im Vault!
{t('camera.alreadyInVault')}
</div>
<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>
<button
onClick={() => setMatchingBottle(null)}
className="text-[10px] text-zinc-400 font-black uppercase text-left hover:text-zinc-600"
>
Trotzdem als neue Flasche speichern
{t('camera.saveAnyway')}
</button>
</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 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} />
Bild erfolgreich analysiert
{t('camera.analysisSuccess')}
</div>
{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="flex items-center gap-2 mb-2 md:mb-3 text-amber-600 dark:text-amber-500">
<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 className="space-y-2">
<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>
</div>
<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>
</div>
<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>
</div>
<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>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import React, { useState } from 'react';
import { Sparkles, GlassWater, Dices, X } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
import Link from 'next/link';
interface Bottle {
@@ -16,6 +17,7 @@ interface DramOfTheDayProps {
}
export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
const { t } = useI18n();
const [suggestion, setSuggestion] = useState<Bottle | null>(null);
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');
if (openBottles.length === 0) {
alert('Keine offenen Flaschen gefunden! Vielleicht Zeit für ein neues Tasting? 🥃');
alert(t('home.dramOfDay.noOpenBottles'));
setIsRolling(false);
return;
}
@@ -49,7 +51,7 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
) : (
<Sparkles size={18} />
)}
Dram of the Day
{t('home.dramOfDay.button')}
</button>
{suggestion && (
@@ -68,7 +70,7 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
</div>
<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">
{suggestion.name}
</h2>
@@ -83,13 +85,13 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
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"
>
Flasche anschauen
{t('home.dramOfDay.viewBottle')}
</Link>
<button
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"
>
Nicht heute, noch mal würfeln
{t('home.dramOfDay.rollAgain')}
</button>
</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 { updateBottle } from '@/services/update-bottle';
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
import { useI18n } from '@/i18n/I18nContext';
interface EditBottleFormProps {
bottle: {
@@ -23,6 +24,7 @@ interface EditBottleFormProps {
}
export default function EditBottleForm({ bottle, onComplete }: EditBottleFormProps) {
const { t, locale } = useI18n();
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSearching, setIsSearching] = useState(false);
@@ -60,7 +62,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
if (result.success && result.id) {
setDiscoveryResult({ id: result.id!, url: result.url!, title: result.title! });
} else {
setError(result.error || 'Keinen Treffer gefunden.');
setError(result.error || t('bottle.noMatchFound'));
}
setIsSearching(false);
};
@@ -91,10 +93,10 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
setIsEditing(false);
if (onComplete) onComplete();
} else {
setError(response.error || 'Fehler beim Speichern');
setError(response.error || t('common.error'));
}
} catch (err) {
setError('Etwas ist schiefgelaufen.');
setError(t('common.error'));
} finally {
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"
>
<Edit2 size={16} />
Details bearbeiten
{t('bottle.editDetails')}
</button>
{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">
<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>
@@ -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="flex justify-between items-center mb-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>
<button
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="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
type="text"
value={formData.name}
@@ -145,7 +147,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
/>
</div>
<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
type="text"
value={formData.distillery}
@@ -154,7 +156,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
/>
</div>
<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
type="text"
value={formData.category}
@@ -164,7 +166,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
</div>
<div className="grid grid-cols-2 gap-2">
<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
type="number"
step="0.1"
@@ -174,7 +176,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
/>
</div>
<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
type="number"
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"
>
{isSearching ? <Loader2 size={10} className="animate-spin" /> : <Search size={10} />}
Automatisch suchen
{t('bottle.autoSearch')}
</button>
</label>
<input
@@ -212,7 +214,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
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"
>
ID Übernehmen
{t('bottle.applyId')}
</button>
<a
href={discoveryResult.url}
@@ -220,14 +222,14 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
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"
>
<ExternalLink size={10} /> Prüfen
<ExternalLink size={10} /> {t('common.check')}
</a>
</div>
</div>
)}
</div>
<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
type="number"
step="0.01"
@@ -239,7 +241,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
</div>
<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
type="text"
placeholder="z.B. 2010"
@@ -250,7 +252,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
</div>
<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
type="text"
placeholder="z.B. 2022"
@@ -261,7 +263,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
</div>
<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
type="text"
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"
>
{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>
</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 { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users } from 'lucide-react';
import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext';
interface Session {
id: string;
@@ -13,6 +14,7 @@ interface Session {
}
export default function SessionList() {
const { t, locale } = useI18n();
const supabase = createClientComponentClient();
const [sessions, setSessions] = useState<Session[]>([]);
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">
<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" />
Tasting Sessions
{t('session.title')}
</h3>
<form onSubmit={handleCreateSession} className="flex gap-2 mb-6">
@@ -78,7 +80,7 @@ export default function SessionList() {
type="text"
value={newName}
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"
/>
<button
@@ -96,7 +98,7 @@ export default function SessionList() {
</div>
) : sessions.length === 0 ? (
<div className="text-center py-8 text-zinc-500 text-sm">
Noch keine Sessions geplant.
{t('session.noSessions')}
</div>
) : (
<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">
<span className="flex items-center gap-1">
<Calendar size={12} />
{new Date(session.scheduled_at).toLocaleDateString('de-DE')}
{new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</span>
{session.participant_count! > 0 && (
<span className="flex items-center gap-1">
<Users size={12} />
{session.participant_count} Teilnehmer
{session.participant_count} {t('tasting.participants')}
</span>
)}
</div>

View File

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

View File

@@ -3,6 +3,7 @@
import React, { useState } from 'react';
import { updateBottleStatus } from '@/services/update-bottle-status';
import { Loader2, Package, Play, CheckCircle, FlaskConical } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface StatusSwitcherProps {
bottleId: string;
@@ -10,6 +11,7 @@ interface StatusSwitcherProps {
}
export default function StatusSwitcher({ bottleId, currentStatus }: StatusSwitcherProps) {
const { t } = useI18n();
const [status, setStatus] = useState(currentStatus);
const [loading, setLoading] = useState(false);
@@ -22,26 +24,26 @@ export default function StatusSwitcher({ bottleId, currentStatus }: StatusSwitch
if (result.success) {
setStatus(newStatus);
} else {
alert(result.error || 'Fehler beim Aktualisieren des Status');
alert(result.error || t('common.error'));
}
} catch (err) {
alert('Ein unerwarteter Fehler ist aufgetreten');
alert(t('common.error'));
} finally {
setLoading(false);
}
};
const options = [
{ id: 'sealed', label: 'Versiegelt', icon: Package, color: 'hover:bg-blue-500' },
{ id: 'open', label: 'Offen', icon: Play, color: 'hover:bg-amber-500' },
{ id: 'sampled', label: 'Sampled', icon: FlaskConical, color: 'hover:bg-purple-500' },
{ id: 'empty', label: 'Leer', icon: CheckCircle, color: 'hover:bg-zinc-500' },
{ id: 'sealed', label: t('bottle.status.sealed'), icon: Package, color: 'hover:bg-blue-500' },
{ id: 'open', label: t('bottle.status.open'), icon: Play, color: 'hover:bg-amber-500' },
{ id: 'sampled', label: t('bottle.status.sampled'), icon: FlaskConical, color: 'hover:bg-purple-500' },
{ id: 'empty', label: t('bottle.status.empty'), icon: CheckCircle, color: 'hover:bg-zinc-500' },
] as const;
return (
<div className="space-y-3">
<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} />}
</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">

View File

@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
import { saveTasting } from '@/services/save-tasting';
import { Loader2, Send, Star, Users, Check } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useI18n } from '@/i18n/I18nContext';
interface Buddy {
id: string;
@@ -16,6 +17,7 @@ interface TastingNoteFormProps {
}
export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteFormProps) {
const { t } = useI18n();
const supabase = createClientComponentClient();
const [rating, setRating] = useState(85);
const [nose, setNose] = useState('');
@@ -78,10 +80,10 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
setSelectedBuddyIds([]);
// We don't need to manually refresh because of revalidatePath in the server action
} else {
setError(result.error || 'Fehler beim Speichern');
setError(result.error || t('common.error'));
}
} catch (err) {
setError('Ein unerwarteter Fehler ist aufgetreten');
setError(t('common.error'));
} finally {
setLoading(false);
}
@@ -93,7 +95,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
<div className="flex items-center justify-between">
<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" />
Rating
{t('tasting.rating')}
</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>
</div>
@@ -113,7 +115,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
</div>
<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">
<button
type="button"
@@ -139,33 +141,33 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
</div>
<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
value={nose}
onChange={(e) => setNose(e.target.value)}
placeholder="Aromen in der Nase..."
placeholder={t('tasting.notesPlaceholder')}
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"
/>
</div>
<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
value={palate}
onChange={(e) => setPalate(e.target.value)}
placeholder="Geschmack am Gaumen..."
placeholder={t('tasting.notesPlaceholder')}
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"
/>
</div>
<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
value={finish}
onChange={(e) => setFinish(e.target.value)}
placeholder="Nachhall..."
placeholder={t('tasting.notesPlaceholder')}
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"
/>
@@ -175,7 +177,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
<div className="space-y-3">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
<Users size={14} className="text-amber-500" />
Gekostet mit (Buddies)
{t('tasting.participants')}
</label>
<div className="flex flex-wrap gap-2">
{buddies.map((buddy) => (
@@ -210,7 +212,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
{loading ? <Loader2 className="animate-spin" size={18} /> : (
<>
<Send size={16} />
Note Speichern
{t('tasting.saveTasting')}
</>
)}
</button>