feat: unify tasting form refactor & align db schema status levels
This commit is contained in:
11
migration_v2.sql
Normal file
11
migration_v2.sql
Normal 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'));
|
||||||
@@ -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">
|
||||||
|
<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">
|
<h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-orange-600 uppercase tracking-widest">
|
||||||
<Droplets size={20} /> Dram bewerten
|
<Droplets size={20} /> Dram bewerten
|
||||||
</h3>
|
</h3>
|
||||||
<TastingNoteForm bottleId={bottle.id} sessionId={sessionId} />
|
<TastingNoteForm
|
||||||
|
bottleId={bottle.id}
|
||||||
|
sessionId={sessionId}
|
||||||
|
onSuccess={() => setIsFormVisible(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
availableBuddies={buddies || []}
|
||||||
|
suggestedTags={suggestedTags}
|
||||||
|
suggestedCustomTags={suggestedCustomTags}
|
||||||
|
defaultExpanded={defaultExpanded}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
</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>
|
||||||
|
|||||||
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 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}
|
||||||
|
setFinishTagIds={setFinishTagIds}
|
||||||
|
selectedBuddyIds={selectedBuddyIds}
|
||||||
|
setSelectedBuddyIds={setSelectedBuddyIds}
|
||||||
|
availableBuddies={buddies}
|
||||||
|
suggestedTags={suggestedTags}
|
||||||
|
suggestedCustomTags={suggestedCustomTags}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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">
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user