feat: Buddy System & Bulk Scanner

- Add Buddy linking via QR code/handshake (buddy_invites table)
- Add Bulk Scanner for rapid-fire bottle scanning in sessions
- Add processing_status to bottles for background AI analysis
- Fix offline OCR with proper tessdata caching in Service Worker
- Fix Supabase GoTrueClient singleton warning
- Add collection refresh after offline sync completes

New components:
- BuddyHandshake.tsx - QR code display and code entry
- BulkScanSheet.tsx - Camera UI with capture queue
- BottleSkeletonCard.tsx - Pending bottle display
- useBulkScanner.ts - Queue management hook
- buddy-link.ts - Server actions for buddy linking
- bulk-scan.ts - Server actions for batch processing
This commit is contained in:
2025-12-25 22:11:50 +01:00
parent afe9197776
commit 75461d7c30
22 changed files with 2050 additions and 146 deletions

View File

@@ -27,10 +27,12 @@
"fuse.js": "^7.1.0",
"heic2any": "^0.0.4",
"lucide-react": "^0.468.0",
"nanoid": "^5.1.6",
"next": "16.1.0",
"openai": "^6.15.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-qr-code": "^2.0.18",
"recharts": "^3.6.0",
"sharp": "^0.34.5",
"tesseract.js": "^7.0.0",

