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:
296
src/components/BuddyHandshake.tsx
Normal file
296
src/components/BuddyHandshake.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user