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", "fuse.js": "^7.1.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"nanoid": "^5.1.6",
"next": "16.1.0", "next": "16.1.0",
"openai": "^6.15.0", "openai": "^6.15.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-qr-code": "^2.0.18",
"recharts": "^3.6.0", "recharts": "^3.6.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",

29
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
lucide-react: lucide-react:
specifier: ^0.468.0 specifier: ^0.468.0
version: 0.468.0(react@19.2.3) version: 0.468.0(react@19.2.3)
nanoid:
specifier: ^5.1.6
version: 5.1.6
next: next:
specifier: 16.1.0 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) 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: react-dom:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.3(react@19.2.3) version: 19.2.3(react@19.2.3)
react-qr-code:
specifier: ^2.0.18
version: 2.0.18(react@19.2.3)
recharts: recharts:
specifier: ^3.6.0 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) 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} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true 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: napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
@@ -2542,6 +2553,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
qr.js@0.0.0:
resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==}
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2556,6 +2570,11 @@ packages:
react-is@17.0.2: react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} 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: react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies: peerDependencies:
@@ -5358,6 +5377,8 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
nanoid@5.1.6: {}
napi-postinstall@0.3.4: {} napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
@@ -5573,6 +5594,8 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
qr.js@0.0.0: {}
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
react-dom@19.2.3(react@19.2.3): react-dom@19.2.3(react@19.2.3):
@@ -5584,6 +5607,12 @@ snapshots:
react-is@17.0.2: {} 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): react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1):
dependencies: dependencies:
'@types/use-sync-external-store': 0.0.6 '@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 // CONFIG: Assets
const STATIC_ASSETS = [ const STATIC_ASSETS = [
@@ -7,6 +7,16 @@ const STATIC_ASSETS = [
'/icon-512.png', '/icon-512.png',
'/favicon.ico', '/favicon.ico',
'/lib/browser-image-compression.js', '/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 = [ const CORE_PAGES = [
@@ -55,7 +65,7 @@ self.addEventListener('install', (event) => {
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => { 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 items = [...STATIC_ASSETS, ...CORE_PAGES];
const total = items.length; const total = items.length;
@@ -153,6 +163,31 @@ self.addEventListener('fetch', (event) => {
return; 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 // Navigation & Assets
const isNavigation = event.request.mode === 'navigate'; const isNavigation = event.request.mode === 'navigate';
const isAsset = event.request.destination === 'style' || 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 () => { return () => {
subscription.unsubscribe(); subscription.unsubscribe();
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('collection-updated', handleCollectionUpdated);
}; };
}, []); }, []);

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client'; 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 Link from 'next/link';
import AvatarStack from '@/components/AvatarStack'; import AvatarStack from '@/components/AvatarStack';
import { deleteSession } from '@/services/delete-session'; import { deleteSession } from '@/services/delete-session';
@@ -13,6 +13,8 @@ import { useI18n } from '@/i18n/I18nContext';
import SessionTimeline from '@/components/SessionTimeline'; import SessionTimeline from '@/components/SessionTimeline';
import SessionABVCurve from '@/components/SessionABVCurve'; import SessionABVCurve from '@/components/SessionABVCurve';
import OfflineIndicator from '@/components/OfflineIndicator'; import OfflineIndicator from '@/components/OfflineIndicator';
import BulkScanSheet from '@/components/BulkScanSheet';
import BottleSkeletonCard from '@/components/BottleSkeletonCard';
interface Buddy { interface Buddy {
id: string; id: string;
@@ -44,6 +46,7 @@ interface SessionTasting {
image_url?: string | null; image_url?: string | null;
abv: number; abv: number;
category?: string; category?: string;
processing_status?: string;
}; };
tasting_tags: { tasting_tags: {
tags: { tags: {
@@ -66,9 +69,31 @@ export default function SessionDetailPage() {
const [isAddingParticipant, setIsAddingParticipant] = useState(false); const [isAddingParticipant, setIsAddingParticipant] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
useEffect(() => { useEffect(() => {
fetchSessionData(); 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]); }, [id]);
const fetchSessionData = async () => { const fetchSessionData = async () => {
@@ -102,7 +127,7 @@ export default function SessionDetailPage() {
id, id,
rating, rating,
tasted_at, tasted_at,
bottles(id, name, distillery, image_url, abv, category), bottles(id, name, distillery, image_url, abv, category, processing_status),
tasting_tags(tags(name)) tasting_tags(tags(name))
`) `)
.eq('session_id', id) .eq('session_id', id)
@@ -388,14 +413,25 @@ export default function SessionDetailPage() {
<GlassWater size={16} className="text-orange-600" /> <GlassWater size={16} className="text-orange-600" />
Verkostete Flaschen Verkostete Flaschen
</h3> </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 <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" 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} /> <Plus size={16} />
Flasche hinzufügen Flasche
</Link> </Link>
</div> </div>
</div>
<SessionTimeline <SessionTimeline
tastings={tastings.map(t => ({ tastings={tastings.map(t => ({
@@ -413,6 +449,18 @@ export default function SessionDetailPage() {
</section> </section>
</div> </div>
</div> </div>
{/* Bulk Scan Sheet */}
<BulkScanSheet
isOpen={isBulkScanOpen}
onClose={() => setIsBulkScanOpen(false)}
sessionId={id as string}
sessionName={session.name}
onSuccess={(bottleIds) => {
setIsBulkScanOpen(false);
fetchSessionData();
}}
/>
</main> </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 React, { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client'; 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 { useI18n } from '@/i18n/I18nContext';
import { addBuddy, deleteBuddy } from '@/services/buddy'; import { addBuddy, deleteBuddy } from '@/services/buddy';
import BuddyHandshake from './BuddyHandshake';
interface Buddy { interface Buddy {
id: string; id: string;
@@ -25,6 +26,7 @@ export default function BuddyList() {
} }
return false; return false;
}); });
const [isHandshakeOpen, setIsHandshakeOpen] = useState(false);
useEffect(() => { useEffect(() => {
fetchBuddies(); fetchBuddies();
@@ -117,6 +119,17 @@ export default function BuddyList() {
</button> </button>
</form> </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 ? ( {isLoading ? (
<div className="flex justify-center py-8 text-zinc-500"> <div className="flex justify-center py-8 text-zinc-500">
<Loader2 size={24} className="animate-spin" /> <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> <span className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest ml-1">{buddies.length} Buddies</span>
</div> </div>
)} )}
{/* Buddy Handshake Dialog */}
<BuddyHandshake
isOpen={isHandshakeOpen}
onClose={() => setIsHandshakeOpen(false)}
onSuccess={() => {
setIsHandshakeOpen(false);
fetchBuddies();
}}
/>
</div> </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'; 'use client';
import React from 'react'; import React, { useState } from 'react';
import { Camera } from 'lucide-react'; import { Camera, Zap } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useSession } from '@/context/SessionContext';
import BulkScanSheet from './BulkScanSheet';
interface FloatingScannerButtonProps { interface FloatingScannerButtonProps {
onImageSelected: (base64Image: string) => void; onImageSelected: (base64Image: string) => void;
@@ -10,6 +12,9 @@ interface FloatingScannerButtonProps {
export default function FloatingScannerButton({ onImageSelected }: FloatingScannerButtonProps) { export default function FloatingScannerButton({ onImageSelected }: FloatingScannerButtonProps) {
const fileInputRef = React.useRef<HTMLInputElement>(null); 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 handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -23,7 +28,16 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
reader.readAsDataURL(file); reader.readAsDataURL(file);
}; };
const handleMainClick = () => {
if (activeSession) {
setIsExpanded(!isExpanded);
} else {
fileInputRef.current?.click();
}
};
return ( return (
<>
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50"> <div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
<input <input
type="file" type="file"
@@ -32,15 +46,58 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
accept="image/*" accept="image/*"
className="hidden" 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 <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 }} whileHover={{ scale: 1.1, translateY: -4 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
initial={{ y: 100, opacity: 0 }} 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" 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 */} {/* Shine Animation */}
{!isExpanded && (
<motion.div <motion.div
animate={{ animate={{
x: ['-100%', '100%'], 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" 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" /> <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 */} {/* Pulse ring */}
{!isExpanded && (
<span className="absolute inset-0 rounded-full border-4 border-orange-600 animate-ping opacity-20" /> <span className="absolute inset-0 rounded-full border-4 border-orange-600 animate-ping opacity-20" />
)}
</motion.button> </motion.button>
</div> </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() { export default function PWARegistration() {
useEffect(() => { useEffect(() => {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { // Register immediately - the page is already loaded when this component mounts
navigator.serviceWorker navigator.serviceWorker
.register('/sw.js') .register('/sw.js')
.then((registration) => { .then((registration) => {
console.log('SW registered: ', registration); console.log('[PWA] SW registered:', registration.scope);
// Check for updates // Check for updates
registration.onupdatefound = () => { registration.onupdatefound = () => {
@@ -18,17 +18,16 @@ export default function PWARegistration() {
installingWorker.onstatechange = () => { installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') { if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) { if (navigator.serviceWorker.controller) {
console.log('[SW] New content is available; please refresh.'); console.log('[PWA] New content available; please refresh.');
} else { } else {
console.log('[SW] Content is cached for offline use.'); console.log('[PWA] Content cached for offline use.');
} }
} }
}; };
}; };
}) })
.catch((registrationError) => { .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; syncInProgress.current = false;
setIsSyncing(false); setIsSyncing(false);
setCurrentProgress(null); 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 }, [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 online = isOnline();
const tesseractReady = await isTesseractReady(); 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) { 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); const dummyMetadata = generateDummyMetadata(file);
// Queue for later processing
await db.pending_scans.add({ await db.pending_scans.add({
temp_id: `temp_${Date.now()}`, temp_id: `temp_${Date.now()}`,
imageBase64: processedImage.base64, imageBase64: processedImage.base64,
@@ -163,9 +165,10 @@ export function useScanner(options: UseScannerOptions = {}) {
metadata: dummyMetadata as any, metadata: dummyMetadata as any,
}); });
// Show editor with dummy data (status: complete so editor opens!)
setResult(prev => ({ setResult(prev => ({
...prev, ...prev,
status: 'queued', status: 'complete',
mergedResult: dummyMetadata, mergedResult: dummyMetadata,
perf: { perf: {
compression: perfCompression, compression: perfCompression,

View File

@@ -40,8 +40,14 @@ const distilleryFuse = new Fuse(distilleries, fuseOptions);
// Tesseract worker singleton (reused across scans) // Tesseract worker singleton (reused across scans)
let tesseractWorker: Tesseract.Worker | null = null; let tesseractWorker: Tesseract.Worker | null = null;
// Character whitelist for whisky labels (no special symbols that cause noise) // Character whitelist for whisky labels ("Pattern Hack")
const CHAR_WHITELIST = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789%.,:\'"-/ '; // 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 * 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...'); 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, { tesseractWorker = await Tesseract.createWorker('eng', Tesseract.OEM.LSTM_ONLY, {
workerPath: '/tessdata/worker.min.js', // Local worker for offline
corePath: '/tessdata/', corePath: '/tessdata/',
langPath: '/tessdata/', langPath: '/tessdata/',
logger: (m) => { logger: (m) => {
@@ -215,27 +222,46 @@ function findDistillery(text: string): { name: string; region: string; contextua
console.log('[LocalOCR] Lines for distillery matching:', lines.length); 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) { for (const originalLine of lines) {
// STRIP & MATCH: Remove numbers for cleaner Fuse matching // 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(); const textOnlyLine = originalLine.replace(/[0-9]/g, '').replace(/\s+/g, ' ').trim();
if (textOnlyLine.length < 4) continue; 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 match = results[0].item;
const matchScore = results[0].score; const matchScore = results[0].score;
// SANITY CHECK: The text-only part should be similar length to distillery name // SANITY CHECK: Length ratio should be reasonable (0.6 - 1.5)
// Max 60% deviation allowed (relaxed for partial matches) const lengthRatio = phrase.length / match.name.length;
const lengthRatio = textOnlyLine.length / match.name.length; if (lengthRatio < 0.6 || lengthRatio > 1.5) {
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)}%)`);
continue; 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 { return {
name: match.name, name: match.name,
region: match.region, region: match.region,
@@ -258,6 +284,8 @@ function findDistillery(text: string): { name: string; region: string; contextua
}; };
} }
} }
}
}
return null; return null;
} }

View File

@@ -27,12 +27,31 @@ export async function isTesseractReady(): Promise<boolean> {
} }
try { try {
// Check for the core files in cache (matching actual file names in /public/tessdata) // Check for the core files in cache
const wasmMatch = await window.caches.match('/tessdata/tesseract-core-simd.wasm'); // Try to find files in any cache (not just default)
const langMatch = await window.caches.match('/tessdata/eng.traineddata'); const cacheNames = await caches.keys();
console.log('[Scanner] Available caches:', cacheNames);
const ready = !!(wasmMatch && langMatch); let wasmMatch = false;
console.log('[Scanner] Offline cache check:', { wasmMatch: !!wasmMatch, langMatch: !!langMatch, ready }); 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; return ready;
} catch (error) { } catch (error) {
console.warn('[Scanner] Cache check failed:', error); console.warn('[Scanner] Cache check failed:', error);
@@ -58,32 +77,48 @@ export function extractNumbers(text: string): ExtractedNumbers {
if (!text) return result; if (!text) return result;
// Normalize text: lowercase, clean up common OCR mistakes // ========== ABV EXTRACTION (Enhanced) ==========
const normalizedText = text // Step 1: Normalize text for common Tesseract OCR mistakes
.replace(/[oO]/g, '0') // Common OCR mistake: O -> 0 let normalizedText = text
.replace(/[lI]/g, '1') // Common OCR mistake: l/I -> 1 // Fix % misread as numbers or text
.toLowerCase(); .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 = [ const abvPatterns = [
/(\d{2}[.,]\d{1,2})\s*%/, // 43.5% or 43,5 % /(\d{2}\.?\d{0,2})\s*%/, // 43%, 43.5%, 57.1%
/(\d{2})\s*%/, // 43% /(\d{2}\.?\d{0,2})\s*(?:vol|alc)/i, // 43 vol, 43.5 alc
/abv[:\s]*(\d{2}[.,]?\d{0,2})/i, // ABV: 43 or ABV 43.5 /(?:abv|alc|vol)[:\s]*(\d{2}\.?\d{0,2})/i, // ABV: 43, vol. 43.5
/vol[.\s]*(\d{2}[.,]?\d{0,2})/i, // vol. 43 /(\d{2}\.?\d{0,2})\s*(?:percent|prozent)/i, // 43 percent/prozent
/(\d{2}[.,]\d{1,2})\s*vol/i, // 43.5 vol
]; ];
for (const pattern of abvPatterns) { for (const pattern of abvPatterns) {
const match = normalizedText.match(pattern); const match = normalizedText.match(pattern);
if (match) { if (match) {
const value = parseFloat(match[1].replace(',', '.')); const value = parseFloat(match[1]);
if (value >= 35 && value <= 75) { // Reasonable whisky ABV range // 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; result.abv = value;
console.log(`[ABV] Detected: ${value}% from pattern: ${pattern.source}`);
break; 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" // Age patterns: "12 years", "12 year old", "12 YO", "aged 12"
const agePatterns = [ const agePatterns = [
/(\d{1,2})\s*(?:years?|yrs?|y\.?o\.?|jahre?)/i, /(\d{1,2})\s*(?:years?|yrs?|y\.?o\.?|jahre?)/i,
@@ -156,11 +191,13 @@ export interface PreprocessOptions {
edgeCrop?: number; edgeCrop?: number;
/** Target height for resizing. Default: 1200 */ /** Target height for resizing. Default: 1200 */
targetHeight?: number; targetHeight?: number;
/** Apply binarization (hard black/white). Default: false */ /** Apply simple binarization (hard black/white). Default: false */
binarize?: boolean; binarize?: boolean;
/** Apply adaptive thresholding (better for uneven lighting). Default: true */
adaptiveThreshold?: boolean;
/** Contrast boost factor (1.0 = no change). Default: 1.3 */ /** Contrast boost factor (1.0 = no change). Default: 1.3 */
contrastBoost?: number; contrastBoost?: number;
/** Apply sharpening. Default: true */ /** Apply sharpening. Default: false */
sharpen?: boolean; sharpen?: boolean;
} }
@@ -186,8 +223,9 @@ export async function preprocessImageForOCR(
const { const {
edgeCrop = 0.05, // Remove 5% from each edge (minimal) edgeCrop = 0.05, // Remove 5% from each edge (minimal)
targetHeight = 1200, // High resolution targetHeight = 1200, // High resolution
binarize = false, // Don't binarize by default binarize = false, // Simple binarization (global threshold)
contrastBoost = 1.3, // 30% contrast boost 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 sharpen = false, // Disabled - creates noise on photos
} = options; } = options;
@@ -263,9 +301,100 @@ export async function preprocessImageForOCR(
} }
} }
// Second pass: Apply contrast enhancement // Put processed data back (after grayscale conversion)
for (let i = 0; i < data.length; i += 4) { ctx.putImageData(imageData, 0, 0);
let gray = data[i];
// 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 = ((gray - 128) * contrastBoost) + 128;
gray = Math.min(255, Math.max(0, gray)); gray = Math.min(255, Math.max(0, gray));
@@ -273,19 +402,18 @@ export async function preprocessImageForOCR(
gray = gray >= 128 ? 255 : 0; 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(simpleData, 0, 0);
ctx.putImageData(imageData, 0, 0); }
console.log('[PreprocessOCR] Image preprocessed:', { console.log('[PreprocessOCR] Image preprocessed:', {
original: `${img.width}x${img.height}`, original: `${img.width}x${img.height}`,
cropped: `${cropWidth}x${cropHeight} (${(edgeCrop * 100).toFixed(0)}% edge crop)`, cropped: `${cropWidth}x${cropHeight} (${(edgeCrop * 100).toFixed(0)}% edge crop)`,
final: `${newWidth}x${newHeight}`, final: `${newWidth}x${newHeight}`,
sharpen, sharpen,
contrastBoost, mode: adaptiveThreshold ? 'adaptive-threshold' : (binarize ? 'binarized' : 'grayscale+contrast'),
mode: binarize ? 'binarized' : 'grayscale',
}); });
return canvas.toDataURL('image/png'); 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() { export function createClient() {
return createBrowserClient( if (globalForSupabase.supabaseBrowserClient) {
process.env.NEXT_PUBLIC_SUPABASE_URL!, return globalForSupabase.supabaseBrowserClient;
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! }
);
// 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, abv DECIMAL,
age INTEGER, age INTEGER,
status TEXT DEFAULT 'sealed' CHECK (status IN ('sealed', 'open', 'sampled', 'empty')), 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, whiskybase_id TEXT,
image_url TEXT, image_url TEXT,
purchase_price DECIMAL(10, 2), purchase_price DECIMAL(10, 2),
@@ -415,3 +416,32 @@ SELECT
(SELECT id FROM subscription_plans WHERE name = 'starter' LIMIT 1) (SELECT id FROM subscription_plans WHERE name = 'starter' LIMIT 1)
FROM auth.users u FROM auth.users u
ON CONFLICT (user_id) DO NOTHING; 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