29
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
lucide-react:
specifier: ^0.468.0
version: 0.468.0(react@19.2.3)
nanoid:
specifier: ^5.1.6
version: 5.1.6
next:
specifier: 16.1.0
version: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -65,6 +68,9 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.3(react@19.2.3)
react-qr-code:
specifier: ^2.0.18
version: 2.0.18(react@19.2.3)
recharts:
specifier: ^3.6.0
version: 3.6.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1)
@@ -2299,6 +2305,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20}
hasBin: true
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -2542,6 +2553,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qr.js@0.0.0:
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2556,6 +2570,11 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-qr-code@2.0.18:
resolution: {integrity: sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==}
peerDependencies:
react: '*'
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -5358,6 +5377,8 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.6: {}
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -5573,6 +5594,8 @@ snapshots:
punycode@2.3.1: {}
qr.js@0.0.0: {}
queue-microtask@1.2.3: {}
react-dom@19.2.3(react@19.2.3):
@@ -5584,6 +5607,12 @@ snapshots:
react-is@17.0.2: {}
react-qr-code@2.0.18(react@19.2.3):
dependencies:
prop-types: 15.8.1
qr.js: 0.0.0
react: 19.2.3
react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'whisky-vault-v14-offline';
const CACHE_NAME = 'whisky-vault-v19-offline';
// CONFIG: Assets
const STATIC_ASSETS = [
@@ -7,6 +7,16 @@ const STATIC_ASSETS = [
'/icon-512.png',
'/favicon.ico',
'/lib/browser-image-compression.js',
// Tesseract OCR files for offline scanning (ALL variants for browser compatibility)
'/tessdata/worker.min.js',
'/tessdata/tesseract-core.wasm.js',
'/tessdata/tesseract-core-simd.wasm.js',
'/tessdata/tesseract-core-lstm.wasm.js',
'/tessdata/tesseract-core-simd-lstm.wasm.js',
'/tessdata/tesseract-core-relaxedsimd.wasm.js',
'/tessdata/tesseract-core-relaxedsimd-lstm.wasm.js',
'/tessdata/eng.traineddata',
'/tessdata/eng.traineddata.gz',
];
const CORE_PAGES = [
@@ -55,7 +65,7 @@ self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => {
console.log('🏗️ PWA: Building Offline-Modus v13...');
console.log(`🏗️ PWA: Building Offline-Modus ${CACHE_NAME}...`);
const items = [...STATIC_ASSETS, ...CORE_PAGES];
const total = items.length;
@@ -153,6 +163,31 @@ self.addEventListener('fetch', (event) => {
return;
}
// 📦 Tesseract OCR files and static libs - CACHE FIRST (critical for offline)
if (url.pathname.startsWith('/tessdata/') || url.pathname.startsWith('/lib/')) {
event.respondWith(
caches.match(event.request).then(async (cachedResponse) => {
if (cachedResponse) {
console.log(`[SW] Serving from cache: ${url.pathname}`);
return cachedResponse;
}
// Try network, cache the result
try {
const networkResponse = await fetchWithTimeout(event.request, 30000);
if (networkResponse && networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error(`[SW] Failed to fetch ${url.pathname}:`, error);
return new Response('File not available offline', { status: 503 });
}
})
);
return;
}
// Navigation & Assets
const isNavigation = event.request.mode === 'navigate';
const isAsset = event.request.destination === 'style' ||

3
public/tessdata/worker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -98,9 +98,17 @@ export default function Home() {
}
});
// Listen for collection updates (e.g., after offline sync completes)
const handleCollectionUpdated = () => {
console.log('[Home] Collection update event received, refreshing...');
fetchCollection();
};
window.addEventListener('collection-updated', handleCollectionUpdated);
return () => {
subscription.unsubscribe();
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('collection-updated', handleCollectionUpdated);
};
}, []);

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { ChevronLeft, Users, Calendar, GlassWater, Plus, Trash2, Loader2, Sparkles, ChevronRight, Play, Square } from 'lucide-react';
import { ChevronLeft, Users, Calendar, GlassWater, Plus, Trash2, Loader2, Sparkles, ChevronRight, Play, Square, Zap } from 'lucide-react';
import Link from 'next/link';
import AvatarStack from '@/components/AvatarStack';
import { deleteSession } from '@/services/delete-session';
@@ -13,6 +13,8 @@ import { useI18n } from '@/i18n/I18nContext';
import SessionTimeline from '@/components/SessionTimeline';
import SessionABVCurve from '@/components/SessionABVCurve';
import OfflineIndicator from '@/components/OfflineIndicator';
import BulkScanSheet from '@/components/BulkScanSheet';
import BottleSkeletonCard from '@/components/BottleSkeletonCard';
interface Buddy {
id: string;
@@ -44,6 +46,7 @@ interface SessionTasting {
image_url?: string | null;
abv: number;
category?: string;
processing_status?: string;
};
tasting_tags: {
tags: {
@@ -66,9 +69,31 @@ export default function SessionDetailPage() {
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
useEffect(() => {
fetchSessionData();
// Subscribe to bottle updates for realtime processing status
const channel = supabase
.channel('bottle-updates')
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'bottles' },
(payload) => {
// Refresh if a bottle's processing_status changed
if (payload.new && payload.old) {
if (payload.new.processing_status !== payload.old.processing_status) {
fetchSessionData();
}
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [id]);
const fetchSessionData = async () => {
@@ -102,7 +127,7 @@ export default function SessionDetailPage() {
id,
rating,
tasted_at,
bottles(id, name, distillery, image_url, abv, category),
bottles(id, name, distillery, image_url, abv, category, processing_status),
tasting_tags(tags(name))
`)
.eq('session_id', id)
@@ -388,14 +413,25 @@ export default function SessionDetailPage() {
<GlassWater size={16} className="text-orange-600" />
Verkostete Flaschen
</h3>
<div className="flex gap-2">
{!session.ended_at && (
<button
onClick={() => setIsBulkScanOpen(true)}
className="bg-zinc-800 hover:bg-zinc-700 text-orange-500 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all border border-zinc-700"
>
<Zap size={16} />
Bulk Scan
</button>
)}
<Link
href={`/?session_id=${id}`} // Redirect to home with context
href={`/?session_id=${id}`}
className="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-orange-600/20"
>
<Plus size={16} />
Flasche hinzufügen
Flasche
</Link>
</div>
</div>
<SessionTimeline
tastings={tastings.map(t => ({
@@ -413,6 +449,18 @@ export default function SessionDetailPage() {
</section>
</div>
</div>
{/* Bulk Scan Sheet */}
<BulkScanSheet
isOpen={isBulkScanOpen}
onClose={() => setIsBulkScanOpen(false)}
sessionId={id as string}
sessionName={session.name}
onSuccess={(bottleIds) => {
setIsBulkScanOpen(false);
fetchSessionData();
}}
/>
</main>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import React from 'react';
import { motion } from 'framer-motion';
import { Sparkles, AlertCircle, Loader2 } from 'lucide-react';
interface BottleSkeletonCardProps {
name?: string;
imageUrl?: string;
processingStatus: 'pending' | 'analyzing' | 'error';
onClick?: () => void;
}
export default function BottleSkeletonCard({
name,
imageUrl,
processingStatus,
onClick
}: BottleSkeletonCardProps) {
const isError = processingStatus === 'error';
const isPending = processingStatus === 'pending';
const isAnalyzing = processingStatus === 'analyzing';
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
onClick={onClick}
className={`relative bg-zinc-900 rounded-2xl border overflow-hidden cursor-pointer transition-all ${isError
? 'border-red-500/50 hover:border-red-500'
: 'border-zinc-800 hover:border-orange-600/50'
}`}
>
{/* Image */}
<div className="aspect-[3/4] bg-zinc-950 relative overflow-hidden">
{imageUrl ? (
<img
src={imageUrl}
alt="Bottle preview"
className={`w-full h-full object-cover ${isError ? 'opacity-50 grayscale' : 'opacity-60'}`}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="w-16 h-24 bg-zinc-800 rounded-lg animate-pulse" />
</div>
)}
{/* Processing Overlay */}
<div className={`absolute inset-0 flex flex-col items-center justify-center ${isError ? 'bg-red-950/50' : 'bg-black/40'
}`}>
{isError ? (
<>
<AlertCircle size={32} className="text-red-500 mb-2" />
<span className="text-xs font-bold text-red-400">Fehler</span>
</>
) : (
<>
{isPending ? (
<Loader2 size={28} className="text-orange-500 animate-spin mb-2" />
) : (
<Sparkles size={28} className="text-orange-500 animate-pulse mb-2" />
)}
<span className="text-xs font-bold text-zinc-300">
{isPending ? 'Warten...' : 'KI analysiert...'}
</span>
</>
)}
</div>
{/* Status Badge */}
<div className={`absolute top-2 right-2 px-2 py-1 rounded-lg text-[9px] font-black uppercase tracking-widest ${isError
? 'bg-red-500/20 text-red-400'
: isAnalyzing
? 'bg-orange-500/20 text-orange-400'
: 'bg-zinc-800/80 text-zinc-500'
}`}>
{isError ? 'Fehler' : isAnalyzing ? '✨ Analyse' : 'Warte'}
</div>
</div>
{/* Info */}
<div className="p-3">
{/* Skeleton Name */}
<div className="h-4 bg-zinc-800 rounded animate-pulse mb-2 w-3/4" />
{/* Skeleton Details */}
<div className="flex gap-2">
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-12" />
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-8" />
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,296 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, QrCode, Keyboard, Loader2, CheckCircle2, Copy, RefreshCw, Link2 } from 'lucide-react';
import QRCode from 'react-qr-code';
import { generateBuddyCode, redeemBuddyCode, revokeBuddyCode } from '@/services/buddy-link';
import { useI18n } from '@/i18n/I18nContext';
interface BuddyHandshakeProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
type Tab = 'show' | 'enter';
export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHandshakeProps) {
const { t } = useI18n();
const [activeTab, setActiveTab] = useState<Tab>('show');
// Show Code Tab State
const [code, setCode] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [copied, setCopied] = useState(false);
// Enter Code Tab State
const [inputCode, setInputCode] = useState('');
const [isRedeeming, setIsRedeeming] = useState(false);
const [redeemError, setRedeemError] = useState<string | null>(null);
const [redeemSuccess, setRedeemSuccess] = useState<string | null>(null);
// Generate code when showing "Show Code" tab
useEffect(() => {
if (isOpen && activeTab === 'show' && !code) {
handleGenerateCode();
}
}, [isOpen, activeTab]);
// Reset state when closing
useEffect(() => {
if (!isOpen) {
setCode(null);
setInputCode('');
setRedeemError(null);
setRedeemSuccess(null);
setCopied(false);
}
}, [isOpen]);
const handleGenerateCode = async () => {
setIsGenerating(true);
const result = await generateBuddyCode();
if (result.success && result.code) {
setCode(result.code);
}
setIsGenerating(false);
};
const handleRefreshCode = async () => {
await revokeBuddyCode();
setCode(null);
handleGenerateCode();
};
const handleCopyCode = () => {
if (code) {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleRedeemCode = async () => {
if (!inputCode.trim()) return;
setIsRedeeming(true);
setRedeemError(null);
setRedeemSuccess(null);
const result = await redeemBuddyCode(inputCode);
if (result.success) {
setRedeemSuccess(`Verbunden mit ${result.buddyName}!`);
setTimeout(() => {
onSuccess?.();
onClose();
}, 1500);
} else {
setRedeemError(result.error || 'Unbekannter Fehler');
}
setIsRedeeming(false);
};
const formatCodeForDisplay = (code: string) => {
// Format as XXX-XXX for better readability
return `${code.slice(0, 3)}-${code.slice(3)}`;
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="w-full max-w-md bg-zinc-900 rounded-3xl border border-zinc-800 shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-zinc-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
<Link2 size={20} className="text-orange-500" />
</div>
<div>
<h2 className="text-lg font-bold text-white">Account verbinden</h2>
<p className="text-xs text-zinc-500">Buddy-Handshake</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-white"
>
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-zinc-800">
<button
onClick={() => setActiveTab('show')}
className={`flex-1 py-3.5 text-xs font-bold uppercase tracking-widest transition-all flex items-center justify-center gap-2 ${activeTab === 'show'
? 'text-orange-500 border-b-2 border-orange-500 bg-orange-500/5'
: 'text-zinc-500 hover:text-zinc-300'
}`}
>
<QrCode size={14} />
Mein Code
</button>
<button
onClick={() => setActiveTab('enter')}
className={`flex-1 py-3.5 text-xs font-bold uppercase tracking-widest transition-all flex items-center justify-center gap-2 ${activeTab === 'enter'
? 'text-orange-500 border-b-2 border-orange-500 bg-orange-500/5'
: 'text-zinc-500 hover:text-zinc-300'
}`}
>
<Keyboard size={14} />
Code eingeben
</button>
</div>
{/* Content */}
<div className="p-6">
{activeTab === 'show' ? (
<div className="flex flex-col items-center gap-6">
{isGenerating ? (
<div className="py-12 flex flex-col items-center gap-4">
<Loader2 size={32} className="animate-spin text-orange-500" />
<p className="text-sm text-zinc-500">Generiere Code...</p>
</div>
) : code ? (
<>
{/* QR Code */}
<div className="bg-white p-4 rounded-2xl">
<QRCode
value={`dramlog://buddy/${code}`}
size={180}
level="M"
/>
</div>
{/* Text Code */}
<div className="flex flex-col items-center gap-2">
<p className="text-xs text-zinc-500 uppercase tracking-widest">Oder Code teilen:</p>
<div className="flex items-center gap-2">
<span className="text-3xl font-black tracking-[0.3em] text-white font-mono">
{formatCodeForDisplay(code)}
</span>
<button
onClick={handleCopyCode}
className={`p-2 rounded-lg transition-all ${copied
? 'bg-green-500/20 text-green-500'
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-400'
}`}
>
{copied ? <CheckCircle2 size={18} /> : <Copy size={18} />}
</button>
</div>
</div>
{/* Timer/Refresh */}
<div className="flex items-center gap-4 text-xs text-zinc-500">
<span>Gültig für 15 Minuten</span>
<button
onClick={handleRefreshCode}
className="flex items-center gap-1.5 text-orange-500 hover:text-orange-400"
>
<RefreshCw size={12} />
Neuer Code
</button>
</div>
</>
) : (
<div className="py-12 text-center text-zinc-500">
<p>Fehler beim Generieren</p>
<button
onClick={handleGenerateCode}
className="mt-4 text-orange-500 hover:underline"
>
Erneut versuchen
</button>
</div>
)}
</div>
) : (
<div className="flex flex-col gap-6">
<p className="text-sm text-zinc-400 text-center">
Gib den 6-stelligen Code deines Buddies ein:
</p>
{/* Code Input */}
<input
type="text"
value={inputCode}
onChange={(e) => {
const val = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
if (val.length <= 6) {
setInputCode(val);
setRedeemError(null);
}
}}
placeholder="XXXXXX"
className="w-full text-center text-3xl font-black tracking-[0.4em] bg-zinc-950 border-2 border-zinc-800 rounded-2xl py-4 text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-500 transition-colors font-mono"
maxLength={6}
autoFocus
/>
{/* Error Message */}
{redeemError && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-red-500 text-center bg-red-500/10 py-2 px-4 rounded-xl"
>
{redeemError}
</motion.p>
)}
{/* Success Message */}
{redeemSuccess && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center justify-center gap-2 text-green-500 bg-green-500/10 py-3 px-4 rounded-xl"
>
<CheckCircle2 size={20} />
<span className="font-bold">{redeemSuccess}</span>
</motion.div>
)}
{/* Submit Button */}
<button
onClick={handleRedeemCode}
disabled={inputCode.length !== 6 || isRedeeming || !!redeemSuccess}
className="w-full py-4 bg-orange-600 hover:bg-orange-700 disabled:bg-zinc-800 disabled:text-zinc-600 text-white font-bold rounded-2xl transition-all flex items-center justify-center gap-2"
>
{isRedeeming ? (
<>
<Loader2 size={18} className="animate-spin" />
Verbinde...
</>
) : (
<>
<Link2 size={18} />
Verbinden
</>
)}
</button>
</div>
)}
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -2,9 +2,10 @@
import React, { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Users, UserPlus, Trash2, User, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { Users, UserPlus, Trash2, Loader2, ChevronDown, ChevronUp, Link2 } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
import { addBuddy, deleteBuddy } from '@/services/buddy';
import BuddyHandshake from './BuddyHandshake';
interface Buddy {
id: string;
@@ -25,6 +26,7 @@ export default function BuddyList() {
}
return false;
});
const [isHandshakeOpen, setIsHandshakeOpen] = useState(false);
useEffect(() => {
fetchBuddies();
@@ -117,6 +119,17 @@ export default function BuddyList() {
</button>
</form>
{/* Link Account Button */}
<button
onClick={() => setIsHandshakeOpen(true)}
className="w-full mb-6 py-3 bg-zinc-950 hover:bg-zinc-800 border border-zinc-800 hover:border-orange-600/50 rounded-2xl transition-all flex items-center justify-center gap-2 group"
>
<Link2 size={16} className="text-orange-600" />
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 group-hover:text-orange-500 transition-colors">
Account verbinden
</span>
</button>
{isLoading ? (
<div className="flex justify-center py-8 text-zinc-500">
<Loader2 size={24} className="animate-spin" />
@@ -173,6 +186,16 @@ export default function BuddyList() {
<span className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest ml-1">{buddies.length} Buddies</span>
</div>
)}
{/* Buddy Handshake Dialog */}
<BuddyHandshake
isOpen={isHandshakeOpen}
onClose={() => setIsHandshakeOpen(false)}
onSuccess={() => {
setIsHandshakeOpen(false);
fetchBuddies();
}}
/>
</div>
);
}

View File

@@ -0,0 +1,280 @@
'use client';
import React, { useState, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Camera, Trash2, Send, Loader2, Check, AlertCircle, Zap } from 'lucide-react';
import { useBulkScanner } from '@/hooks/useBulkScanner';
interface BulkScanSheetProps {
isOpen: boolean;
onClose: () => void;
sessionId: string;
sessionName: string;
onSuccess?: (bottleIds: string[]) => void;
}
export default function BulkScanSheet({
isOpen,
onClose,
sessionId,
sessionName,
onSuccess
}: BulkScanSheetProps) {
const { queue, addToQueue, removeFromQueue, clearQueue, submitToSession, isSubmitting, progress } = useBulkScanner();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [isCameraReady, setIsCameraReady] = useState(false);
const [cameraError, setCameraError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
// Start camera when sheet opens
React.useEffect(() => {
if (isOpen) {
startCamera();
} else {
stopCamera();
}
return () => stopCamera();
}, [isOpen]);
const startCamera = async () => {
try {
setCameraError(null);
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
}
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
streamRef.current = stream;
setIsCameraReady(true);
}
} catch (error) {
console.error('Camera error:', error);
setCameraError('Kamera konnte nicht gestartet werden');
}
};
const stopCamera = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setIsCameraReady(false);
};
const captureImage = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return;
const video = videoRef.current;
const canvas = canvasRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(video, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
addToQueue(blob, `Flasche #${queue.length + 1}`);
}
}, 'image/webp', 0.85);
}, [addToQueue, queue.length]);
const handleSubmit = async () => {
setSubmitError(null);
const result = await submitToSession(sessionId);
if (result.success && result.bottleIds) {
onSuccess?.(result.bottleIds);
setTimeout(() => {
onClose();
}, 500);
} else {
setSubmitError(result.error || 'Fehler beim Hochladen');
}
};
const handleClose = () => {
if (queue.length > 0 && !isSubmitting) {
if (!confirm('Warteschlange verwerfen?')) return;
}
clearQueue();
onClose();
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black z-50 flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between p-4 bg-zinc-900/80 backdrop-blur-sm border-b border-zinc-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
<Zap size={20} className="text-orange-500" />
</div>
<div>
<h2 className="text-sm font-bold text-white uppercase tracking-widest">Bulk Scan</h2>
<p className="text-xs text-zinc-500">{sessionName}</p>
</div>
</div>
<button
onClick={handleClose}
disabled={isSubmitting}
className="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-white disabled:opacity-50"
>
<X size={24} />
</button>
</div>
{/* Camera View */}
<div className="flex-1 relative overflow-hidden">
{cameraError ? (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-900">
<div className="text-center text-zinc-500">
<AlertCircle size={48} className="mx-auto mb-4 text-red-500" />
<p>{cameraError}</p>
</div>
</div>
) : (
<>
<video
ref={videoRef}
autoPlay
playsInline
muted
className="absolute inset-0 w-full h-full object-cover"
/>
{!isCameraReady && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-900">
<Loader2 size={32} className="animate-spin text-orange-500" />
</div>
)}
</>
)}
{/* Hidden canvas for capture */}
<canvas ref={canvasRef} className="hidden" />
{/* Capture Button */}
{isCameraReady && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
<button
onClick={captureImage}
disabled={isSubmitting}
className="w-20 h-20 bg-white rounded-full flex items-center justify-center shadow-2xl active:scale-95 transition-transform disabled:opacity-50 ring-4 ring-white/20"
>
<Camera size={32} className="text-zinc-900" />
</button>
</div>
)}
{/* Queue Counter Badge */}
{queue.length > 0 && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute top-4 right-4 bg-orange-600 text-white text-lg font-black px-4 py-2 rounded-full shadow-lg"
>
{queue.length}
</motion.div>
)}
</div>
{/* Queue Strip */}
{queue.length > 0 && (
<motion.div
initial={{ y: 100 }}
animate={{ y: 0 }}
className="bg-zinc-900 border-t border-zinc-800 p-4"
>
{/* Thumbnails */}
<div className="flex gap-2 overflow-x-auto pb-3 scrollbar-none">
{queue.map((item, i) => (
<motion.div
key={item.tempId}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="relative shrink-0"
>
<div className={`w-16 h-20 rounded-xl overflow-hidden ring-2 ${item.status === 'done' ? 'ring-green-500' :
item.status === 'error' ? 'ring-red-500' :
item.status === 'uploading' ? 'ring-orange-500 animate-pulse' :
'ring-zinc-700'
}`}>
<img
src={item.previewUrl}
alt={`Bottle ${i + 1}`}
className="w-full h-full object-cover"
/>
{item.status === 'uploading' && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Loader2 size={16} className="animate-spin text-orange-500" />
</div>
)}
{item.status === 'done' && (
<div className="absolute inset-0 bg-green-500/30 flex items-center justify-center">
<Check size={20} className="text-white" />
</div>
)}
</div>
{!isSubmitting && item.status === 'queued' && (
<button
onClick={() => removeFromQueue(item.tempId)}
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center shadow-lg"
>
<X size={12} className="text-white" />
</button>
)}
<span className="absolute bottom-0.5 left-0.5 text-[9px] font-bold text-white bg-black/60 px-1 rounded">
#{i + 1}
</span>
</motion.div>
))}
</div>
{/* Error Message */}
{submitError && (
<div className="mb-3 text-sm text-red-500 text-center bg-red-500/10 py-2 rounded-xl">
{submitError}
</div>
)}
{/* Submit Button */}
<button
onClick={handleSubmit}
disabled={isSubmitting || queue.length === 0}
className="w-full py-4 bg-orange-600 hover:bg-orange-700 disabled:bg-zinc-800 disabled:text-zinc-600 text-white font-bold rounded-2xl transition-all flex items-center justify-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 size={18} className="animate-spin" />
Hochladen {progress.current}/{progress.total}...
</>
) : (
<>
<Send size={18} />
{queue.length} Flasche{queue.length !== 1 ? 'n' : ''} zur Session hinzufügen
</>
)}
</button>
</motion.div>
)}
</motion.div>
</AnimatePresence>
);
}

