- 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
290 lines
15 KiB
TypeScript
290 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import { Edit2, Save, X, Info, Tag, FlaskConical, CircleDollarSign, Search, Loader2, ExternalLink } from 'lucide-react';
|
|
import { updateBottle } from '@/services/update-bottle';
|
|
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
|
|
import { useI18n } from '@/i18n/I18nContext';
|
|
|
|
interface EditBottleFormProps {
|
|
bottle: {
|
|
id: string;
|
|
name: string;
|
|
distillery: string;
|
|
category: string;
|
|
abv: number;
|
|
age: number;
|
|
whiskybase_id: string | null;
|
|
purchase_price?: number | null;
|
|
distilled_at?: string | null;
|
|
bottled_at?: string | null;
|
|
batch_info?: string | null;
|
|
};
|
|
onComplete?: () => void;
|
|
}
|
|
|
|
export default function EditBottleForm({ bottle, onComplete }: EditBottleFormProps) {
|
|
const { t, locale } = useI18n();
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [discoveryResult, setDiscoveryResult] = useState<{ id: string; url: string; title: string } | null>(null);
|
|
|
|
const [formData, setFormData] = useState({
|
|
name: bottle.name,
|
|
distillery: bottle.distillery || '',
|
|
category: bottle.category || '',
|
|
abv: bottle.abv || 0,
|
|
age: bottle.age || 0,
|
|
whiskybase_id: bottle.whiskybase_id || '',
|
|
purchase_price: bottle.purchase_price || '',
|
|
distilled_at: bottle.distilled_at || '',
|
|
bottled_at: bottle.bottled_at || '',
|
|
batch_info: bottle.batch_info || '',
|
|
});
|
|
|
|
const handleDiscover = async () => {
|
|
setIsSearching(true);
|
|
setError(null);
|
|
setDiscoveryResult(null);
|
|
|
|
const result = await discoverWhiskybaseId({
|
|
name: formData.name,
|
|
distillery: formData.distillery,
|
|
abv: formData.abv,
|
|
age: formData.age,
|
|
distilled_at: formData.distilled_at || undefined,
|
|
bottled_at: formData.bottled_at || undefined,
|
|
batch_info: formData.batch_info || undefined,
|
|
});
|
|
|
|
if (result.success && result.id) {
|
|
setDiscoveryResult({ id: result.id!, url: result.url!, title: result.title! });
|
|
} else {
|
|
setError(result.error || t('bottle.noMatchFound'));
|
|
}
|
|
setIsSearching(false);
|
|
};
|
|
|
|
const applyDiscovery = () => {
|
|
if (discoveryResult) {
|
|
setFormData({ ...formData, whiskybase_id: discoveryResult.id });
|
|
setDiscoveryResult(null);
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setIsSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await updateBottle(bottle.id, {
|
|
...formData,
|
|
abv: Number(formData.abv),
|
|
age: formData.age ? Number(formData.age) : undefined,
|
|
purchase_price: formData.purchase_price ? Number(formData.purchase_price) : undefined,
|
|
distilled_at: formData.distilled_at || undefined,
|
|
bottled_at: formData.bottled_at || undefined,
|
|
batch_info: formData.batch_info || undefined,
|
|
});
|
|
|
|
if (response.success) {
|
|
setIsEditing(false);
|
|
if (onComplete) onComplete();
|
|
} else {
|
|
setError(response.error || t('common.error'));
|
|
}
|
|
} catch (err) {
|
|
setError(t('common.error'));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
if (!isEditing) {
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<button
|
|
onClick={() => setIsEditing(true)}
|
|
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} />
|
|
{t('bottle.editDetails')}
|
|
</button>
|
|
{bottle.purchase_price && (
|
|
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/10 text-green-700 dark:text-green-400 rounded-xl text-sm font-bold border border-green-100 dark:border-green-900/30 w-fit">
|
|
<CircleDollarSign size={16} />
|
|
{t('bottle.priceLabel')}: {parseFloat(bottle.purchase_price.toString()).toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR' })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 bg-white dark:bg-zinc-900 border border-amber-500/30 rounded-3xl shadow-xl space-y-4 animate-in zoom-in-95 duration-200">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h3 className="text-lg font-black text-amber-600 uppercase tracking-widest flex items-center gap-2">
|
|
<Info size={18} /> {t('bottle.editTitle')}
|
|
</h3>
|
|
<button
|
|
onClick={() => setIsEditing(false)}
|
|
className="text-zinc-400 hover:text-zinc-600 p-1"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.nameLabel')}</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.distilleryLabel')}</label>
|
|
<input
|
|
type="text"
|
|
value={formData.distillery}
|
|
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.categoryLabel')}</label>
|
|
<input
|
|
type="text"
|
|
value={formData.category}
|
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.abvLabel')}</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={formData.abv}
|
|
onChange={(e) => setFormData({ ...formData, abv: parseFloat(e.target.value) })}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.ageLabel')}</label>
|
|
<input
|
|
type="number"
|
|
value={formData.age}
|
|
onChange={(e) => setFormData({ ...formData, age: parseInt(e.target.value) })}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1 flex justify-between items-center">
|
|
<span>Whiskybase ID</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleDiscover}
|
|
disabled={isSearching}
|
|
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} />}
|
|
{t('bottle.autoSearch')}
|
|
</button>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.whiskybase_id}
|
|
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
{discoveryResult && (
|
|
<div className="mt-2 p-3 bg-zinc-50 dark:bg-zinc-800/50 border border-amber-500/20 rounded-xl animate-in fade-in slide-in-from-top-2">
|
|
<p className="text-[10px] text-zinc-500 mb-2">Treffer gefunden:</p>
|
|
<p className="text-[11px] font-bold text-zinc-800 dark:text-zinc-200 mb-2 truncate">{discoveryResult.title}</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
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"
|
|
>
|
|
{t('bottle.applyId')}
|
|
</button>
|
|
<a
|
|
href={discoveryResult.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="px-3 py-1.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors flex items-center gap-1"
|
|
>
|
|
<ExternalLink size={10} /> {t('common.check')}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.priceLabel')} (€)</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
placeholder="0.00"
|
|
value={formData.purchase_price}
|
|
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
|
|
className="w-full px-4 py-2 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-900/30 rounded-xl outline-none focus:ring-2 focus:ring-amber-500 font-bold text-amber-700 dark:text-amber-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.distilledLabel')}</label>
|
|
<input
|
|
type="text"
|
|
placeholder="z.B. 2010"
|
|
value={formData.distilled_at}
|
|
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.bottledLabel')}</label>
|
|
<input
|
|
type="text"
|
|
placeholder="z.B. 2022"
|
|
value={formData.bottled_at}
|
|
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1 md:col-span-2">
|
|
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">{t('bottle.batchLabel')}</label>
|
|
<input
|
|
type="text"
|
|
placeholder="z.B. Batch 12 oder L-Code"
|
|
value={formData.batch_info}
|
|
onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })}
|
|
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <p className="text-red-500 text-xs italic">{error}</p>}
|
|
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={isSaving}
|
|
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} />}
|
|
{t('bottle.saveChanges')}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|