feat: implement automated Whiskybase ID discovery

This commit is contained in:
2025-12-18 12:40:57 +01:00
parent 35c2443473
commit fef1c4a275
3 changed files with 211 additions and 3 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import React, { useRef, useState } from 'react';
import { Camera, Upload, CheckCircle2, AlertCircle, Sparkles, ExternalLink, ChevronRight } from 'lucide-react';
import { Camera, Upload, CheckCircle2, AlertCircle, Sparkles, ExternalLink, ChevronRight, Search, Loader2 } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useRouter, useSearchParams } from 'next/navigation';
import { analyzeBottle } from '@/services/analyze-bottle';
@@ -11,6 +11,8 @@ import { savePendingBottle } from '@/lib/offline-db';
import { v4 as uuidv4 } from 'uuid';
import { findMatchingBottle } from '@/services/find-matching-bottle';
import { validateSession } from '@/services/validate-session';
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
import { updateBottle } from '@/services/update-bottle';
import Link from 'next/link';
interface CameraCaptureProps {
@@ -47,6 +49,8 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const [isQueued, setIsQueued] = useState(false);
const [matchingBottle, setMatchingBottle] = useState<{ id: string; name: string } | null>(null);
const [lastSavedId, setLastSavedId] = useState<string | null>(null);
const [wbDiscovery, setWbDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
const [isDiscovering, setIsDiscovering] = useState(false);
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -153,6 +157,33 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
}
};
const handleDiscoverWb = async () => {
if (!lastSavedId || !analysisResult) return;
setIsDiscovering(true);
const result = await discoverWhiskybaseId({
name: analysisResult.name || '',
distillery: analysisResult.distillery || undefined,
abv: analysisResult.abv || undefined,
age: analysisResult.age || undefined
});
if (result.success && result.id) {
setWbDiscovery({ id: result.id, url: result.url!, title: result.title! });
}
setIsDiscovering(false);
};
const handleLinkWb = async () => {
if (!lastSavedId || !wbDiscovery) return;
const res = await updateBottle(lastSavedId, {
whiskybase_id: wbDiscovery.id
});
if (res.success) {
setWbDiscovery(null);
// Show some success feedback if needed, but the button will disappear anyway
}
};
const compressImage = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -245,6 +276,50 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
<ChevronRight size={20} />
</button>
{!wbDiscovery && !isDiscovering && (
<button
onClick={handleDiscoverWb}
className="w-full py-3 px-6 bg-amber-50 dark:bg-amber-900/20 text-amber-600 rounded-xl font-bold flex items-center justify-center gap-2 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 transition-all text-sm"
>
<Search size={16} />
Whiskybase-Link suchen
</button>
)}
{isDiscovering && (
<div className="w-full py-3 px-6 text-zinc-400 font-bold flex items-center justify-center gap-2 text-sm italic">
<Loader2 size={16} className="animate-spin" />
Suche auf Whiskybase...
</div>
)}
{wbDiscovery && (
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/50 border border-amber-500/30 rounded-2xl space-y-3 animate-in fade-in slide-in-from-top-2">
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-amber-600">
<Sparkles size={12} /> Treffer gefunden
</div>
<p className="text-xs font-bold text-zinc-800 dark:text-zinc-200 line-clamp-2 leading-snug">
{wbDiscovery.title}
</p>
<div className="flex gap-2">
<button
onClick={handleLinkWb}
className="flex-1 py-2.5 bg-amber-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-amber-700 transition-colors"
>
Verknüpfen
</button>
<a
href={wbDiscovery.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-2.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 transition-colors flex items-center justify-center gap-1"
>
<ExternalLink size={12} /> Prüfen
</a>
</div>
</div>
)}
<button
onClick={() => {
setPreviewUrl(null);

View File

@@ -1,8 +1,9 @@
'use client';
import React, { useState } from 'react';
import { Edit2, Save, X, Info, Tag, FlaskConical, CircleDollarSign } from 'lucide-react';
import { Edit2, Save, X, Info, Tag, FlaskConical, CircleDollarSign, Search, Loader2, ExternalLink } from 'lucide-react';
import { updateBottle } from '@/services/update-bottle';
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
interface EditBottleFormProps {
bottle: {
@@ -21,7 +22,9 @@ interface EditBottleFormProps {
export default function EditBottleForm({ bottle, onComplete }: EditBottleFormProps) {
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [discoveryResult, setDiscoveryResult] = useState<{ id: string; url: string; title: string } | null>(null);
const [formData, setFormData] = useState({
name: bottle.name,
@@ -33,6 +36,33 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
purchase_price: bottle.purchase_price || '',
});
const handleDiscover = async () => {
setIsSearching(true);
setError(null);
setDiscoveryResult(null);
const result = await discoverWhiskybaseId({
name: formData.name,
distillery: formData.distillery,
abv: formData.abv,
age: formData.age
});
if (result.success && result.id) {
setDiscoveryResult({ id: result.id!, url: result.url!, title: result.title! });
} else {
setError(result.error || 'Keinen Treffer gefunden.');
}
setIsSearching(false);
};
const applyDiscovery = () => {
if (discoveryResult) {
setFormData({ ...formData, whiskybase_id: discoveryResult.id });
setDiscoveryResult(null);
}
};
const handleSave = async () => {
setIsSaving(true);
setError(null);
@@ -142,13 +172,47 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
</div>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1">Whiskybase ID</label>
<label className="text-[10px] font-black uppercase text-zinc-400 ml-1 flex justify-between items-center">
<span>Whiskybase ID</span>
<button
type="button"
onClick={handleDiscover}
disabled={isSearching}
className="text-amber-600 hover:text-amber-700 flex items-center gap-1 normal-case font-bold"
>
{isSearching ? <Loader2 size={10} className="animate-spin" /> : <Search size={10} />}
Automatisch suchen
</button>
</label>
<input
type="text"
value={formData.whiskybase_id}
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
className="w-full px-4 py-2 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl outline-none focus:ring-2 focus:ring-amber-500"
/>
{discoveryResult && (
<div className="mt-2 p-3 bg-zinc-50 dark:bg-zinc-800/50 border border-amber-500/20 rounded-xl animate-in fade-in slide-in-from-top-2">
<p className="text-[10px] text-zinc-500 mb-2">Treffer gefunden:</p>
<p className="text-[11px] font-bold text-zinc-800 dark:text-zinc-200 mb-2 truncate">{discoveryResult.title}</p>
<div className="flex gap-2">
<button
type="button"
onClick={applyDiscovery}
className="px-3 py-1.5 bg-amber-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-amber-700 transition-colors"
>
ID Übernehmen
</button>
<a
href={discoveryResult.url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors flex items-center gap-1"
>
<ExternalLink size={10} /> Prüfen
</a>
</div>
</div>
)}
</div>
<div className="space-y-1">
<label className="text-[10px] font-black uppercase text-amber-600 ml-1 flex items-center gap-1">

View File

@@ -0,0 +1,69 @@
'use server';
/**
* Service to discover a Whiskybase ID for a given bottle.
* Uses SerpApi to search Google and extracts the ID from the first result.
*/
export async function discoverWhiskybaseId(bottle: {
name: string;
distillery?: string;
abv?: number;
age?: number;
}) {
const apiKey = process.env.SERPAPI_API_KEY;
if (!apiKey) {
return {
success: false,
error: 'SERPAPI_API_KEY ist nicht konfiguriert.'
};
}
try {
// Construct targeted search query
// site:whiskybase.com/whiskies/whisky ensures we only get product pages
const queryParts = [
'site:whiskybase.com/whiskies/whisky',
`"${bottle.distillery || ''}"`,
`"${bottle.name}"`,
bottle.abv ? `${bottle.abv}%` : '',
bottle.age ? `${bottle.age} year old` : ''
].filter(Boolean);
const q = queryParts.join(' ');
const url = `https://serpapi.com/search.json?q=${encodeURIComponent(q)}&api_key=${apiKey}&engine=google&num=5`;
const response = await fetch(url);
const data = await response.json();
if (!data.organic_results || data.organic_results.length === 0) {
return { success: false, error: 'Keine Treffer auf Whiskybase gefunden.' };
}
// Try to find the first result that looks like a valid product page
// Pattern matches: https://www.whiskybase.com/whiskies/whisky/12345/name
const wbRegex = /\/whisky\/(\d+)\//;
for (const result of data.organic_results) {
const link = result.link;
const match = link.match(wbRegex);
if (match && match[1]) {
return {
success: true,
id: match[1],
url: link,
title: result.title
};
}
}
return { success: false, error: 'Konnte keine gültige Whiskybase-ID im Suchergebnis finden.' };
} catch (error) {
console.error('Whiskybase Discovery Error:', error);
return {
success: false,
error: 'Fehler bei der Suche auf Whiskybase.'
};
}
}