View File

@@ -1,8 +1,10 @@
'use client';
import React from 'react';
import { Camera } from 'lucide-react';
import { motion } from 'framer-motion';
import React, { useState } from 'react';
import { Camera, Zap } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useSession } from '@/context/SessionContext';
import BulkScanSheet from './BulkScanSheet';
interface FloatingScannerButtonProps {
onImageSelected: (base64Image: string) => void;
@@ -10,6 +12,9 @@ interface FloatingScannerButtonProps {
export default function FloatingScannerButton({ onImageSelected }: FloatingScannerButtonProps) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const { activeSession } = useSession();
const [isBulkOpen, setIsBulkOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -23,7 +28,16 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
reader.readAsDataURL(file);
};
const handleMainClick = () => {
if (activeSession) {
setIsExpanded(!isExpanded);
} else {
fileInputRef.current?.click();
}
};
return (
<>
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
<input
type="file"
@@ -32,15 +46,58 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
accept="image/*"
className="hidden"
/>
{/* Expanded Options */}
<AnimatePresence>
{isExpanded && activeSession && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.8 }}
className="absolute bottom-20 left-1/2 -translate-x-1/2 flex gap-3"
>
{/* Single Scan */}
<motion.button
onClick={() => fileInputRef.current?.click()}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
setIsExpanded(false);
fileInputRef.current?.click();
}}
className="flex flex-col items-center gap-1.5 px-4 py-3 bg-zinc-900 border border-zinc-700 rounded-2xl shadow-xl"
>
<Camera size={24} className="text-white" />
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Einzel</span>
</motion.button>
{/* Bulk Scan */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
setIsExpanded(false);
setIsBulkOpen(true);
}}
className="flex flex-col items-center gap-1.5 px-4 py-3 bg-orange-600 rounded-2xl shadow-xl shadow-orange-950/30"
>
<Zap size={24} className="text-white" />
<span className="text-[10px] font-bold text-white/80 uppercase tracking-widest">Bulk</span>
</motion.button>
</motion.div>
)}
</AnimatePresence>
{/* Main Button */}
<motion.button
onClick={handleMainClick}
whileHover={{ scale: 1.1, translateY: -4 }}
whileTap={{ scale: 0.9 }}
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
animate={{ y: 0, opacity: 1, rotate: isExpanded ? 45 : 0 }}
className="relative group p-6 rounded-full bg-orange-600 text-black shadow-lg shadow-orange-950/40 hover:shadow-orange-950/60 transition-all overflow-hidden"
>
{/* Shine Animation */}
{!isExpanded && (
<motion.div
animate={{
x: ['-100%', '100%'],
@@ -53,12 +110,34 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
}}
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 -z-0"
/>
)}
<Camera size={32} strokeWidth={2.5} className="relative z-10" />
{/* Active Session Indicator */}
{activeSession && !isExpanded && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full border-2 border-orange-600 flex items-center justify-center">
<Zap size={10} className="text-white" />
</div>
)}
{/* Pulse ring */}
{!isExpanded && (
<span className="absolute inset-0 rounded-full border-4 border-orange-600 animate-ping opacity-20" />
)}
</motion.button>
</div>
{/* Bulk Scan Sheet */}
{activeSession && (
<BulkScanSheet
isOpen={isBulkOpen}
onClose={() => setIsBulkOpen(false)}
sessionId={activeSession.id}
sessionName={activeSession.name}
onSuccess={() => setIsBulkOpen(false)}
/>
)}
</>
);
}

