feat: implement Save & Taste flow in CameraCapture

This commit is contained in:
2025-12-18 11:49:40 +01:00
parent 2685176992
commit 5f757d7b56
9 changed files with 244 additions and 19 deletions

View File

@@ -1,17 +1,8 @@
'use client';
import React, { useRef, useState } from 'react';
import { Camera, Upload, CheckCircle2, AlertCircle, Sparkles, ExternalLink } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { analyzeBottle } from '@/services/analyze-bottle';
import { saveBottle } from '@/services/save-bottle';
import { BottleMetadata } from '@/types/whisky';
import { savePendingBottle } from '@/lib/offline-db';
import { v4 as uuidv4 } from 'uuid';
import { findMatchingBottle } from '@/services/find-matching-bottle';
import Link from 'next/link';
// ... (skipping to line 192 in the actual file, index adjust needed)
import { ChevronRight } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void;
@@ -21,6 +12,10 @@ interface CameraCaptureProps {
export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onSaveComplete }: CameraCaptureProps) {
const supabase = createClientComponentClient();
const router = useRouter();
const searchParams = useSearchParams();
const sessionId = searchParams.get('session_id');
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
@@ -29,6 +24,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const [analysisResult, setAnalysisResult] = useState<BottleMetadata | null>(null);
const [isQueued, setIsQueued] = useState(false);
const [matchingBottle, setMatchingBottle] = useState<{ id: string; name: string } | null>(null);
const [lastSavedId, setLastSavedId] = useState<string | null>(null);
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -100,11 +96,9 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const response = await saveBottle(analysisResult, previewUrl, user.id);
if (response.success) {
setPreviewUrl(null);
setAnalysisResult(null);
if (response.success && response.data) {
setLastSavedId(response.data.id);
if (onSaveComplete) onSaveComplete();
// Optionale Erfolgsmeldung oder Redirect
} else {
setError(response.error || 'Speichern fehlgeschlagen.');
}
@@ -190,9 +184,38 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
className="hidden"
/>
{matchingBottle ? (
{lastSavedId ? (
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
<div className="flex items-center gap-2 text-green-600 font-bold justify-center p-2">
<CheckCircle2 size={24} className="text-green-500" />
Erfolgreich gespeichert!
</div>
<button
onClick={() => {
const url = `/bottles/${lastSavedId}${sessionId ? `?session_id=${sessionId}` : ''}`;
router.push(url);
}}
className="w-full py-4 px-6 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl font-black uppercase tracking-widest flex items-center justify-center gap-2 hover:bg-zinc-800 dark:hover:bg-white transition-all shadow-xl"
>
Jetzt verkosten
<ChevronRight size={20} />
</button>
<button
onClick={() => {
setPreviewUrl(null);
setAnalysisResult(null);
setLastSavedId(null);
}}
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200 font-bold transition-colors"
>
Später (Zurück zur Liste)
</button>
</div>
) : matchingBottle ? (
<Link
href={`/bottles/${matchingBottle.id}`}
href={`/bottles/${matchingBottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`}
className="w-full py-4 px-6 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-amber-600/20"
>
<ExternalLink size={20} />

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { saveTasting } from '../save-tasting';
import { createServerActionClient } from '@supabase/auth-helpers-nextjs';
// Mock Supabase
vi.mock('@supabase/auth-helpers-nextjs', () => ({
createServerActionClient: vi.fn(),
}));
// Mock next/headers
vi.mock('next/headers', () => ({
cookies: vi.fn(),
}));
// Mock next/cache
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}));
describe('saveTasting', () => {
const mockInsert = vi.fn();
const mockSelect = vi.fn();
const mockSingle = vi.fn();
const mockGetSession = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
const mockSupabase = {
auth: {
getSession: mockGetSession,
},
from: vi.fn().mockReturnValue({
insert: mockInsert,
select: mockSelect,
eq: vi.fn(),
}),
};
mockInsert.mockReturnValue({ select: mockSelect });
mockSelect.mockReturnValue({ single: mockSingle });
(createServerActionClient as any).mockReturnValue(mockSupabase);
});
it('should save a tasting note and tags', async () => {
mockGetSession.mockResolvedValue({
data: { session: { user: { id: 'user-123' } } },
});
mockSingle.mockResolvedValue({
data: { id: 'tasting-456' },
error: null,
});
// Mock the second insert for tags
const mockTagInsert = vi.fn().mockResolvedValue({ error: null });
(createServerActionClient({ cookies: {} as any }).from as any).mockImplementation((table: string) => {
if (table === 'tastings') {
return { insert: mockInsert, select: mockSelect };
}
return { insert: mockTagInsert };
});
const result = await saveTasting({
bottle_id: 'bottle-789',
rating: 90,
buddy_ids: ['buddy-1', 'buddy-2'],
});
expect(result.success).toBe(true);
expect(mockInsert).toHaveBeenCalledWith(expect.objectContaining({
bottle_id: 'bottle-789',
rating: 90,
}));
// Check tags were inserted with user_id (the fix for recursion)
expect(mockTagInsert).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ buddy_id: 'buddy-1', user_id: 'user-123' }),
expect.objectContaining({ buddy_id: 'buddy-2', user_id: 'user-123' }),
]));
});
});

10
src/tests/setup.ts Normal file
View File

@@ -0,0 +1,10 @@
import '@testing-library/jest-dom';
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
afterEach(() => {
cleanup();
});