feat: unify tasting form refactor & align db schema status levels

This commit is contained in:
2025-12-23 22:13:05 +01:00
parent 6a41667f9c
commit 11275dd875
11 changed files with 570 additions and 442 deletions

11
migration_v2.sql Normal file
View File

@@ -0,0 +1,11 @@
-- Run this in your Supabase SQL Editor if you have an older database version
-- 1. Add columns if they don't exist
ALTER TABLE bottles ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'sealed';
ALTER TABLE bottles ADD COLUMN IF NOT EXISTS purchase_price DECIMAL(10, 2);
ALTER TABLE bottles ADD COLUMN IF NOT EXISTS finished_at TIMESTAMP WITH TIME ZONE;
-- 2. Add check constraint for status
-- Note: We drop it first in case it exists with different values
ALTER TABLE bottles DROP CONSTRAINT IF EXISTS bottles_status_check;
ALTER TABLE bottles ADD CONSTRAINT bottles_status_check CHECK (status IN ('sealed', 'open', 'sampled', 'empty'));

View File

@@ -2,7 +2,9 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; 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 { getStorageUrl } from '@/lib/supabase';
import TastingNoteForm from '@/components/TastingNoteForm'; import TastingNoteForm from '@/components/TastingNoteForm';
import TastingList from '@/components/TastingList'; import TastingList from '@/components/TastingList';
@@ -18,9 +20,37 @@ interface BottleDetailsProps {
} }
export default function BottleDetails({ bottleId, sessionId, userId }: BottleDetailsProps) { export default function BottleDetails({ bottleId, sessionId, userId }: BottleDetailsProps) {
const { t } = useI18n(); const { t, locale } = useI18n();
const { bottle, tastings, loading, error, isOffline } = useBottleData(bottleId); 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) { if (loading) {
return ( return (
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-4"> <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> </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 && ( {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="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"> <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"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8 items-start">
{/* Form */} {/* Form */}
<div className="lg:col-span-1 border border-zinc-800 rounded-3xl p-6 bg-zinc-900/50 md:sticky md:top-24"> <div className="lg:col-span-1 space-y-4 md:sticky md:top-24">
<h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-orange-600 uppercase tracking-widest"> <button
<Droplets size={20} /> Dram bewerten onClick={() => setIsFormVisible(!isFormVisible)}
</h3> 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'}`}
<TastingNoteForm bottleId={bottle.id} sessionId={sessionId} /> >
<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> </div>
{/* List */} {/* List */}

View File

@@ -545,6 +545,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
activeSessionName={activeSession?.name} activeSessionName={activeSession?.name}
activeSessionId={activeSession?.id} activeSessionId={activeSession?.id}
isEnriching={isEnriching} isEnriching={isEnriching}
defaultExpanded={true}
/> />
{isAdmin && perfMetrics && ( {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"> <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">

View File

@@ -10,6 +10,7 @@ import { db } from '@/lib/db';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { discoverWhiskybaseId } from '@/services/discover-whiskybase'; import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
import TastingFormBody from './TastingFormBody';
interface TastingEditorProps { interface TastingEditorProps {
bottleMetadata: BottleMetadata; bottleMetadata: BottleMetadata;
@@ -19,24 +20,22 @@ interface TastingEditorProps {
activeSessionName?: string; activeSessionName?: string;
activeSessionId?: string; activeSessionId?: string;
isEnriching?: boolean; 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 { t } = useI18n();
const supabase = createClient(); 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 [rating, setRating] = useState(85);
const [noseNotes, setNoseNotes] = useState(''); const [noseNotes, setNoseNotes] = useState('');
const [palateNotes, setPalateNotes] = useState(''); const [palateNotes, setPalateNotes] = useState('');
const [finishNotes, setFinishNotes] = useState(''); const [finishNotes, setFinishNotes] = useState('');
const [isSample, setIsSample] = useState(false); 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 [noseTagIds, setNoseTagIds] = useState<string[]>([]);
const [palateTagIds, setPalateTagIds] = useState<string[]>([]); const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
const [finishTagIds, setFinishTagIds] = 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 [whiskybaseError, setWhiskybaseError] = useState<string | null>(null);
const [textureTagIds, setTextureTagIds] = useState<string[]>([]); const [textureTagIds, setTextureTagIds] = useState<string[]>([]);
const [selectedBuddyIds, setSelectedBuddyIds] = 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 buddies = useLiveQuery(() => db.cache_buddies.toArray(), [], [] as any[]);
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null); 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, buddy_ids: selectedBuddyIds,
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds, ...textureTagIds], tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds, ...textureTagIds],
// Visual data for ResultCard // Visual data for ResultCard
nose: noseScore,
taste: tasteScore,
finish: finishScore,
complexity: complexityScore,
balance: balanceScore,
// Edited bottle metadata // Edited bottle metadata
bottleMetadata: { bottleMetadata: {
...bottleMetadata, ...bottleMetadata,
@@ -193,6 +193,8 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
distilled_at: bottleDistilledAt || null, distilled_at: bottleDistilledAt || null,
bottled_at: bottleBottledAt || null, bottled_at: bottleBottledAt || null,
whiskybaseId: whiskybaseId || 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"> <p className="text-zinc-500 text-[10px] font-bold uppercase tracking-widest leading-none">
{bottleCategory || 'Whisky'} {bottleAbv ? `${bottleAbv}%` : ''} {bottleAge ? `${bottleAge}y` : ''} {bottleCategory || 'Whisky'} {bottleAbv ? `${bottleAbv}%` : ''} {bottleAge ? `${bottleAge}y` : ''}
</p> </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>
</div> </div>
@@ -412,6 +421,24 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
/> />
</div> </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 */} {/* Whiskybase Discovery */}
{isDiscoveringWb && ( {isDiscoveringWb && (
<div className="flex items-center gap-2 p-3 bg-zinc-900 rounded-lg border border-zinc-800"> <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> </div>
</button> </button>
{/* Rating Slider */} {/* Shared Tasting Form Body */}
<div className="space-y-6 bg-zinc-900 p-8 rounded-3xl border border-zinc-800 shadow-inner relative overflow-hidden"> <TastingFormBody
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none"> rating={rating}
<Zap size={120} className="text-orange-500" /> setRating={setRating}
</div> isSample={isSample}
<div className="flex items-center justify-between relative z-10"> setIsSample={setIsSample}
<label className="text-xs font-bold text-zinc-500 uppercase tracking-[0.2em] flex items-center gap-2"> nose={noseNotes}
<Star size={14} className="text-orange-500 fill-orange-500" /> setNose={setNoseNotes}
{t('tasting.rating')} noseTagIds={noseTagIds}
</label> setNoseTagIds={setNoseTagIds}
<span className="text-4xl font-bold text-orange-600 tracking-tighter">{rating}<span className="text-zinc-700 text-sm ml-1">/100</span></span> palate={palateNotes}
</div> setPalate={setPalateNotes}
<input palateTagIds={palateTagIds}
type="range" setPalateTagIds={setPalateTagIds}
min="0" finish={finishNotes}
max="100" setFinish={setFinishNotes}
value={rating} finishTagIds={finishTagIds}
onChange={(e) => setRating(parseInt(e.target.value))} setFinishTagIds={setFinishTagIds}
className="w-full h-2 bg-zinc-800 rounded-full appearance-none cursor-pointer accent-orange-600 transition-all" selectedBuddyIds={selectedBuddyIds}
/> setSelectedBuddyIds={setSelectedBuddyIds}
<div className="flex justify-between text-[10px] text-zinc-500 font-bold uppercase tracking-widest px-1 relative z-10"> availableBuddies={buddies || []}
<span>Swill</span> suggestedTags={suggestedTags}
<span>Dram</span> suggestedCustomTags={suggestedCustomTags}
<span>Legendary</span> defaultExpanded={defaultExpanded}
</div> t={t}
/>
<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>
</div> </div>
</div> </div>
@@ -696,7 +544,6 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
> >
<Send size={20} /> <Send size={20} />
{t('tasting.saveTasting')} {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> </button>
</div> </div>
</div> </div>

View 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>
);
}

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { saveTasting } from '@/services/save-tasting'; 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 { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext'; import { useSession } from '@/context/SessionContext';
@@ -10,6 +10,7 @@ import TagSelector from './TagSelector';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import TastingFormBody from './TastingFormBody';
interface Buddy { interface Buddy {
id: string; 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 [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
const [showPaletteWarning, setShowPaletteWarning] = useState(false); 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; const effectiveSessionId = sessionId || activeSession?.id;
useEffect(() => { useEffect(() => {
@@ -239,199 +245,30 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
</div> </div>
)} )}
<div className="space-y-4"> <TastingFormBody
<div className="flex items-center justify-between"> rating={rating}
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2"> setRating={setRating}
<Star size={14} className="text-orange-500 fill-orange-500" /> isSample={isSample}
{t('tasting.rating')} setIsSample={setIsSample}
</label> nose={nose}
<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> setNose={setNose}
</div> noseTagIds={noseTagIds}
<input setNoseTagIds={setNoseTagIds}
type="range" palate={palate}
min="0" setPalate={setPalate}
max="100" palateTagIds={palateTagIds}
value={rating} setPalateTagIds={setPalateTagIds}
onChange={(e) => setRating(parseInt(e.target.value))} finish={finish}
className="w-full h-1.5 bg-zinc-800 rounded-full appearance-none cursor-pointer accent-orange-600 hover:accent-orange-500 transition-all" setFinish={setFinish}
/> finishTagIds={finishTagIds}
<div className="flex justify-between text-[9px] text-zinc-400 font-black uppercase tracking-widest px-1"> setFinishTagIds={setFinishTagIds}
<span>Swill</span> selectedBuddyIds={selectedBuddyIds}
<span>Dram</span> setSelectedBuddyIds={setSelectedBuddyIds}
<span>Legendary</span> availableBuddies={buddies}
</div> suggestedTags={suggestedTags}
</div> suggestedCustomTags={suggestedCustomTags}
t={t}
<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>
)}
{error && ( {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"> <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">

View File

@@ -51,15 +51,18 @@ export interface CachedBottle {
id: string; id: string;
name: string; name: string;
distillery: string; distillery: string;
category?: string; category: string;
abv?: number; abv: number;
age?: number; age: number;
image_url?: string; whiskybase_id: string | null;
whiskybase_id?: string; image_url: string;
distilled_at?: string; purchase_price?: number | null;
bottled_at?: string; status?: string | null;
batch_info?: string; distilled_at?: string | null;
last_updated: number; bottled_at?: string | null;
batch_info?: string | null;
created_at: string;
updated_at: string;
} }
export interface CachedTasting { export interface CachedTasting {
@@ -92,8 +95,8 @@ export class WhiskyDexie extends Dexie {
pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at, syncing, attempts', pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at, syncing, attempts',
cache_tags: 'id, category, name', cache_tags: 'id, category, name',
cache_buddies: 'id, name', cache_buddies: 'id, name',
cache_bottles: 'id, name, distillery', cache_bottles: 'id, distillery, category, status, purchase_price',
cache_tastings: 'id, bottle_id, created_at' cache_tastings: 'id, bottle_id, session_id, tasted_at',
}); });
} }
} }

View File

@@ -5,7 +5,7 @@ const apiKey = process.env.GEMINI_API_KEY!;
const genAI = new GoogleGenerativeAI(apiKey); const genAI = new GoogleGenerativeAI(apiKey);
export const geminiModel = genAI.getGenerativeModel({ export const geminiModel = genAI.getGenerativeModel({
model: 'gemini-2.5-flash', model: 'gemma-3-27b',
generationConfig: { generationConfig: {
responseMimeType: 'application/json', responseMimeType: 'application/json',
}, },

View File

@@ -88,6 +88,7 @@ export async function saveBottle(
distilled_at: metadata.distilled_at, distilled_at: metadata.distilled_at,
bottled_at: metadata.bottled_at, bottled_at: metadata.bottled_at,
batch_info: metadata.batch_info, batch_info: metadata.batch_info,
purchase_price: metadata.purchase_price,
suggested_tags: metadata.suggested_tags, suggested_tags: metadata.suggested_tags,
suggested_custom_tags: metadata.suggested_custom_tags, suggested_custom_tags: metadata.suggested_custom_tags,
}) })

View File

@@ -26,6 +26,7 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
distilled_at: data.distilled_at, distilled_at: data.distilled_at,
bottled_at: data.bottled_at, bottled_at: data.bottled_at,
batch_info: data.batch_info, batch_info: data.batch_info,
status: data.status,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}) })
.eq('id', bottleId) .eq('id', bottleId)

View File

@@ -15,6 +15,8 @@ export const BottleMetadataSchema = z.object({
batch_info: z.string().trim().max(255).nullish(), batch_info: z.string().trim().max(255).nullish(),
is_whisky: z.boolean().default(true), is_whisky: z.boolean().default(true),
confidence: z.number().min(0).max(100).default(100), 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_tags: z.array(z.string().trim().max(100)).nullish(),
suggested_custom_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 type TastingNoteData = z.infer<typeof TastingNoteSchema>;
export const UpdateBottleSchema = z.object({ export const UpdateBottleSchema = z.object({
name: z.string().trim().min(1).max(255).optional(), name: z.string().trim().min(1).max(255).nullish(),
distillery: z.string().trim().max(255).optional(), distillery: z.string().trim().max(255).nullish(),
category: z.string().trim().max(100).optional(), category: z.string().trim().max(100).nullish(),
abv: z.number().min(0).max(100).optional(), abv: z.number().min(0).max(100).nullish(),
age: z.number().min(0).max(100).optional(), age: z.number().min(0).max(100).nullish(),
whiskybase_id: z.string().trim().max(50).optional(), whiskybase_id: z.string().trim().max(50).nullish(),
purchase_price: z.number().min(0).optional(), purchase_price: z.number().min(0).nullish(),
distilled_at: z.string().trim().max(50).optional(), distilled_at: z.string().trim().max(50).nullish(),
bottled_at: z.string().trim().max(50).optional(), bottled_at: z.string().trim().max(50).nullish(),
batch_info: z.string().trim().max(255).optional(), batch_info: z.string().trim().max(255).nullish(),
status: z.enum(['sealed', 'open', 'sampled', 'empty']).nullish(),
}); });
export type UpdateBottleData = z.infer<typeof UpdateBottleSchema>; export type UpdateBottleData = z.infer<typeof UpdateBottleSchema>;