View File

@@ -5,11 +5,11 @@ import { useEffect } from 'react';
export default function PWARegistration() {
useEffect(() => {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
// Register immediately - the page is already loaded when this component mounts
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration);
console.log('[PWA] SW registered:', registration.scope);
// Check for updates
registration.onupdatefound = () => {
@@ -18,17 +18,16 @@ export default function PWARegistration() {
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
console.log('[SW] New content is available; please refresh.');
console.log('[PWA] New content available; please refresh.');
} else {
console.log('[SW] Content is cached for offline use.');
console.log('[PWA] Content cached for offline use.');
}
}
};
};
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
console.error('[PWA] SW registration failed:', registrationError);
});
}
}, []);

View File

@@ -231,6 +231,9 @@ export default function UploadQueue() {
syncInProgress.current = false;
setIsSyncing(false);
setCurrentProgress(null);
// Dispatch event to notify that sync is complete and collection should be refreshed
window.dispatchEvent(new CustomEvent('collection-updated'));
}
}, [supabase]); // Removed pendingScans, pendingTastings, totalInQueue, isSyncing

152
src/hooks/useBulkScanner.ts Normal file
View File

@@ -0,0 +1,152 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { processBulkScan } from '@/services/bulk-scan';
import { nanoid } from 'nanoid';
export interface QueuedBottle {
tempId: string;
blob: Blob;
previewUrl: string;
status: 'queued' | 'uploading' | 'done' | 'error';
ocrPreview?: string;
}
export interface UseBulkScannerReturn {
queue: QueuedBottle[];
addToQueue: (blob: Blob, ocrHint?: string) => void;
removeFromQueue: (tempId: string) => void;
clearQueue: () => void;
submitToSession: (sessionId: string) => Promise<{ success: boolean; bottleIds?: string[]; error?: string }>;
isSubmitting: boolean;
progress: { current: number; total: number };
}
export function useBulkScanner(): UseBulkScannerReturn {
const [queue, setQueue] = useState<QueuedBottle[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
// Track blob URLs for cleanup
const blobUrlsRef = useRef<Set<string>>(new Set());
const addToQueue = useCallback((blob: Blob, ocrHint?: string) => {
const previewUrl = URL.createObjectURL(blob);
blobUrlsRef.current.add(previewUrl);
const newItem: QueuedBottle = {
tempId: nanoid(),
blob,
previewUrl,
status: 'queued',
ocrPreview: ocrHint,
};
setQueue(prev => [...prev, newItem]);
}, []);
const removeFromQueue = useCallback((tempId: string) => {
setQueue(prev => {
const item = prev.find(i => i.tempId === tempId);
if (item) {
URL.revokeObjectURL(item.previewUrl);
blobUrlsRef.current.delete(item.previewUrl);
}
return prev.filter(i => i.tempId !== tempId);
});
}, []);
const clearQueue = useCallback(() => {
// Revoke all blob URLs
queue.forEach(item => {
URL.revokeObjectURL(item.previewUrl);
});
blobUrlsRef.current.clear();
setQueue([]);
}, [queue]);
const submitToSession = useCallback(async (sessionId: string): Promise<{
success: boolean;
bottleIds?: string[];
error?: string
}> => {
if (queue.length === 0) {
return { success: false, error: 'Keine Flaschen in der Warteschlange' };
}
setIsSubmitting(true);
setProgress({ current: 0, total: queue.length });
try {
// Convert all blobs to base64 data URLs
const imageDataUrls: string[] = [];
for (let i = 0; i < queue.length; i++) {
const item = queue[i];
// Update status to uploading
setQueue(prev => prev.map(q =>
q.tempId === item.tempId
? { ...q, status: 'uploading' as const }
: q
));
// Convert blob to data URL
const dataUrl = await blobToDataUrl(item.blob);
imageDataUrls.push(dataUrl);
setProgress({ current: i + 1, total: queue.length });
}
// Submit all to server
const result = await processBulkScan(sessionId, imageDataUrls);
if (result.success) {
// Mark all as done
setQueue(prev => prev.map(q => ({ ...q, status: 'done' as const })));
// Clear queue after short delay
setTimeout(() => {
clearQueue();
}, 1000);
return { success: true, bottleIds: result.bottleIds };
} else {
// Mark all as error
setQueue(prev => prev.map(q => ({ ...q, status: 'error' as const })));
return { success: false, error: result.error };
}
} catch (error) {
console.error('Submit error:', error);
setQueue(prev => prev.map(q => ({ ...q, status: 'error' as const })));
return {
success: false,
error: error instanceof Error ? error.message : 'Unbekannter Fehler'
};
} finally {
setIsSubmitting(false);
}
}, [queue, clearQueue]);
return {
queue,
addToQueue,
removeFromQueue,
clearQueue,
submitToSession,
isSubmitting,
progress,
};
}
/**
* Convert Blob to base64 data URL
*/
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}

