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

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

View File

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