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

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