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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
35
src/components/LanguageSwitcher.tsx
Normal file
35
src/components/LanguageSwitcher.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
69
src/i18n/I18nContext.tsx
Normal 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
162
src/i18n/de.ts
Normal 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
162
src/i18n/en.ts
Normal 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
160
src/i18n/types.ts
Normal 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;
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user