- Renamed app to DramLog and updated branding to Gold (#C89D46) - Implemented new BottomNavigation with Floating Scan Button - Fixed 'black screen' race condition in ScanAndTasteFlow - Refactored TastingEditor and StatsDashboard for a cleaner editorial look - Standardized colors and typography across the application
267 lines
13 KiB
TypeScript
267 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { X, Loader2, Sparkles, AlertCircle } from 'lucide-react';
|
|
import TastingEditor from './TastingEditor';
|
|
import SessionBottomSheet from './SessionBottomSheet';
|
|
import ResultCard from './ResultCard';
|
|
import { useSession } from '@/context/SessionContext';
|
|
import { magicScan } from '@/services/magic-scan';
|
|
import { saveBottle } from '@/services/save-bottle';
|
|
import { saveTasting } from '@/services/save-tasting';
|
|
import { BottleMetadata } from '@/types/whisky';
|
|
import { useI18n } from '@/i18n/I18nContext';
|
|
import { createClient } from '@/lib/supabase/client';
|
|
|
|
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
|
|
|
|
interface ScanAndTasteFlowProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
base64Image: string | null;
|
|
}
|
|
|
|
export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanAndTasteFlowProps) {
|
|
const [state, setState] = useState<FlowState>('IDLE');
|
|
const [isSessionsOpen, setIsSessionsOpen] = useState(false);
|
|
const { activeSession } = useSession();
|
|
const [tastingData, setTastingData] = useState<any>(null);
|
|
const [bottleMetadata, setBottleMetadata] = useState<BottleMetadata | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const { locale } = useI18n();
|
|
const supabase = createClient();
|
|
|
|
// Trigger scan when open and image provided
|
|
useEffect(() => {
|
|
if (isOpen && base64Image) {
|
|
console.log('[ScanFlow] Starting handleScan...');
|
|
handleScan(base64Image);
|
|
} else if (!isOpen) {
|
|
setState('IDLE');
|
|
setTastingData(null);
|
|
setBottleMetadata(null);
|
|
setError(null);
|
|
setIsSaving(false);
|
|
}
|
|
}, [isOpen, base64Image]);
|
|
|
|
const handleScan = async (image: string) => {
|
|
setState('SCANNING');
|
|
setError(null);
|
|
|
|
try {
|
|
const cleanBase64 = image.split(',')[1] || image;
|
|
console.log('[ScanFlow] Calling magicScan service...');
|
|
const result = await magicScan(cleanBase64, 'gemini', locale);
|
|
|
|
if (result.success && result.data) {
|
|
console.log('[ScanFlow] magicScan success');
|
|
setBottleMetadata(result.data);
|
|
setState('EDITOR');
|
|
} else {
|
|
console.error('[ScanFlow] magicScan failure:', result.error);
|
|
throw new Error(result.error || 'Flasche konnte nicht erkannt werden.');
|
|
}
|
|
} catch (err: any) {
|
|
console.error('[ScanFlow] handleScan error:', err);
|
|
setError(err.message);
|
|
setState('ERROR');
|
|
}
|
|
};
|
|
|
|
const handleSaveTasting = async (formData: any) => {
|
|
if (!bottleMetadata || !base64Image) return;
|
|
|
|
setIsSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const { data: { user } = {} } = await supabase.auth.getUser();
|
|
if (!user) throw new Error('Nicht autorisiert');
|
|
|
|
// 1. Save Bottle
|
|
const bottleResult = await saveBottle(bottleMetadata, base64Image, user.id);
|
|
if (!bottleResult.success || !bottleResult.data) {
|
|
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
|
}
|
|
|
|
const bottleId = bottleResult.data.id;
|
|
|
|
// 2. Save Tasting
|
|
const tastingNote = {
|
|
...formData,
|
|
bottle_id: bottleId,
|
|
};
|
|
|
|
const tastingResult = await saveTasting(tastingNote);
|
|
if (!tastingResult.success) {
|
|
throw new Error(tastingResult.error || 'Fehler beim Speichern des Tastings');
|
|
}
|
|
|
|
setTastingData(tastingNote);
|
|
setState('RESULT');
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
setState('ERROR');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleShare = async () => {
|
|
if (navigator.share) {
|
|
try {
|
|
await navigator.share({
|
|
title: `My Tasting: ${bottleMetadata?.name || 'Whisky'}`,
|
|
text: `Check out my tasting results for ${bottleMetadata?.distillery} ${bottleMetadata?.name}!`,
|
|
url: window.location.href,
|
|
});
|
|
} catch (err) {
|
|
console.error('Share failed:', err);
|
|
}
|
|
} else {
|
|
alert('Sharing is not supported on this browser.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-[60] bg-[#0F1014] flex flex-col h-[100dvh] w-screen overflow-hidden overscroll-none"
|
|
>
|
|
{/* Close Button */}
|
|
<button
|
|
onClick={onClose}
|
|
className="absolute top-6 right-6 z-[70] p-2 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white transition-colors"
|
|
>
|
|
<X size={24} />
|
|
</button>
|
|
|
|
<div className="flex-1 w-full h-full flex flex-col relative min-h-0">
|
|
{/*
|
|
Robust state check:
|
|
If we are IDLE but have an image, we are essentially SCANNING (or about to be).
|
|
If we have no image, we shouldn't really be here, but show error just in case.
|
|
*/}
|
|
{(state === 'SCANNING' || (state === 'IDLE' && base64Image)) && (
|
|
<div className="flex-1 flex flex-col items-center justify-center">
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
className="flex flex-col items-center gap-6"
|
|
>
|
|
<div className="relative">
|
|
<motion.div
|
|
animate={{ rotate: 360 }}
|
|
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
|
|
className="w-32 h-32 rounded-full border-2 border-dashed border-[#C89D46]/30"
|
|
/>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<Loader2 size={48} className="animate-spin text-[#C89D46]" />
|
|
</div>
|
|
</div>
|
|
<div className="text-center space-y-2">
|
|
<h2 className="text-2xl font-display font-bold text-white uppercase tracking-tight">Analysiere Etikett...</h2>
|
|
<p className="text-[#C89D46] font-sans font-bold uppercase tracking-widest text-[10px] flex items-center justify-center gap-2">
|
|
<Sparkles size={12} /> KI-gestütztes Scanning
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
|
|
{state === 'ERROR' && (
|
|
<div className="flex-1 flex flex-col items-center justify-center">
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
className="flex flex-col items-center gap-6 p-8 text-center"
|
|
>
|
|
<div className="w-20 h-20 rounded-full bg-red-500/10 flex items-center justify-center text-red-500">
|
|
<AlertCircle size={40} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h2 className="text-2xl font-display font-bold text-white uppercase tracking-tight">Ups! Da lief was schief.</h2>
|
|
<p className="text-white/60 text-sm max-w-xs mx-auto font-sans">{error || 'Wir konnten die Flasche leider nicht erkennen. Bitte versuch es mit einem anderen Foto.'}</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="px-8 py-4 bg-white text-[#0F1014] rounded-2xl font-sans font-bold uppercase tracking-widest text-[10px] hover:bg-white/90 transition-all"
|
|
>
|
|
Schließen
|
|
</button>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
|
|
{state === 'EDITOR' && bottleMetadata && (
|
|
<motion.div
|
|
key="editor"
|
|
initial={{ y: 50, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={{ y: -50, opacity: 0 }}
|
|
className="flex-1 w-full h-full flex flex-col min-h-0"
|
|
>
|
|
<TastingEditor
|
|
bottleMetadata={bottleMetadata}
|
|
image={base64Image}
|
|
onSave={handleSaveTasting}
|
|
onOpenSessions={() => setIsSessionsOpen(true)}
|
|
activeSessionName={activeSession?.name}
|
|
activeSessionId={activeSession?.id}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
|
|
{(isSaving) && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="absolute inset-0 z-[80] bg-[#0F1014]/80 backdrop-blur-sm flex flex-col items-center justify-center gap-6"
|
|
>
|
|
<Loader2 size={48} className="animate-spin text-[#C89D46]" />
|
|
<h2 className="text-xl font-display font-bold text-white uppercase tracking-tight">Speichere Tasting...</h2>
|
|
</motion.div>
|
|
)}
|
|
|
|
{state === 'RESULT' && tastingData && bottleMetadata && (
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="min-h-full flex flex-col items-center justify-center py-20 px-6">
|
|
<motion.div
|
|
key="result"
|
|
initial={{ scale: 0.8, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
className="w-full max-w-sm"
|
|
>
|
|
<ResultCard
|
|
data={{
|
|
...tastingData,
|
|
complexity: tastingData.complexity || 75,
|
|
balance: tastingData.balance || 85,
|
|
}}
|
|
bottleName={bottleMetadata.name || 'Unknown Whisky'}
|
|
image={base64Image}
|
|
onShare={handleShare}
|
|
/>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<SessionBottomSheet
|
|
isOpen={isSessionsOpen}
|
|
onClose={() => setIsSessionsOpen(false)}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|