feat: unify tasting form refactor & align db schema status levels
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff } from 'lucide-react';
|
||||
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { updateBottle } from '@/services/update-bottle';
|
||||
import { getStorageUrl } from '@/lib/supabase';
|
||||
import TastingNoteForm from '@/components/TastingNoteForm';
|
||||
import TastingList from '@/components/TastingList';
|
||||
@@ -18,9 +20,37 @@ interface BottleDetailsProps {
|
||||
}
|
||||
|
||||
export default function BottleDetails({ bottleId, sessionId, userId }: BottleDetailsProps) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { bottle, tastings, loading, error, isOffline } = useBottleData(bottleId);
|
||||
|
||||
// Quick Collection States
|
||||
const [price, setPrice] = React.useState<string>('');
|
||||
const [status, setStatus] = React.useState<string>('sealed');
|
||||
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||
const [isFormVisible, setIsFormVisible] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (bottle) {
|
||||
setPrice(bottle.purchase_price?.toString() || '');
|
||||
setStatus((bottle as any).status || 'sealed');
|
||||
}
|
||||
}, [bottle]);
|
||||
|
||||
const handleQuickUpdate = async (newPrice?: string, newStatus?: string) => {
|
||||
if (isOffline) return;
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateBottle(bottleId, {
|
||||
purchase_price: newPrice !== undefined ? (newPrice ? parseFloat(newPrice) : null) : (price ? parseFloat(price) : null),
|
||||
status: newStatus !== undefined ? newStatus : status
|
||||
} as any);
|
||||
} catch (err) {
|
||||
console.error('Quick update failed:', err);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-4">
|
||||
@@ -141,6 +171,53 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Collection Card */}
|
||||
<div className="md:col-span-2 p-5 bg-orange-600/5 rounded-3xl border border-orange-600/20 shadow-xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-orange-600 flex items-center gap-2">
|
||||
<Package size={14} /> Sammlungs-Status
|
||||
</h3>
|
||||
{isUpdating && <Loader2 size={12} className="animate-spin text-orange-600" />}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Price */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-bold uppercase text-zinc-500 ml-1">Einkaufspreis</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
onBlur={() => handleQuickUpdate(price)}
|
||||
placeholder="0.00"
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl pl-3 pr-8 py-2 text-sm font-bold text-orange-500 focus:outline-none focus:border-orange-600 transition-all"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] font-bold text-zinc-700">€</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-bold uppercase text-zinc-500 ml-1">Flaschenstatus</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
handleQuickUpdate(undefined, e.target.value);
|
||||
}}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3 py-2 text-sm font-bold text-zinc-200 focus:outline-none focus:border-orange-600 appearance-none transition-all"
|
||||
>
|
||||
<option value="sealed">Versiegelt</option>
|
||||
<option value="open">Offen</option>
|
||||
<option value="sampled">Sample</option>
|
||||
<option value="empty">Leer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bottle.batch_info && (
|
||||
<div className="p-4 bg-zinc-800/30 rounded-2xl border border-dashed border-zinc-700/50 md:col-span-1">
|
||||
<div className="flex items-center gap-2 text-zinc-500 text-[10px] font-bold uppercase mb-1">
|
||||
@@ -191,11 +268,39 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8 items-start">
|
||||
{/* Form */}
|
||||
<div className="lg:col-span-1 border border-zinc-800 rounded-3xl p-6 bg-zinc-900/50 md:sticky md:top-24">
|
||||
<h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-orange-600 uppercase tracking-widest">
|
||||
<Droplets size={20} /> Dram bewerten
|
||||
</h3>
|
||||
<TastingNoteForm bottleId={bottle.id} sessionId={sessionId} />
|
||||
<div className="lg:col-span-1 space-y-4 md:sticky md:top-24">
|
||||
<button
|
||||
onClick={() => setIsFormVisible(!isFormVisible)}
|
||||
className={`w-full p-6 rounded-3xl border flex items-center justify-between transition-all group ${isFormVisible ? 'bg-orange-600 border-orange-600 text-white shadow-xl shadow-orange-950/40' : 'bg-zinc-900/50 border-zinc-800 text-zinc-400 hover:border-orange-500/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isFormVisible ? <Plus size={20} className="rotate-45 transition-transform" /> : <Plus size={20} className="text-orange-600 transition-transform" />}
|
||||
<span className={`text-sm font-black uppercase tracking-widest ${isFormVisible ? 'text-white' : 'text-zinc-100'}`}>Neue Tasting Note</span>
|
||||
</div>
|
||||
<ChevronDown size={20} className={`transition-transform duration-300 ${isFormVisible ? 'rotate-180' : 'opacity-0'}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isFormVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, height: 0 }}
|
||||
animate={{ opacity: 1, y: 0, height: 'auto' }}
|
||||
exit={{ opacity: 0, y: -20, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="border border-zinc-800 rounded-3xl p-6 bg-zinc-900/50">
|
||||
<h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-orange-600 uppercase tracking-widest">
|
||||
<Droplets size={20} /> Dram bewerten
|
||||
</h3>
|
||||
<TastingNoteForm
|
||||
bottleId={bottle.id}
|
||||
sessionId={sessionId}
|
||||
onSuccess={() => setIsFormVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
|
||||
@@ -545,6 +545,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
|
||||
activeSessionName={activeSession?.name}
|
||||
activeSessionId={activeSession?.id}
|
||||
isEnriching={isEnriching}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
{isAdmin && perfMetrics && (
|
||||
<div className="absolute top-24 left-6 right-6 z-50 p-3 bg-zinc-950/80 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[9px] font-mono text-white/90 shadow-xl overflow-x-auto">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { db } from '@/lib/db';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
|
||||
import TastingFormBody from './TastingFormBody';
|
||||
|
||||
interface TastingEditorProps {
|
||||
bottleMetadata: BottleMetadata;
|
||||
@@ -19,24 +20,22 @@ interface TastingEditorProps {
|
||||
activeSessionName?: string;
|
||||
activeSessionId?: string;
|
||||
isEnriching?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId, isEnriching }: TastingEditorProps) {
|
||||
export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId, isEnriching, defaultExpanded = false }: TastingEditorProps) {
|
||||
const { t } = useI18n();
|
||||
const supabase = createClient();
|
||||
const [status, setStatus] = React.useState<string>('sealed');
|
||||
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||
const [isFormVisible, setIsFormVisible] = React.useState(false);
|
||||
|
||||
const [rating, setRating] = useState(85);
|
||||
const [noseNotes, setNoseNotes] = useState('');
|
||||
const [palateNotes, setPalateNotes] = useState('');
|
||||
const [finishNotes, setFinishNotes] = useState('');
|
||||
const [isSample, setIsSample] = useState(false);
|
||||
|
||||
// Sliders for evaluation
|
||||
const [noseScore, setNoseScore] = useState(50);
|
||||
const [tasteScore, setTasteScore] = useState(50);
|
||||
const [finishScore, setFinishScore] = useState(50);
|
||||
const [complexityScore, setComplexityScore] = useState(75);
|
||||
const [balanceScore, setBalanceScore] = useState(85);
|
||||
|
||||
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
|
||||
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
|
||||
const [finishTagIds, setFinishTagIds] = useState<string[]>([]);
|
||||
@@ -63,6 +62,12 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
const [whiskybaseError, setWhiskybaseError] = useState<string | null>(null);
|
||||
const [textureTagIds, setTextureTagIds] = useState<string[]>([]);
|
||||
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
||||
const [bottlePurchasePrice, setBottlePurchasePrice] = useState(bottleMetadata.purchase_price?.toString() || '');
|
||||
|
||||
// Section collapse states
|
||||
const [isNoseExpanded, setIsNoseExpanded] = useState(defaultExpanded);
|
||||
const [isPalateExpanded, setIsPalateExpanded] = useState(defaultExpanded);
|
||||
const [isFinishExpanded, setIsFinishExpanded] = useState(defaultExpanded);
|
||||
|
||||
const buddies = useLiveQuery(() => db.cache_buddies.toArray(), [], [] as any[]);
|
||||
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
|
||||
@@ -173,11 +178,6 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
buddy_ids: selectedBuddyIds,
|
||||
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds, ...textureTagIds],
|
||||
// Visual data for ResultCard
|
||||
nose: noseScore,
|
||||
taste: tasteScore,
|
||||
finish: finishScore,
|
||||
complexity: complexityScore,
|
||||
balance: balanceScore,
|
||||
// Edited bottle metadata
|
||||
bottleMetadata: {
|
||||
...bottleMetadata,
|
||||
@@ -193,6 +193,8 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
distilled_at: bottleDistilledAt || null,
|
||||
bottled_at: bottleBottledAt || null,
|
||||
whiskybaseId: whiskybaseId || null,
|
||||
purchase_price: bottlePurchasePrice ? parseFloat(bottlePurchasePrice) : null,
|
||||
status: status,
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -237,6 +239,13 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
<p className="text-zinc-500 text-[10px] font-bold uppercase tracking-widest leading-none">
|
||||
{bottleCategory || 'Whisky'} {bottleAbv ? `• ${bottleAbv}%` : ''} {bottleAge ? `• ${bottleAge}y` : ''}
|
||||
</p>
|
||||
{bottlePurchasePrice && (
|
||||
<div className="mt-3 flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-black text-orange-600 bg-orange-600/10 px-2 py-0.5 rounded-md border border-orange-600/20">
|
||||
EK: {bottlePurchasePrice}€
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -412,6 +421,24 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Bottle Status */}
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-orange-600 transition-colors"
|
||||
>
|
||||
<option value="sealed">Versiegelt</option>
|
||||
<option value="open">Offen</option>
|
||||
<option value="sampled">Sample</option>
|
||||
<option value="empty">Leer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Whiskybase Discovery */}
|
||||
{isDiscoveringWb && (
|
||||
<div className="flex items-center gap-2 p-3 bg-zinc-900 rounded-lg border border-zinc-800">
|
||||
@@ -479,211 +506,32 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Rating Slider */}
|
||||
<div className="space-y-6 bg-zinc-900 p-8 rounded-3xl border border-zinc-800 shadow-inner relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
|
||||
<Zap size={120} className="text-orange-500" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between relative z-10">
|
||||
<label className="text-xs font-bold text-zinc-500 uppercase tracking-[0.2em] flex items-center gap-2">
|
||||
<Star size={14} className="text-orange-500 fill-orange-500" />
|
||||
{t('tasting.rating')}
|
||||
</label>
|
||||
<span className="text-4xl font-bold text-orange-600 tracking-tighter">{rating}<span className="text-zinc-700 text-sm ml-1">/100</span></span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={rating}
|
||||
onChange={(e) => setRating(parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-zinc-800 rounded-full appearance-none cursor-pointer accent-orange-600 transition-all"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-zinc-500 font-bold uppercase tracking-widest px-1 relative z-10">
|
||||
<span>Swill</span>
|
||||
<span>Dram</span>
|
||||
<span>Legendary</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2 relative z-10">
|
||||
{['Bottle', 'Sample'].map(type => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setIsSample(type === 'Sample')}
|
||||
className={`flex-1 py-4 rounded-xl text-[10px] font-bold uppercase tracking-widest border transition-all ${(type === 'Sample' ? isSample : !isSample)
|
||||
? 'bg-orange-600 border-orange-600 text-white shadow-lg'
|
||||
: 'bg-transparent border-zinc-800 text-zinc-500 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Evaluation Sliders Area */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<CustomSlider label="Complexity" value={complexityScore} onChange={setComplexityScore} icon={<Sparkles size={18} />} />
|
||||
<CustomSlider label="Balance" value={balanceScore} onChange={setBalanceScore} icon={<Check size={18} />} />
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-12 pb-12">
|
||||
{/* Nose Section */}
|
||||
<div className="space-y-8 bg-zinc-900/50 p-8 rounded-3xl border border-zinc-800">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="w-14 h-14 rounded-2xl bg-orange-500/10 flex items-center justify-center text-orange-500 shrink-0 border border-orange-500/20 shadow-xl">
|
||||
<Wind size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-zinc-50 uppercase tracking-widest leading-none">{t('tasting.nose')}</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest mt-2 px-0.5">Aroma & Bouquet</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<CustomSlider label="Nose Intensity" value={noseScore} onChange={setNoseScore} icon={<Sparkles size={16} />} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-bold text-zinc-700 uppercase tracking-widest px-1">Tags</p>
|
||||
<TagSelector
|
||||
category="nose"
|
||||
selectedTagIds={noseTagIds}
|
||||
onToggleTag={(id) => setNoseTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
isLoading={isEnriching}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-bold text-zinc-700 uppercase tracking-widest px-1">Eigene Notizen</p>
|
||||
<textarea
|
||||
value={noseNotes}
|
||||
onChange={(e) => setNoseNotes(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder') || "Wie riecht er?..."}
|
||||
className="w-full p-6 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm text-zinc-200 focus:ring-1 focus:ring-orange-500 outline-none min-h-[120px] resize-none transition-all placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palate Section */}
|
||||
<div className="space-y-8 bg-zinc-900/50 p-8 rounded-3xl border border-zinc-800">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="w-14 h-14 rounded-2xl bg-orange-500/10 flex items-center justify-center text-orange-500 shrink-0 border border-orange-500/20 shadow-xl">
|
||||
<Utensils size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-zinc-50 uppercase tracking-widest leading-none">{t('tasting.palate')}</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest mt-2 px-0.5">Geschmack & Textur</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<CustomSlider label="Taste Impact" value={tasteScore} onChange={setTasteScore} icon={<Sparkles size={16} />} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-bold text-zinc-700 uppercase tracking-widest px-1">Tags</p>
|
||||
<TagSelector
|
||||
category="taste"
|
||||
selectedTagIds={palateTagIds}
|
||||
onToggleTag={(id) => setPalateTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
isLoading={isEnriching}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-bold text-zinc-700 uppercase tracking-widest px-1">Eigene Notizen</p>
|
||||
<textarea
|
||||
value={palateNotes}
|
||||
onChange={(e) => setPalateNotes(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder') || "Wie schmeckt er?..."}
|
||||
className="w-full p-6 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm text-zinc-200 focus:ring-1 focus:ring-orange-500 outline-none min-h-[120px] resize-none transition-all placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Finish Section */}
|
||||
<div className="space-y-8 bg-zinc-900/50 p-8 rounded-3xl border border-zinc-800">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="w-14 h-14 rounded-2xl bg-orange-500/10 flex items-center justify-center text-orange-500 shrink-0 border border-orange-500/20 shadow-xl">
|
||||
<Droplets size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-zinc-50 uppercase tracking-widest leading-none">{t('tasting.finish')}</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest mt-2 px-0.5">Abgang & Nachklang</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<CustomSlider label="Finish Duration" value={finishScore} onChange={setFinishScore} icon={<Sparkles size={16} />} />
|
||||
|
||||
<div className="space-y-6 pt-4 border-t border-zinc-800">
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-bold text-zinc-700 uppercase tracking-widest px-1">Aroma Tags</p>
|
||||
<TagSelector
|
||||
category="finish"
|
||||
selectedTagIds={finishTagIds}
|
||||
onToggleTag={(id) => setFinishTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-bold text-zinc-700 uppercase tracking-widest px-1">Gefühl & Textur</p>
|
||||
<TagSelector
|
||||
category="texture"
|
||||
selectedTagIds={textureTagIds}
|
||||
onToggleTag={(id) => setTextureTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] font-bold text-zinc-700 uppercase tracking-widest px-1">Eigene Notizen</p>
|
||||
<textarea
|
||||
value={finishNotes}
|
||||
onChange={(e) => setFinishNotes(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder') || "Der bleibende Eindruck..."}
|
||||
className="w-full p-6 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm text-zinc-200 focus:ring-1 focus:ring-orange-500 outline-none min-h-[120px] resize-none transition-all placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buddy Selection */}
|
||||
{buddies && buddies.length > 0 && (
|
||||
<div className="space-y-8 bg-zinc-900/50 p-8 rounded-3xl border border-zinc-800">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="w-14 h-14 rounded-2xl bg-orange-500/10 flex items-center justify-center text-orange-500 shrink-0 border border-orange-500/20 shadow-xl">
|
||||
<Users size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-zinc-50 uppercase tracking-widest leading-none">Mit wem trinkst du?</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest mt-2 px-0.5">Gesellschaft & Buddies</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{buddies.map(buddy => (
|
||||
<button
|
||||
key={buddy.id}
|
||||
onClick={() => toggleBuddy(buddy.id)}
|
||||
className={`px-5 py-3 rounded-xl text-[10px] font-bold uppercase transition-all border flex items-center gap-2 ${selectedBuddyIds.includes(buddy.id)
|
||||
? 'bg-orange-600 border-orange-600 text-white shadow-lg'
|
||||
: 'bg-transparent border-zinc-800 text-zinc-500 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{selectedBuddyIds.includes(buddy.id) && <Check size={14} />}
|
||||
{buddy.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Shared Tasting Form Body */}
|
||||
<TastingFormBody
|
||||
rating={rating}
|
||||
setRating={setRating}
|
||||
isSample={isSample}
|
||||
setIsSample={setIsSample}
|
||||
nose={noseNotes}
|
||||
setNose={setNoseNotes}
|
||||
noseTagIds={noseTagIds}
|
||||
setNoseTagIds={setNoseTagIds}
|
||||
palate={palateNotes}
|
||||
setPalate={setPalateNotes}
|
||||
palateTagIds={palateTagIds}
|
||||
setPalateTagIds={setPalateTagIds}
|
||||
finish={finishNotes}
|
||||
setFinish={setFinishNotes}
|
||||
finishTagIds={finishTagIds}
|
||||
setFinishTagIds={setFinishTagIds}
|
||||
selectedBuddyIds={selectedBuddyIds}
|
||||
setSelectedBuddyIds={setSelectedBuddyIds}
|
||||
availableBuddies={buddies || []}
|
||||
suggestedTags={suggestedTags}
|
||||
suggestedCustomTags={suggestedCustomTags}
|
||||
defaultExpanded={defaultExpanded}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -696,7 +544,6 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
||||
>
|
||||
<Send size={20} />
|
||||
{t('tasting.saveTasting')}
|
||||
<div className="ml-auto bg-black/20 px-3 py-1 rounded-full text-[10px] font-bold text-white/60">{rating}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
319
src/components/TastingFormBody.tsx
Normal file
319
src/components/TastingFormBody.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Star, Wind, Utensils, Droplets, Users, Check, ChevronDown, CheckCircle2 } from 'lucide-react';
|
||||
import TagSelector from './TagSelector';
|
||||
|
||||
interface TastingFormBodyProps {
|
||||
rating: number;
|
||||
setRating: (val: number) => void;
|
||||
isSample: boolean;
|
||||
setIsSample: (val: boolean) => void;
|
||||
|
||||
nose: string;
|
||||
setNose: (val: string) => void;
|
||||
noseTagIds: string[];
|
||||
setNoseTagIds: (ids: string[]) => void;
|
||||
|
||||
palate: string;
|
||||
setPalate: (val: string) => void;
|
||||
palateTagIds: string[];
|
||||
setPalateTagIds: (ids: string[]) => void;
|
||||
|
||||
finish: string;
|
||||
setFinish: (val: string) => void;
|
||||
finishTagIds: string[];
|
||||
setFinishTagIds: (ids: string[]) => void;
|
||||
|
||||
selectedBuddyIds: string[];
|
||||
setSelectedBuddyIds: (ids: string[]) => void;
|
||||
availableBuddies: Array<{ id: string; name: string }>;
|
||||
|
||||
suggestedTags?: string[];
|
||||
suggestedCustomTags?: string[];
|
||||
|
||||
defaultExpanded?: boolean;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export default function TastingFormBody({
|
||||
rating, setRating,
|
||||
isSample, setIsSample,
|
||||
nose, setNose, noseTagIds, setNoseTagIds,
|
||||
palate, setPalate, palateTagIds, setPalateTagIds,
|
||||
finish, setFinish, finishTagIds, setFinishTagIds,
|
||||
selectedBuddyIds, setSelectedBuddyIds, availableBuddies,
|
||||
suggestedTags = [],
|
||||
suggestedCustomTags = [],
|
||||
defaultExpanded = false,
|
||||
t
|
||||
}: TastingFormBodyProps) {
|
||||
const [isNoseExpanded, setIsNoseExpanded] = React.useState(defaultExpanded);
|
||||
const [isPalateExpanded, setIsPalateExpanded] = React.useState(defaultExpanded);
|
||||
const [isFinishExpanded, setIsFinishExpanded] = React.useState(defaultExpanded);
|
||||
|
||||
const toggleBuddy = (id: string) => {
|
||||
setSelectedBuddyIds(selectedBuddyIds.includes(id)
|
||||
? selectedBuddyIds.filter(bid => bid !== id)
|
||||
: [...selectedBuddyIds, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 pb-10">
|
||||
{/* Rating Section */}
|
||||
<div className="space-y-5 bg-zinc-900/40 p-6 rounded-3xl border border-zinc-800/50 shadow-inner">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-2xl bg-orange-500/10 text-orange-500 border border-orange-500/20">
|
||||
<Star size={20} className="fill-orange-500" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">{t('tasting.rating')}</span>
|
||||
<p className="text-[9px] text-zinc-600 font-bold uppercase mt-0.5">Persönliche Wertung</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-4xl font-black text-orange-500 tracking-tighter leading-none">{rating}</span>
|
||||
<span className="text-[10px] font-black text-zinc-600 uppercase mt-1">/ 100 PTS</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={rating}
|
||||
onChange={(e) => setRating(parseInt(e.target.value))}
|
||||
className="w-full h-1.5 bg-zinc-800 rounded-full appearance-none cursor-pointer accent-orange-600 transition-all hover:accent-orange-500"
|
||||
/>
|
||||
<div className="flex justify-between px-1">
|
||||
<span className="text-[8px] font-black text-zinc-700 uppercase tracking-widest">Swill</span>
|
||||
<div className="h-1 w-1 rounded-full bg-zinc-800 mt-1" />
|
||||
<span className="text-[8px] font-black text-zinc-700 uppercase tracking-widest">Dram</span>
|
||||
<div className="h-1 w-1 rounded-full bg-zinc-800 mt-1" />
|
||||
<span className="text-[8px] font-black text-zinc-400 uppercase tracking-widest">Legend</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottle / Sample Toggle */}
|
||||
<div className="grid grid-cols-2 gap-3 p-1.5 bg-zinc-900/50 rounded-2xl border border-zinc-800/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSample(false)}
|
||||
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all flex items-center justify-center gap-2 ${!isSample ? 'bg-zinc-800 text-orange-500 shadow-xl ring-1 ring-white/5' : 'text-zinc-600 hover:text-zinc-400'}`}
|
||||
>
|
||||
<CheckCircle2 size={14} className={!isSample ? 'opacity-100' : 'opacity-0'} />
|
||||
Flasche
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSample(true)}
|
||||
className={`py-3 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all flex items-center justify-center gap-2 ${isSample ? 'bg-zinc-800 text-orange-500 shadow-xl ring-1 ring-white/5' : 'text-zinc-600 hover:text-zinc-400'}`}
|
||||
>
|
||||
<CheckCircle2 size={14} className={isSample ? 'opacity-100' : 'opacity-0'} />
|
||||
Sample
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sensoric Sections */}
|
||||
<div className="space-y-4">
|
||||
{/* Nose Section */}
|
||||
<div className={`bg-zinc-900 border transition-all duration-300 overflow-hidden ${isNoseExpanded ? 'rounded-[32px] border-orange-500/20' : 'rounded-2xl border-zinc-800'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsNoseExpanded(!isNoseExpanded)}
|
||||
className={`w-full px-6 py-5 flex items-center justify-between transition-colors ${isNoseExpanded ? 'bg-zinc-950/50' : 'hover:bg-zinc-800/50'}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-2.5 rounded-xl border transition-all ${isNoseExpanded ? 'bg-orange-600/10 text-orange-500 border-orange-500/20' : 'bg-zinc-800/50 text-zinc-500 border-transparent'}`}>
|
||||
<Wind size={20} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className={`text-xs font-black uppercase tracking-[0.2em] leading-none ${isNoseExpanded ? 'text-zinc-50' : 'text-zinc-500'}`}>
|
||||
{t('tasting.nose')}
|
||||
</h3>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{(noseTagIds.length > 0 || nose) ? (
|
||||
<span className="flex items-center gap-1.5 text-[9px] font-black text-orange-500 uppercase tracking-widest">
|
||||
<Check size={10} /> {noseTagIds.length} Tags {nose ? '+ Notiz' : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-zinc-600 font-bold uppercase tracking-widest">Aroma & Eindruck</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown size={20} className={`text-zinc-700 transition-transform duration-300 ${isNoseExpanded ? 'rotate-180 text-orange-500' : ''}`} />
|
||||
</button>
|
||||
{isNoseExpanded && (
|
||||
<div className="p-6 space-y-6 animate-in slide-in-from-top-4 duration-300">
|
||||
<TagSelector
|
||||
category="nose"
|
||||
selectedTagIds={noseTagIds}
|
||||
onToggleTag={(id) => setNoseTagIds(noseTagIds.includes(id) ? noseTagIds.filter(t => t !== id) : [...noseTagIds, id])}
|
||||
label=""
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">Freitext Notizen</label>
|
||||
<textarea
|
||||
value={nose}
|
||||
onChange={(e) => setNose(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Palate Section */}
|
||||
<div className={`bg-zinc-900 border transition-all duration-300 overflow-hidden ${isPalateExpanded ? 'rounded-[32px] border-orange-500/20' : 'rounded-2xl border-zinc-800'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPalateExpanded(!isPalateExpanded)}
|
||||
className={`w-full px-6 py-5 flex items-center justify-between transition-colors ${isPalateExpanded ? 'bg-zinc-950/50' : 'hover:bg-zinc-800/50'}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-2.5 rounded-xl border transition-all ${isPalateExpanded ? 'bg-orange-600/10 text-orange-500 border-orange-500/20' : 'bg-zinc-800/50 text-zinc-500 border-transparent'}`}>
|
||||
<Utensils size={20} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className={`text-xs font-black uppercase tracking-[0.2em] leading-none ${isPalateExpanded ? 'text-zinc-50' : 'text-zinc-500'}`}>
|
||||
{t('tasting.palate')}
|
||||
</h3>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{(palateTagIds.length > 0 || palate) ? (
|
||||
<span className="flex items-center gap-1.5 text-[9px] font-black text-orange-500 uppercase tracking-widest">
|
||||
<Check size={10} /> {palateTagIds.length} Tags {palate ? '+ Notiz' : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-zinc-600 font-bold uppercase tracking-widest">Geschmack & Körper</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown size={20} className={`text-zinc-700 transition-transform duration-300 ${isPalateExpanded ? 'rotate-180 text-orange-500' : ''}`} />
|
||||
</button>
|
||||
{isPalateExpanded && (
|
||||
<div className="p-6 space-y-6 animate-in slide-in-from-top-4 duration-300">
|
||||
<TagSelector
|
||||
category="taste"
|
||||
selectedTagIds={palateTagIds}
|
||||
onToggleTag={(id) => setPalateTagIds(palateTagIds.includes(id) ? palateTagIds.filter(t => t !== id) : [...palateTagIds, id])}
|
||||
label=""
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">Freitext Notizen</label>
|
||||
<textarea
|
||||
value={palate}
|
||||
onChange={(e) => setPalate(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Finish Section */}
|
||||
<div className={`bg-zinc-900 border transition-all duration-300 overflow-hidden ${isFinishExpanded ? 'rounded-[32px] border-orange-500/20' : 'rounded-2xl border-zinc-800'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFinishExpanded(!isFinishExpanded)}
|
||||
className={`w-full px-6 py-5 flex items-center justify-between transition-colors ${isFinishExpanded ? 'bg-zinc-950/50' : 'hover:bg-zinc-800/50'}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-2.5 rounded-xl border transition-all ${isFinishExpanded ? 'bg-orange-600/10 text-orange-500 border-orange-500/20' : 'bg-zinc-800/50 text-zinc-500 border-transparent'}`}>
|
||||
<Droplets size={20} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className={`text-xs font-black uppercase tracking-[0.2em] leading-none ${isFinishExpanded ? 'text-zinc-50' : 'text-zinc-500'}`}>
|
||||
{t('tasting.finish')}
|
||||
</h3>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{(finishTagIds.length > 0 || finish) ? (
|
||||
<span className="flex items-center gap-1.5 text-[9px] font-black text-orange-500 uppercase tracking-widest">
|
||||
<Check size={10} /> {finishTagIds.length} Tags {finish ? '+ Notiz' : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-zinc-600 font-bold uppercase tracking-widest">Abgang & Nachhall</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown size={20} className={`text-zinc-700 transition-transform duration-300 ${isFinishExpanded ? 'rotate-180 text-orange-500' : ''}`} />
|
||||
</button>
|
||||
{isFinishExpanded && (
|
||||
<div className="p-6 space-y-6 animate-in slide-in-from-top-4 duration-300">
|
||||
<TagSelector
|
||||
category="finish"
|
||||
selectedTagIds={finishTagIds}
|
||||
onToggleTag={(id) => setFinishTagIds(finishTagIds.includes(id) ? finishTagIds.filter(t => t !== id) : [...finishTagIds, id])}
|
||||
label=""
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 pt-4 border-t border-zinc-800/50">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<div className="w-1 h-3 bg-orange-600 rounded-full" />
|
||||
<label className="text-[10px] font-black uppercase text-zinc-500 tracking-widest">Gefühl & Textur</label>
|
||||
</div>
|
||||
<TagSelector
|
||||
category="texture"
|
||||
selectedTagIds={finishTagIds}
|
||||
onToggleTag={(id) => setFinishTagIds(finishTagIds.includes(id) ? finishTagIds.filter(t => t !== id) : [...finishTagIds, id])}
|
||||
label=""
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1">Freitext Notizen</label>
|
||||
<textarea
|
||||
value={finish}
|
||||
onChange={(e) => setFinish(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buddies Selection */}
|
||||
{availableBuddies.length > 0 && (
|
||||
<div className="space-y-5 px-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users size={18} className="text-zinc-600" />
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-zinc-500">{t('tasting.participants')}</h3>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{availableBuddies.map((buddy) => (
|
||||
<button
|
||||
key={buddy.id}
|
||||
type="button"
|
||||
onClick={() => toggleBuddy(buddy.id)}
|
||||
className={`px-5 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all border flex items-center gap-2.5 ${selectedBuddyIds.includes(buddy.id)
|
||||
? 'bg-orange-600 border-orange-600 text-white shadow-xl shadow-orange-950/40 ring-2 ring-orange-500/20'
|
||||
: 'bg-zinc-900/50 border-zinc-800 text-zinc-600 hover:border-zinc-700 hover:text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{selectedBuddyIds.includes(buddy.id) ? <CheckCircle2 size={14} /> : <div className="w-3.5 h-3.5 rounded-full border border-zinc-700" />}
|
||||
{buddy.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { saveTasting } from '@/services/save-tasting';
|
||||
import { Loader2, Send, Star, Users, Check, Sparkles, Droplets, Wind, Utensils, Zap } from 'lucide-react';
|
||||
import { Loader2, Send, Star, Users, Check, Sparkles, Droplets, Wind, Utensils, Zap, ChevronDown } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useI18n } from '@/i18n/I18nContext';
|
||||
import { useSession } from '@/context/SessionContext';
|
||||
@@ -10,6 +10,7 @@ import TagSelector from './TagSelector';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import TastingFormBody from './TastingFormBody';
|
||||
|
||||
interface Buddy {
|
||||
id: string;
|
||||
@@ -44,6 +45,11 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
|
||||
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
|
||||
|
||||
// Section collapse states
|
||||
const [isNoseExpanded, setIsNoseExpanded] = useState(false);
|
||||
const [isPalateExpanded, setIsPalateExpanded] = useState(false);
|
||||
const [isFinishExpanded, setIsFinishExpanded] = useState(false);
|
||||
|
||||
const effectiveSessionId = sessionId || activeSession?.id;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -239,199 +245,30 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Star size={14} className="text-orange-500 fill-orange-500" />
|
||||
{t('tasting.rating')}
|
||||
</label>
|
||||
<span className="text-2xl font-black text-orange-600 tracking-tighter">{rating}<span className="text-zinc-500 text-sm ml-0.5 font-bold">/100</span></span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={rating}
|
||||
onChange={(e) => setRating(parseInt(e.target.value))}
|
||||
className="w-full h-1.5 bg-zinc-800 rounded-full appearance-none cursor-pointer accent-orange-600 hover:accent-orange-500 transition-all"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-zinc-400 font-black uppercase tracking-widest px-1">
|
||||
<span>Swill</span>
|
||||
<span>Dram</span>
|
||||
<span>Legendary</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<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-950 rounded-2xl border border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSample(false)}
|
||||
className={`py-2.5 px-4 rounded-xl text-xs font-black uppercase tracking-tight transition-all pb-3 ${!isSample
|
||||
? 'bg-zinc-800 text-orange-600 shadow-sm ring-1 ring-white/5'
|
||||
: 'text-zinc-500 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
Bottle
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSample(true)}
|
||||
className={`py-2.5 px-4 rounded-xl text-xs font-black uppercase tracking-tight transition-all pb-3 ${isSample
|
||||
? 'bg-zinc-800 text-orange-600 shadow-sm ring-1 ring-white/5'
|
||||
: 'text-zinc-500 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
Sample
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Nose Section */}
|
||||
<div className="bg-zinc-950 rounded-3xl border border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-900/50 px-5 py-4 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-orange-950/30 p-2 rounded-xl text-orange-600">
|
||||
<Wind size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-50 leading-none">
|
||||
{t('tasting.nose')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Aroma & Eindruck</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
<TagSelector
|
||||
category="nose"
|
||||
selectedTagIds={noseTagIds}
|
||||
onToggleTag={toggleNoseTag}
|
||||
label=""
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block px-1">Eigene Notizen</label>
|
||||
<textarea
|
||||
value={nose}
|
||||
onChange={(e) => setNose(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Palate Section */}
|
||||
<div className="bg-zinc-950 rounded-3xl border border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-900/50 px-5 py-4 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-orange-950/30 p-2 rounded-xl text-orange-600">
|
||||
<Utensils size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-50 leading-none">
|
||||
{t('tasting.palate')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Geschmack & Textur</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
<TagSelector
|
||||
category="taste"
|
||||
selectedTagIds={palateTagIds}
|
||||
onToggleTag={togglePalateTag}
|
||||
label=""
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block px-1">Eigene Notizen</label>
|
||||
<textarea
|
||||
value={palate}
|
||||
onChange={(e) => setPalate(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Finish Section */}
|
||||
<div className="bg-zinc-950 rounded-3xl border border-zinc-800 shadow-sm overflow-hidden transition-all">
|
||||
<div className="bg-zinc-900/50 px-5 py-4 border-b border-zinc-800 flex items-center gap-3">
|
||||
<div className="bg-orange-950/30 p-2 rounded-xl text-orange-600">
|
||||
<Droplets size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-50 leading-none">
|
||||
{t('tasting.finish')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 font-bold uppercase mt-1">Abgang & Nachhall</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
<TagSelector
|
||||
category="finish"
|
||||
selectedTagIds={finishTagIds}
|
||||
onToggleTag={toggleFinishTag}
|
||||
label=""
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block px-1 opacity-50">Gefühl & Textur</label>
|
||||
<TagSelector
|
||||
category="texture"
|
||||
selectedTagIds={finishTagIds}
|
||||
onToggleTag={toggleFinishTag}
|
||||
label=""
|
||||
suggestedTagNames={suggestedTags}
|
||||
suggestedCustomTagNames={suggestedCustomTags}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-zinc-400 uppercase tracking-widest block px-1">Eigene Notizen</label>
|
||||
<textarea
|
||||
value={finish}
|
||||
onChange={(e) => setFinish(e.target.value)}
|
||||
placeholder={t('tasting.notesPlaceholder')}
|
||||
rows={2}
|
||||
className="w-full p-4 bg-zinc-900 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{buddies.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
|
||||
<Users size={14} className="text-orange-500" />
|
||||
{t('tasting.participants')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{buddies.map((buddy) => (
|
||||
<button
|
||||
key={buddy.id}
|
||||
type="button"
|
||||
onClick={() => toggleBuddy(buddy.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-[10px] font-black uppercase transition-all flex items-center gap-1.5 border shadow-sm ${selectedBuddyIds.includes(buddy.id)
|
||||
? 'bg-orange-600 border-orange-600 text-white shadow-orange-600/20'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-400 hover:border-orange-500/50'
|
||||
}`}
|
||||
>
|
||||
{selectedBuddyIds.includes(buddy.id) && <Check size={10} />}
|
||||
{buddy.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TastingFormBody
|
||||
rating={rating}
|
||||
setRating={setRating}
|
||||
isSample={isSample}
|
||||
setIsSample={setIsSample}
|
||||
nose={nose}
|
||||
setNose={setNose}
|
||||
noseTagIds={noseTagIds}
|
||||
setNoseTagIds={setNoseTagIds}
|
||||
palate={palate}
|
||||
setPalate={setPalate}
|
||||
palateTagIds={palateTagIds}
|
||||
setPalateTagIds={setPalateTagIds}
|
||||
finish={finish}
|
||||
setFinish={setFinish}
|
||||
finishTagIds={finishTagIds}
|
||||
setFinishTagIds={setFinishTagIds}
|
||||
selectedBuddyIds={selectedBuddyIds}
|
||||
setSelectedBuddyIds={setSelectedBuddyIds}
|
||||
availableBuddies={buddies}
|
||||
suggestedTags={suggestedTags}
|
||||
suggestedCustomTags={suggestedCustomTags}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs rounded-lg border border-red-100 dark:border-red-900/50">
|
||||
|
||||
@@ -51,15 +51,18 @@ export interface CachedBottle {
|
||||
id: string;
|
||||
name: string;
|
||||
distillery: string;
|
||||
category?: string;
|
||||
abv?: number;
|
||||
age?: number;
|
||||
image_url?: string;
|
||||
whiskybase_id?: string;
|
||||
distilled_at?: string;
|
||||
bottled_at?: string;
|
||||
batch_info?: string;
|
||||
last_updated: number;
|
||||
category: string;
|
||||
abv: number;
|
||||
age: number;
|
||||
whiskybase_id: string | null;
|
||||
image_url: string;
|
||||
purchase_price?: number | null;
|
||||
status?: string | null;
|
||||
distilled_at?: string | null;
|
||||
bottled_at?: string | null;
|
||||
batch_info?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CachedTasting {
|
||||
@@ -92,8 +95,8 @@ export class WhiskyDexie extends Dexie {
|
||||
pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at, syncing, attempts',
|
||||
cache_tags: 'id, category, name',
|
||||
cache_buddies: 'id, name',
|
||||
cache_bottles: 'id, name, distillery',
|
||||
cache_tastings: 'id, bottle_id, created_at'
|
||||
cache_bottles: 'id, distillery, category, status, purchase_price',
|
||||
cache_tastings: 'id, bottle_id, session_id, tasted_at',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ const apiKey = process.env.GEMINI_API_KEY!;
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
|
||||
export const geminiModel = genAI.getGenerativeModel({
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemma-3-27b',
|
||||
generationConfig: {
|
||||
responseMimeType: 'application/json',
|
||||
},
|
||||
|
||||
@@ -88,6 +88,7 @@ export async function saveBottle(
|
||||
distilled_at: metadata.distilled_at,
|
||||
bottled_at: metadata.bottled_at,
|
||||
batch_info: metadata.batch_info,
|
||||
purchase_price: metadata.purchase_price,
|
||||
suggested_tags: metadata.suggested_tags,
|
||||
suggested_custom_tags: metadata.suggested_custom_tags,
|
||||
})
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
|
||||
distilled_at: data.distilled_at,
|
||||
bottled_at: data.bottled_at,
|
||||
batch_info: data.batch_info,
|
||||
status: data.status,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', bottleId)
|
||||
|
||||
@@ -15,6 +15,8 @@ export const BottleMetadataSchema = z.object({
|
||||
batch_info: z.string().trim().max(255).nullish(),
|
||||
is_whisky: z.boolean().default(true),
|
||||
confidence: z.number().min(0).max(100).default(100),
|
||||
purchase_price: z.number().min(0).nullish(),
|
||||
status: z.enum(['sealed', 'open', 'sampled', 'empty']).default('sealed').nullish(),
|
||||
suggested_tags: z.array(z.string().trim().max(100)).nullish(),
|
||||
suggested_custom_tags: z.array(z.string().trim().max(100)).nullish(),
|
||||
});
|
||||
@@ -37,16 +39,17 @@ export const TastingNoteSchema = z.object({
|
||||
export type TastingNoteData = z.infer<typeof TastingNoteSchema>;
|
||||
|
||||
export const UpdateBottleSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255).optional(),
|
||||
distillery: z.string().trim().max(255).optional(),
|
||||
category: z.string().trim().max(100).optional(),
|
||||
abv: z.number().min(0).max(100).optional(),
|
||||
age: z.number().min(0).max(100).optional(),
|
||||
whiskybase_id: z.string().trim().max(50).optional(),
|
||||
purchase_price: z.number().min(0).optional(),
|
||||
distilled_at: z.string().trim().max(50).optional(),
|
||||
bottled_at: z.string().trim().max(50).optional(),
|
||||
batch_info: z.string().trim().max(255).optional(),
|
||||
name: z.string().trim().min(1).max(255).nullish(),
|
||||
distillery: z.string().trim().max(255).nullish(),
|
||||
category: z.string().trim().max(100).nullish(),
|
||||
abv: z.number().min(0).max(100).nullish(),
|
||||
age: z.number().min(0).max(100).nullish(),
|
||||
whiskybase_id: z.string().trim().max(50).nullish(),
|
||||
purchase_price: z.number().min(0).nullish(),
|
||||
distilled_at: z.string().trim().max(50).nullish(),
|
||||
bottled_at: z.string().trim().max(50).nullish(),
|
||||
batch_info: z.string().trim().max(255).nullish(),
|
||||
status: z.enum(['sealed', 'open', 'sampled', 'empty']).nullish(),
|
||||
});
|
||||
|
||||
export type UpdateBottleData = z.infer<typeof UpdateBottleSchema>;
|
||||
|
||||
Reference in New Issue
Block a user