View File

@@ -150,11 +150,13 @@ export function useScanner(options: UseScannerOptions = {}) {
const online = isOnline();
const tesseractReady = await isTesseractReady();
// If offline and tesseract not ready, queue immediately
// If offline and tesseract not ready, show editor with dummy data
// Queue image for later processing when back online
if (!online && !tesseractReady) {
console.log('[useScanner] Offline + no tesseract cache → queuing');
console.log('[useScanner] Offline + no tesseract cache → showing editor with dummy data');
const dummyMetadata = generateDummyMetadata(file);
// Queue for later processing
await db.pending_scans.add({
temp_id: `temp_${Date.now()}`,
imageBase64: processedImage.base64,
@@ -163,9 +165,10 @@ export function useScanner(options: UseScannerOptions = {}) {
metadata: dummyMetadata as any,
});
// Show editor with dummy data (status: complete so editor opens!)
setResult(prev => ({
...prev,
status: 'queued',
status: 'complete',
mergedResult: dummyMetadata,
perf: {
compression: perfCompression,

View File

@@ -40,8 +40,14 @@ const distilleryFuse = new Fuse(distilleries, fuseOptions);
// Tesseract worker singleton (reused across scans)
let tesseractWorker: Tesseract.Worker | null = null;
// Character whitelist for whisky labels (no special symbols that cause noise)
const CHAR_WHITELIST = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789%.,:\'"-/ ';
// Character whitelist for whisky labels ("Pattern Hack")
// Restricts Tesseract to only whisky-relevant characters:
// - Letters: A-Z, a-z
// - Numbers: 0-9
// - Essential punctuation: .,%&-/ (for ABV "46.5%", names like "No. 1")
// - Space: for word separation
// This prevents garbage like ~, _, ^, {, § from appearing
const CHAR_WHITELIST = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.,%&-/ ';
/**
* Initialize or get the Tesseract worker
@@ -54,8 +60,9 @@ async function getWorker(): Promise<Tesseract.Worker> {
console.log('[LocalOCR] Initializing Tesseract worker with local files...');
// Use local files from /public/tessdata
// Use local files from /public/tessdata for full offline support
tesseractWorker = await Tesseract.createWorker('eng', Tesseract.OEM.LSTM_ONLY, {
workerPath: '/tessdata/worker.min.js', // Local worker for offline
corePath: '/tessdata/',
langPath: '/tessdata/',
logger: (m) => {
@@ -215,27 +222,46 @@ function findDistillery(text: string): { name: string; region: string; contextua
console.log('[LocalOCR] Lines for distillery matching:', lines.length);
// Try to match each line
// Blacklist common whisky words that shouldn't match distillery names
const blacklistedWords = new Set([
'reserve', 'malt', 'single', 'whisky', 'whiskey', 'scotch', 'bourbon',
'blended', 'irish', 'aged', 'years', 'edition', 'cask', 'barrel',
'distillery', 'vintage', 'special', 'limited', 'rare', 'old', 'gold',
'spirit', 'spirits', 'proof', 'strength', 'batch', 'select', 'finish'
]);
// Try to match each line using sliding word windows
for (const originalLine of lines) {
// STRIP & MATCH: Remove numbers for cleaner Fuse matching
// "Bad N NEVIS 27" → "Bad N NEVIS "
const textOnlyLine = originalLine.replace(/[0-9]/g, '').replace(/\s+/g, ' ').trim();
if (textOnlyLine.length < 4) continue;
const results = distilleryFuse.search(textOnlyLine);
// Split into words for window matching
const words = textOnlyLine.split(' ').filter(w => w.length >= 2);
if (results.length > 0 && results[0].score !== undefined && results[0].score < 0.4) {
// Try different window sizes (1-3 words) to find distillery within garbage
// E.g., "ge OO BEN NEVIS" → try "BEN NEVIS", "OO BEN", "BEN", etc.
for (let windowSize = Math.min(3, words.length); windowSize >= 1; windowSize--) {
for (let i = 0; i <= words.length - windowSize; i++) {
const phrase = words.slice(i, i + windowSize).join(' ');
if (phrase.length < 4) continue;
// Skip blacklisted common words
if (blacklistedWords.has(phrase.toLowerCase())) {
continue;
}
const results = distilleryFuse.search(phrase);
if (results.length > 0 && results[0].score !== undefined && results[0].score < 0.3) {
const match = results[0].item;
const matchScore = results[0].score;
// SANITY CHECK: The text-only part should be similar length to distillery name
// Max 60% deviation allowed (relaxed for partial matches)
const lengthRatio = textOnlyLine.length / match.name.length;
const lengthDeviation = Math.abs(1 - lengthRatio);
if (lengthDeviation > 0.6) {
console.log(`[LocalOCR] Match rejected (length): "${textOnlyLine}" → ${match.name} (ratio: ${lengthRatio.toFixed(2)}, deviation: ${(lengthDeviation * 100).toFixed(0)}%)`);
// SANITY CHECK: Length ratio should be reasonable (0.6 - 1.5)
const lengthRatio = phrase.length / match.name.length;
if (lengthRatio < 0.6 || lengthRatio > 1.5) {
continue;
}
@@ -250,7 +276,7 @@ function findDistillery(text: string): { name: string; region: string; contextua
}
}
console.log(`[LocalOCR] Distillery match: "${textOnlyLine}" → ${match.name} (score: ${matchScore.toFixed(3)}, original: "${originalLine}")`);
console.log(`[LocalOCR] Distillery match: "${phrase}" → ${match.name} (score: ${matchScore.toFixed(3)}, original: "${originalLine}")`);
return {
name: match.name,
region: match.region,
@@ -258,6 +284,8 @@ function findDistillery(text: string): { name: string; region: string; contextua
};
}
}
}
}
return null;
}

View File

@@ -27,12 +27,31 @@ export async function isTesseractReady(): Promise<boolean> {
}
try {
// Check for the core files in cache (matching actual file names in /public/tessdata)
const wasmMatch = await window.caches.match('/tessdata/tesseract-core-simd.wasm');
const langMatch = await window.caches.match('/tessdata/eng.traineddata');
// Check for the core files in cache
// Try to find files in any cache (not just default)
const cacheNames = await caches.keys();
console.log('[Scanner] Available caches:', cacheNames);
const ready = !!(wasmMatch && langMatch);
console.log('[Scanner] Offline cache check:', { wasmMatch: !!wasmMatch, langMatch: !!langMatch, ready });
let wasmMatch = false;
let langMatch = false;
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
for (const request of keys) {
const url = request.url;
if (url.includes('tesseract-core') && url.includes('.wasm')) {
wasmMatch = true;
}
if (url.includes('eng.traineddata')) {
langMatch = true;
}
}
}
const ready = wasmMatch && langMatch;
console.log('[Scanner] Offline cache check:', { wasmMatch, langMatch, ready, cacheCount: cacheNames.length });
return ready;
} catch (error) {
console.warn('[Scanner] Cache check failed:', error);
@@ -58,32 +77,48 @@ export function extractNumbers(text: string): ExtractedNumbers {
if (!text) return result;
// Normalize text: lowercase, clean up common OCR mistakes
const normalizedText = text
.replace(/[oO]/g, '0') // Common OCR mistake: O -> 0
.replace(/[lI]/g, '1') // Common OCR mistake: l/I -> 1
.toLowerCase();
// ========== ABV EXTRACTION (Enhanced) ==========
// Step 1: Normalize text for common Tesseract OCR mistakes
let normalizedText = text
// Fix % misread as numbers or text
.replace(/96/g, '%') // Tesseract often reads % as 96
.replace(/o\/o/gi, '%') // o/o → %
.replace(/°\/o/gi, '%') // °/o → %
.replace(/0\/0/g, '%') // 0/0 → %
// Fix common letter/number confusions
.replace(/[oO](?=\d)/g, '0') // O before digit → 0 (e.g., "O5" → "05")
.replace(/(?<=\d)[oO]/g, '0') // O after digit → 0 (e.g., "5O" → "50")
.replace(/[lI](?=\d)/g, '1') // l/I before digit → 1
.replace(/(?<=\d)[lI]/g, '1') // l/I after digit → 1
// Normalize decimal separators
.replace(/,/g, '.');
// ABV patterns: "43%", "43.5%", "43,5 %", "ABV 43", "vol. 43"
// Step 2: ABV patterns - looking for number before % or Vol
const abvPatterns = [
/(\d{2}[.,]\d{1,2})\s*%/, // 43.5% or 43,5 %
/(\d{2})\s*%/, // 43%
/abv[:\s]*(\d{2}[.,]?\d{0,2})/i, // ABV: 43 or ABV 43.5
/vol[.\s]*(\d{2}[.,]?\d{0,2})/i, // vol. 43
/(\d{2}[.,]\d{1,2})\s*vol/i, // 43.5 vol
/(\d{2}\.?\d{0,2})\s*%/, // 43%, 43.5%, 57.1%
/(\d{2}\.?\d{0,2})\s*(?:vol|alc)/i, // 43 vol, 43.5 alc
/(?:abv|alc|vol)[:\s]*(\d{2}\.?\d{0,2})/i, // ABV: 43, vol. 43.5
/(\d{2}\.?\d{0,2})\s*(?:percent|prozent)/i, // 43 percent/prozent
];
for (const pattern of abvPatterns) {
const match = normalizedText.match(pattern);
if (match) {
const value = parseFloat(match[1].replace(',', '.'));
if (value >= 35 && value <= 75) { // Reasonable whisky ABV range
const value = parseFloat(match[1]);
// STRICT RANGE GUARD: Only accept 35.0 - 75.0
// This prevents misidentifying years (1996) or volumes (700ml)
if (value >= 35.0 && value <= 75.0) {
result.abv = value;
console.log(`[ABV] Detected: ${value}% from pattern: ${pattern.source}`);
break;
} else {
console.log(`[ABV] Rejected ${value} - outside 35-75 range`);
}
}
}
// ========== AGE & VINTAGE (unchanged but use normalized text) ==========
// Age patterns: "12 years", "12 year old", "12 YO", "aged 12"
const agePatterns = [
/(\d{1,2})\s*(?:years?|yrs?|y\.?o\.?|jahre?)/i,
@@ -156,11 +191,13 @@ export interface PreprocessOptions {
edgeCrop?: number;
/** Target height for resizing. Default: 1200 */
targetHeight?: number;
/** Apply binarization (hard black/white). Default: false */
/** Apply simple binarization (hard black/white). Default: false */
binarize?: boolean;
/** Apply adaptive thresholding (better for uneven lighting). Default: true */
adaptiveThreshold?: boolean;
/** Contrast boost factor (1.0 = no change). Default: 1.3 */
contrastBoost?: number;
/** Apply sharpening. Default: true */
/** Apply sharpening. Default: false */
sharpen?: boolean;
}
@@ -186,8 +223,9 @@ export async function preprocessImageForOCR(
const {
edgeCrop = 0.05, // Remove 5% from each edge (minimal)
targetHeight = 1200, // High resolution
binarize = false, // Don't binarize by default
contrastBoost = 1.3, // 30% contrast boost
binarize = false, // Simple binarization (global threshold)
adaptiveThreshold = true, // Adaptive thresholding (local threshold) - better for uneven lighting
contrastBoost = 1.3, // 30% contrast boost (only if not using adaptive)
sharpen = false, // Disabled - creates noise on photos
} = options;
@@ -263,9 +301,100 @@ export async function preprocessImageForOCR(
}
}
// Second pass: Apply contrast enhancement
for (let i = 0; i < data.length; i += 4) {
let gray = data[i];
// Put processed data back (after grayscale conversion)
ctx.putImageData(imageData, 0, 0);
// Apply adaptive or simple binarization/contrast
if (adaptiveThreshold) {
// ========== ADAPTIVE THRESHOLDING ==========
// Uses integral image for efficient local mean calculation
// Better for uneven lighting on curved bottles
const adaptiveData = ctx.getImageData(0, 0, newWidth, newHeight);
const pixels = adaptiveData.data;
// Window size: ~1/20th of image width, minimum 11, must be odd
let windowSize = Math.max(11, Math.floor(newWidth / 20));
if (windowSize % 2 === 0) windowSize++;
const halfWindow = Math.floor(windowSize / 2);
// Sauvola-style constant: lower = more sensitive to text
const k = 0.15;
// Build integral image for fast local sum calculation
const integral = new Float64Array((newWidth + 1) * (newHeight + 1));
const integralSq = new Float64Array((newWidth + 1) * (newHeight + 1));
for (let y = 0; y < newHeight; y++) {
let rowSum = 0;
let rowSumSq = 0;
for (let x = 0; x < newWidth; x++) {
const idx = (y * newWidth + x) * 4;
const gray = pixels[idx];
rowSum += gray;
rowSumSq += gray * gray;
const iIdx = (y + 1) * (newWidth + 1) + (x + 1);
const iIdxAbove = y * (newWidth + 1) + (x + 1);
integral[iIdx] = rowSum + integral[iIdxAbove];
integralSq[iIdx] = rowSumSq + integralSq[iIdxAbove];
}
}
// Apply adaptive threshold
const output = new Uint8ClampedArray(pixels.length);
for (let y = 0; y < newHeight; y++) {
for (let x = 0; x < newWidth; x++) {
// Calculate local window bounds
const x1 = Math.max(0, x - halfWindow);
const y1 = Math.max(0, y - halfWindow);
const x2 = Math.min(newWidth - 1, x + halfWindow);
const y2 = Math.min(newHeight - 1, y + halfWindow);
const count = (x2 - x1 + 1) * (y2 - y1 + 1);
// Get local sum and sum of squares using integral image
const i11 = y1 * (newWidth + 1) + x1;
const i12 = y1 * (newWidth + 1) + (x2 + 1);
const i21 = (y2 + 1) * (newWidth + 1) + x1;
const i22 = (y2 + 1) * (newWidth + 1) + (x2 + 1);
const sum = integral[i22] - integral[i21] - integral[i12] + integral[i11];
const sumSq = integralSq[i22] - integralSq[i21] - integralSq[i12] + integralSq[i11];
const mean = sum / count;
const variance = (sumSq / count) - (mean * mean);
const stddev = Math.sqrt(Math.max(0, variance));
// Sauvola threshold: T = mean * (1 + k * (stddev/R - 1))
// R = dynamic range = 128 for grayscale
const threshold = mean * (1 + k * (stddev / 128 - 1));
const idx = (y * newWidth + x) * 4;
const pixel = pixels[idx];
const binaryValue = pixel < threshold ? 0 : 255;
output[idx] = output[idx + 1] = output[idx + 2] = binaryValue;
output[idx + 3] = 255;
}
}
// Copy output back
for (let i = 0; i < pixels.length; i++) {
pixels[i] = output[i];
}
ctx.putImageData(adaptiveData, 0, 0);
console.log('[PreprocessOCR] Adaptive thresholding applied:', {
windowSize,
k,
imageSize: `${newWidth}x${newHeight}`,
});
} else {
// Simple contrast enhancement + optional global binarization
const simpleData = ctx.getImageData(0, 0, newWidth, newHeight);
const pixels = simpleData.data;
for (let i = 0; i < pixels.length; i += 4) {
let gray = pixels[i];
gray = ((gray - 128) * contrastBoost) + 128;
gray = Math.min(255, Math.max(0, gray));
@@ -273,19 +402,18 @@ export async function preprocessImageForOCR(
gray = gray >= 128 ? 255 : 0;
}
data[i] = data[i + 1] = data[i + 2] = gray;
pixels[i] = pixels[i + 1] = pixels[i + 2] = gray;
}
// Put processed data back
ctx.putImageData(imageData, 0, 0);
ctx.putImageData(simpleData, 0, 0);
}
console.log('[PreprocessOCR] Image preprocessed:', {
original: `${img.width}x${img.height}`,
cropped: `${cropWidth}x${cropHeight} (${(edgeCrop * 100).toFixed(0)}% edge crop)`,
final: `${newWidth}x${newHeight}`,
sharpen,
contrastBoost,
mode: binarize ? 'binarized' : 'grayscale',
mode: adaptiveThreshold ? 'adaptive-threshold' : (binarize ? 'binarized' : 'grayscale+contrast'),
});
return canvas.toDataURL('image/png');

View File

@@ -1,8 +1,30 @@
import { createBrowserClient } from '@supabase/ssr';
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
import type { SupabaseClient } from '@supabase/supabase-js';
// Use globalThis to persist across HMR reloads in development
const globalForSupabase = globalThis as typeof globalThis & {
supabaseBrowserClient?: SupabaseClient;
};
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
if (globalForSupabase.supabaseBrowserClient) {
return globalForSupabase.supabaseBrowserClient;
}
// Use supabase-js directly with isSingleton to suppress the warning
globalForSupabase.supabaseBrowserClient = createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
auth: {
// Suppress "Multiple GoTrueClient instances" warning
// This is safe because we use a singleton pattern
storageKey: 'sb-auth-token',
persistSession: true,
detectSessionInUrl: true,
},
}
);
return globalForSupabase.supabaseBrowserClient;
}

252
src/services/buddy-link.ts Normal file
View File

@@ -0,0 +1,252 @@
'use server';
import { createClient } from '@/lib/supabase/server';
import { customAlphabet } from 'nanoid';
import { revalidatePath } from 'next/cache';
// Generate 6-char uppercase alphanumeric codes (no confusing chars like 0/O, 1/I/L)
const generateCode = customAlphabet('ABCDEFGHJKMNPQRSTUVWXYZ23456789', 6);
const CODE_EXPIRY_MINUTES = 15;
/**
* Generate a buddy invite code for the current user.
* Deletes any existing expired codes first.
*/
export async function generateBuddyCode(): Promise<{ success: boolean; code?: string; error?: string }> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
// Delete expired codes for this user
await supabase
.from('buddy_invites')
.delete()
.eq('creator_id', user.id)
.lt('expires_at', new Date().toISOString());
// Check if user already has an active code
const { data: existingCode } = await supabase
.from('buddy_invites')
.select('code, expires_at')
.eq('creator_id', user.id)
.gt('expires_at', new Date().toISOString())
.single();
if (existingCode) {
// Return existing code
return { success: true, code: existingCode.code };
}
// Generate new code
const code = generateCode();
const expiresAt = new Date(Date.now() + CODE_EXPIRY_MINUTES * 60 * 1000);
const { error } = await supabase
.from('buddy_invites')
.insert({
creator_id: user.id,
code,
expires_at: expiresAt.toISOString(),
});
if (error) {
console.error('Error creating buddy code:', error);
return { success: false, error: 'Code konnte nicht erstellt werden' };
}
return { success: true, code };
} catch (error) {
console.error('generateBuddyCode error:', error);
return { success: false, error: 'Unerwarteter Fehler' };
}
}
/**
* Redeem a buddy invite code and create the buddy relationship.
*/
export async function redeemBuddyCode(code: string): Promise<{ success: boolean; buddyName?: string; error?: string }> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, '');
if (normalizedCode.length !== 6) {
return { success: false, error: 'Ungültiger Code-Format' };
}
// Find the invite
const { data: invite, error: findError } = await supabase
.from('buddy_invites')
.select('id, creator_id, expires_at')
.eq('code', normalizedCode)
.single();
if (findError || !invite) {
return { success: false, error: 'Code nicht gefunden' };
}
// Check if expired
if (new Date(invite.expires_at) < new Date()) {
return { success: false, error: 'Code ist abgelaufen' };
}
// Cannot buddy yourself
if (invite.creator_id === user.id) {
return { success: false, error: 'Du kannst dich nicht selbst hinzufügen' };
}
// Check if relationship already exists
const { data: existingBuddy } = await supabase
.from('buddies')
.select('id')
.eq('user_id', user.id)
.eq('buddy_profile_id', invite.creator_id)
.single();
if (existingBuddy) {
return { success: false, error: 'Ihr seid bereits verbunden' };
}
// Get creator's profile for the buddy name
const { data: creatorProfile } = await supabase
.from('profiles')
.select('username')
.eq('id', invite.creator_id)
.single();
const buddyName = creatorProfile?.username || 'Buddy';
// Create buddy relationship (both directions)
// 1. Redeemer adds Creator as buddy
const { error: buddy1Error } = await supabase
.from('buddies')
.insert({
user_id: user.id,
name: buddyName,
buddy_profile_id: invite.creator_id,
});
if (buddy1Error) {
console.error('Error creating buddy (redeemer->creator):', buddy1Error);
return { success: false, error: 'Verbindung konnte nicht erstellt werden' };
}
// 2. Creator adds Redeemer as buddy
const { data: redeemerProfile } = await supabase
.from('profiles')
.select('username')
.eq('id', user.id)
.single();
const redeemerName = redeemerProfile?.username || 'Buddy';
// Use service role or direct insert (creator will see it via RLS)
const { error: buddy2Error } = await supabase
.from('buddies')
.insert({
user_id: invite.creator_id,
name: redeemerName,
buddy_profile_id: user.id,
});
if (buddy2Error) {
console.error('Error creating buddy (creator->redeemer):', buddy2Error);
// Don't fail - at least one direction worked
}
// Delete the used invite
await supabase
.from('buddy_invites')
.delete()
.eq('id', invite.id);
// Revalidate paths
revalidatePath('/');
return { success: true, buddyName };
} catch (error) {
console.error('redeemBuddyCode error:', error);
return { success: false, error: 'Unerwarteter Fehler' };
}
}
/**
* Get all buddies that are linked to real accounts (have buddy_profile_id).
*/
export async function getLinkedBuddies(): Promise<{
success: boolean;
buddies?: Array<{ id: string; name: string; buddy_profile_id: string; username?: string }>;
error?: string;
}> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
const { data, error } = await supabase
.from('buddies')
.select(`
id,
name,
buddy_profile_id,
profiles:buddy_profile_id (username)
`)
.eq('user_id', user.id)
.not('buddy_profile_id', 'is', null)
.order('name');
if (error) {
console.error('Error fetching linked buddies:', error);
return { success: false, error: 'Fehler beim Laden der Buddies' };
}
const buddies = (data || []).map(b => ({
id: b.id,
name: b.name,
buddy_profile_id: b.buddy_profile_id!,
username: (b.profiles as any)?.username,
}));
return { success: true, buddies };
} catch (error) {
console.error('getLinkedBuddies error:', error);
return { success: false, error: 'Unerwarteter Fehler' };
}
}
/**
* Revoke/cancel an active buddy invite code.
*/
export async function revokeBuddyCode(): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
await supabase
.from('buddy_invites')
.delete()
.eq('creator_id', user.id);
return { success: true };
} catch (error) {
console.error('revokeBuddyCode error:', error);
return { success: false, error: 'Unerwarteter Fehler' };
}
}

390
src/services/bulk-scan.ts Normal file
View File

@@ -0,0 +1,390 @@
'use server';
import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
export interface BulkScanResult {
success: boolean;
bottleIds?: string[];
tastingIds?: string[];
error?: string;
}
/**
* Process multiple bottle images in bulk for a tasting session.
* Creates skeleton bottles immediately and triggers background AI analysis.
*
* @param sessionId - The tasting session to link bottles to
* @param imageDataUrls - Array of base64 image data URLs
*/
export async function processBulkScan(
sessionId: string,
imageDataUrls: string[]
): Promise<BulkScanResult> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
// Verify session exists and belongs to user
const { data: session, error: sessionError } = await supabase
.from('tasting_sessions')
.select('id')
.eq('id', sessionId)
.eq('user_id', user.id)
.single();
if (sessionError || !session) {
return { success: false, error: 'Session nicht gefunden' };
}
const bottleIds: string[] = [];
const tastingIds: string[] = [];
// Process each image
for (let i = 0; i < imageDataUrls.length; i++) {
const imageDataUrl = imageDataUrls[i];
// 1. Upload image to Supabase Storage
const imageUrl = await uploadImage(supabase, user.id, imageDataUrl);
// 2. Create skeleton bottle with pending status
const { data: bottle, error: bottleError } = await supabase
.from('bottles')
.insert({
user_id: user.id,
name: `Wird analysiert... (#${i + 1})`,
processing_status: 'pending',
image_url: imageUrl,
status: 'sealed',
})
.select('id')
.single();
if (bottleError || !bottle) {
console.error('Error creating bottle:', bottleError);
continue;
}
bottleIds.push(bottle.id);
// 3. Create tasting to link bottle to session
const { data: tasting, error: tastingError } = await supabase
.from('tastings')
.insert({
bottle_id: bottle.id,
user_id: user.id,
session_id: sessionId,
// No rating yet - placeholder tasting
})
.select('id')
.single();
if (tastingError) {
console.error('Error creating tasting:', tastingError);
} else if (tasting) {
tastingIds.push(tasting.id);
}
}
// 4. Trigger background analysis for all bottles (fire & forget)
// This won't block the response
triggerBackgroundAnalysis(bottleIds, user.id).catch(err => {
console.error('Background analysis error:', err);
});
revalidatePath(`/sessions/${sessionId}`);
revalidatePath('/');
return {
success: true,
bottleIds,
tastingIds,
};
} catch (error) {
console.error('processBulkScan error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unbekannter Fehler',
};
}
}
/**
* Upload a base64 image to Supabase Storage
*/
async function uploadImage(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
dataUrl: string
): Promise<string | null> {
try {
// Convert base64 to blob
const base64Data = dataUrl.split(',')[1];
const mimeType = dataUrl.match(/data:(.*?);/)?.[1] || 'image/webp';
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: mimeType });
// Generate unique filename
const ext = mimeType.split('/')[1] || 'webp';
const filename = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
// Upload to storage
const { error: uploadError } = await supabase.storage
.from('bottles')
.upload(filename, blob, {
contentType: mimeType,
upsert: false,
});
if (uploadError) {
console.error('Upload error:', uploadError);
return null;
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('bottles')
.getPublicUrl(filename);
return publicUrl;
} catch (error) {
console.error('Image upload failed:', error);
return null;
}
}
/**
* Trigger background AI analysis for bottles.
* This runs asynchronously and updates bottles with results.
*/
async function triggerBackgroundAnalysis(bottleIds: string[], userId: string): Promise<void> {
const supabase = await createClient();
for (const bottleId of bottleIds) {
try {
// Update status to analyzing
await supabase
.from('bottles')
.update({ processing_status: 'analyzing' })
.eq('id', bottleId);
// Get bottle image
const { data: bottle } = await supabase
.from('bottles')
.select('image_url')
.eq('id', bottleId)
.single();
if (!bottle?.image_url) {
await markBottleError(supabase, bottleId, 'Kein Bild gefunden');
continue;
}
// Call Gemini analysis
const analysisResult = await analyzeBottleImage(bottle.image_url);
if (analysisResult.success && analysisResult.data) {
// Update bottle with AI results
await supabase
.from('bottles')
.update({
name: analysisResult.data.name || 'Unbekannter Whisky',
distillery: analysisResult.data.distillery,
category: analysisResult.data.category,
abv: analysisResult.data.abv,
age: analysisResult.data.age,
is_whisky: analysisResult.data.is_whisky ?? true,
confidence: analysisResult.data.confidence ?? 80,
processing_status: 'complete',
updated_at: new Date().toISOString(),
})
.eq('id', bottleId);
} else {
await markBottleError(supabase, bottleId, analysisResult.error || 'Analyse fehlgeschlagen');
}
} catch (error) {
console.error(`Analysis failed for bottle ${bottleId}:`, error);
await markBottleError(supabase, bottleId, 'Analysefehler');
}
}
}
async function markBottleError(
supabase: Awaited<ReturnType<typeof createClient>>,
bottleId: string,
error: string
): Promise<void> {
await supabase
.from('bottles')
.update({
processing_status: 'error',
name: `Fehler: ${error.slice(0, 50)}`,
updated_at: new Date().toISOString(),
})
.eq('id', bottleId);
}
/**
* Call Gemini to analyze bottle image
* Uses existing Gemini integration
*/
async function analyzeBottleImage(imageUrl: string): Promise<{
success: boolean;
data?: {
name: string;
distillery?: string;
category?: string;
abv?: number;
age?: number;
is_whisky?: boolean;
confidence?: number;
};
error?: string;
}> {
try {
// Fetch image and convert to base64
const response = await fetch(imageUrl);
if (!response.ok) {
return { success: false, error: 'Bild konnte nicht geladen werden' };
}
const blob = await response.blob();
const buffer = await blob.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
const mimeType = blob.type || 'image/webp';
// Call Gemini
const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
if (!apiKey) {
return { success: false, error: 'API Key nicht konfiguriert' };
}
const geminiResponse = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{
parts: [
{
text: `Analyze this whisky bottle image. Extract:
- name: Full product name
- distillery: Distillery name
- category: e.g. "Single Malt", "Bourbon", "Blend"
- abv: Alcohol percentage as number (e.g. 46.0)
- age: Age statement as number (e.g. 12), null if NAS
- is_whisky: boolean, false if not a whisky
- confidence: 0-100 how confident you are
Respond ONLY with valid JSON, no markdown.`
},
{
inline_data: {
mime_type: mimeType,
data: base64
}
}
]
}],
generationConfig: {
temperature: 0.1,
maxOutputTokens: 500,
}
})
}
);
if (!geminiResponse.ok) {
return { success: false, error: 'Gemini API Fehler' };
}
const geminiData = await geminiResponse.json();
const textContent = geminiData.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textContent) {
return { success: false, error: 'Keine Antwort von Gemini' };
}
// Parse JSON response
const jsonMatch = textContent.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
return { success: false, error: 'Ungültige Gemini-Antwort' };
}
const parsed = JSON.parse(jsonMatch[0]);
return { success: true, data: parsed };
} catch (error) {
console.error('Gemini analysis error:', error);
return { success: false, error: 'Analysefehler' };
}
}
/**
* Get bottles for a session with their processing status
*/
export async function getSessionBottles(sessionId: string): Promise<{
success: boolean;
bottles?: Array<{
id: string;
name: string;
distillery?: string;
abv?: number;
age?: number;
image_url?: string;
processing_status: string;
tasting_id: string;
}>;
error?: string;
}> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
const { data, error } = await supabase
.from('tastings')
.select(`
id,
bottles (
id,
name,
distillery,
abv,
age,
image_url,
processing_status
)
`)
.eq('session_id', sessionId)
.eq('user_id', user.id);
if (error) {
return { success: false, error: error.message };
}
const bottles = (data || [])
.filter(t => t.bottles)
.map(t => ({
...(t.bottles as any),
tasting_id: t.id,
}));
return { success: true, bottles };
} catch (error) {
return { success: false, error: 'Fehler beim Laden' };
}
}

View File

@@ -38,6 +38,7 @@ CREATE TABLE IF NOT EXISTS bottles (
abv DECIMAL,
age INTEGER,
status TEXT DEFAULT 'sealed' CHECK (status IN ('sealed', 'open', 'sampled', 'empty')),
processing_status TEXT DEFAULT 'complete' CHECK (processing_status IN ('pending', 'analyzing', 'complete', 'error')),
whiskybase_id TEXT,
image_url TEXT,
purchase_price DECIMAL(10, 2),
@@ -415,3 +416,32 @@ SELECT
(SELECT id FROM subscription_plans WHERE name = 'starter' LIMIT 1)
FROM auth.users u
ON CONFLICT (user_id) DO NOTHING;
-- ============================================
-- Buddy Invites (Handshake Codes)
-- ============================================
CREATE TABLE IF NOT EXISTS buddy_invites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
creator_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
code TEXT NOT NULL UNIQUE, -- 6 char uppercase alphanumeric
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
CREATE INDEX IF NOT EXISTS idx_buddy_invites_code ON buddy_invites(code);
CREATE INDEX IF NOT EXISTS idx_buddy_invites_creator_id ON buddy_invites(creator_id);
CREATE INDEX IF NOT EXISTS idx_buddy_invites_expires_at ON buddy_invites(expires_at);
ALTER TABLE buddy_invites ENABLE ROW LEVEL SECURITY;
-- Only creator can see their own invites
DROP POLICY IF EXISTS "buddy_invites_creator_policy" ON buddy_invites;
CREATE POLICY "buddy_invites_creator_policy" ON buddy_invites
FOR ALL USING ((SELECT auth.uid()) = creator_id);
-- Allow anyone to SELECT by code (needed for redemption) but only if not expired
DROP POLICY IF EXISTS "buddy_invites_redeem_policy" ON buddy_invites;
CREATE POLICY "buddy_invites_redeem_policy" ON buddy_invites
FOR SELECT USING (expires_at > now());

File diff suppressed because one or more lines are too long