feat: improve AI resilience, add background enrichment loading states, and fix duplicate identifier in TagSelector

This commit is contained in:
2025-12-23 11:38:16 +01:00
parent 1d98bb9947
commit c134c78a2c
37 changed files with 1906 additions and 786 deletions

105
.aiideas
View File

@@ -1,28 +1,85 @@
Act as a Senior TypeScript/Next.js Developer.
´
I need a robust client-side image processing utility (an "Image Agent") to optimize user uploads before sending them to an LLM or Supabase.
Act as a Senior Next.js Developer.
**Task:**
Create a utility file `src/utils/image-processing.ts`.
This file should export a function `processImageForAI` that uses the library `browser-image-compression`.
Refactor the whisky analysis logic to optimize for perceived performance using a "Optimistic UI" approach.
Split the current `analyzeBottle` logic into **two separate Server Actions**.
**Requirements:**
1. **Input:** The function takes a raw `File` object (from an HTML input).
2. **Processing Logic:**
- Resize the image to a maximum of **1024x1024** pixels (maintain aspect ratio).
- Convert the image to **WebP** format.
- Limit the file size to approx **0.4MB**.
- Enable `useWebWorker: true` to prevent UI freezing.
3. **Output:** The function must return a Promise that resolves to an object with this interface:
```typescript
interface ProcessedImage {
file: File; // The compressed WebP file (ready for Supabase storage)
base64: string; // The Base64 string (ready for LLM API calls)
originalFile: File; // Pass through the original file
}
```
4. **Helper:** Include a helper function to convert the resulting Blob/File to a Base64 string correctly.
5. **Edge Cases:** Handle errors gracefully (try/catch) and ensure the returned `file` has the correct `.webp` extension and mime type.
### 1. Create `src/app/actions/scan-label.ts` (Fast OCR)
This action handles the image upload via `FormData`.
It must use the model with `safetySettings: BLOCK_NONE`.
It must **NOT** generate flavor tags or search strings.
**Step 1:** Give me the `npm install` command to add the necessary library.
**Step 2:** Write the complete `src/utils/image-processing.ts` code with proper JSDoc comments.
**System Prompt for this Action:**
```text
ROLE: High-Precision OCR Engine for Whisky Labels.
OBJECTIVE: Extract visible metadata strictly from the image.
SPEED PRIORITY: Do NOT analyze flavor. Do NOT provide descriptions. Do NOT add tags.
TASK:
1. Identify if the image contains a whisky/spirit bottle.
2. Extract the following technical details into the JSON schema below.
3. If a value is not visible or cannot be inferred with high certainty, use null.
EXTRACTION RULES:
- Name: Combine Distillery + Age + Edition + Vintage (e.g., "Signatory Vintage Ben Nevis 2019 4 Year Old").
- Distillery: The producer of the spirit.
- Bottler: Independent bottler (e.g., "Signatory", "Gordon & MacPhail") if applicable.
- Batch Info: Capture ALL Cask numbers, Batch IDs, Bottle numbers, Cask Types (e.g., "Refill Oloroso Sherry Butt, Bottle 1135").
- Codes: Look for laser codes etched on glass/label (e.g., "L20394...").
- Dates: Distinguish clearly between Vintage (distilled year), Bottled year, and Age.
OUTPUT SCHEMA (Strict JSON):
{
"name": "string",
"distillery": "string",
"bottler": "stringOrNull",
"category": "string (e.g. Single Malt Scotch Whisky)",
"abv": numberOrNull,
"age": numberOrNull,
"vintage": "stringOrNull",
"distilled_at": "stringOrNull (Year/Date)",
"bottled_at": "stringOrNull (Year/Date)",
"batch_info": "stringOrNull",
"bottleCode": "stringOrNull",
"whiskybaseId": "stringOrNull",
"is_whisky": boolean,
"confidence": number
}
2. Create src/app/actions/enrich-data.ts (Magic/Tags)
This action takes name and distillery (strings) as input. No image upload. It uses gemini-1.5-flash to retrieve knowledge-based data.
System Prompt for this Action:
Plaintext
TASK: You are a Whisky Sommelier.
INPUT: A whisky named "${name}" from distillery "${distillery}".
1. DATABASE LOOKUP:
Retrieve the sensory profile and specific Whiskybase search string for this bottling.
2. TAGGING:
Select the top 5-8 flavor tags strictly from this list:
[${availableTags}]
3. SEARCH STRING:
Create a precise search string for Whiskybase using: "site:whiskybase.com [Distillery] [Vintage/Age] [Bottler/Edition]"
OUTPUT JSON:
{
"suggested_tags": ["tag1", "tag2", "tag3"],
"suggested_custom_tags": ["unique_note_if_missing_in_list"],
"search_string": "string"
}
3. Integration
Update the frontend component to:
Call scan-label first.
Update the UI state with the metadata immediately.
If the scan was successful, automatically trigger enrich-data in the background to fetch tags and search string.

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -12,11 +12,13 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@ai-sdk/google": "^2.0.51",
"@google/generative-ai": "^0.24.1",
"@mistralai/mistralai": "^1.11.0",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.47.10",
"@tanstack/react-query": "^5.62.7",
"ai": "^5.0.116",
"browser-image-compression": "^2.0.2",
"canvas-confetti": "^1.9.3",
"dexie": "^4.2.1",
@@ -31,7 +33,8 @@
"recharts": "^3.6.0",
"sharp": "^0.34.5",
"uuid": "^13.0.0",
"zod": "^3.23.8"
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.0"
},
"devDependencies": {
"@playwright/test": "^1.57.0",

102
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@ai-sdk/google':
specifier: ^2.0.51
version: 2.0.51(zod@3.25.76)
'@google/generative-ai':
specifier: ^0.24.1
version: 0.24.1
@@ -23,6 +26,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.62.7
version: 5.90.12(react@19.2.3)
ai:
specifier: ^5.0.116
version: 5.0.116(zod@3.25.76)
browser-image-compression:
specifier: ^2.0.2
version: 2.0.2
@@ -46,7 +52,7 @@ importers:
version: 0.468.0(react@19.2.3)
next:
specifier: 16.1.0
version: 16.1.0(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
version: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
openai:
specifier: ^6.15.0
version: 6.15.0(ws@8.18.3)(zod@3.25.76)
@@ -68,6 +74,9 @@ importers:
zod:
specifier: ^3.23.8
version: 3.25.76
zod-to-json-schema:
specifier: ^3.25.0
version: 3.25.0(zod@3.25.76)
devDependencies:
'@playwright/test':
specifier: ^1.57.0
@@ -116,7 +125,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.0.16
version: 4.0.16(@types/node@20.19.27)(jiti@1.21.7)(jsdom@27.3.0)
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@1.21.7)(jsdom@27.3.0)
packages:
@@ -126,6 +135,28 @@ packages:
'@adobe/css-tools@4.4.4':
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
'@ai-sdk/gateway@2.0.23':
resolution: {integrity: sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/google@2.0.51':
resolution: {integrity: sha512-5VMHdZTP4th00hthmh98jP+BZmxiTRMB9R2qh/AuF6OkQeiJikqxZg3hrWDfYrCmQ12wDjy6CbIypnhlwZiYrg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider-utils@3.0.19':
resolution: {integrity: sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/provider@2.0.0':
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
engines: {node: '>=18'}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -687,6 +718,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
'@playwright/test@1.57.0':
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
engines: {node: '>=18'}
@@ -1128,6 +1163,10 @@ packages:
cpu: [x64]
os: [win32]
'@vercel/oidc@3.0.5':
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
engines: {node: '>= 20'}
'@vitejs/plugin-react@5.1.2':
resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1177,6 +1216,12 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ai@5.0.116:
resolution: {integrity: sha512-+2hYJ80/NcDWuv9K2/MLP3cTCFgwWHmHlS1tOpFUKKcmLbErAAlE/S2knsKboc3PNAu8pQkDr2N3K/Vle7ENgQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
@@ -1713,6 +1758,10 @@ packages:
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
eventsource-parser@3.0.6:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -2115,6 +2164,9 @@ packages:
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
@@ -3039,6 +3091,30 @@ snapshots:
'@adobe/css-tools@4.4.4': {}
'@ai-sdk/gateway@2.0.23(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.19(zod@3.25.76)
'@vercel/oidc': 3.0.5
zod: 3.25.76
'@ai-sdk/google@2.0.51(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.19(zod@3.25.76)
zod: 3.25.76
'@ai-sdk/provider-utils@3.0.19(zod@3.25.76)':
dependencies:
'@ai-sdk/provider': 2.0.0
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 3.25.76
'@ai-sdk/provider@2.0.0':
dependencies:
json-schema: 0.4.0
'@alloc/quick-lru@5.2.0': {}
'@asamuzakjp/css-color@4.1.1':
@@ -3497,6 +3573,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@opentelemetry/api@1.9.0': {}
'@playwright/test@1.57.0':
dependencies:
playwright: 1.57.0
@@ -3911,6 +3989,8 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@vercel/oidc@3.0.5': {}
'@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7))':
dependencies:
'@babel/core': 7.28.5
@@ -3970,6 +4050,14 @@ snapshots:
agent-base@7.1.4: {}
ai@5.0.116(zod@3.25.76):
dependencies:
'@ai-sdk/gateway': 2.0.23(zod@3.25.76)
'@ai-sdk/provider': 2.0.0
'@ai-sdk/provider-utils': 3.0.19(zod@3.25.76)
'@opentelemetry/api': 1.9.0
zod: 3.25.76
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -4690,6 +4778,8 @@ snapshots:
eventemitter3@5.0.1: {}
eventsource-parser@3.0.6: {}
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
@@ -5103,6 +5193,8 @@ snapshots:
json-schema-traverse@0.4.1: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@1.0.2:
@@ -5206,7 +5298,7 @@ snapshots:
natural-compare@1.4.0: {}
next@16.1.0(@babel/core@7.28.5)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 16.1.0
'@swc/helpers': 0.5.15
@@ -5225,6 +5317,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.1.0
'@next/swc-win32-arm64-msvc': 16.1.0
'@next/swc-win32-x64-msvc': 16.1.0
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.57.0
sharp: 0.34.5
transitivePeerDependencies:
@@ -5990,7 +6083,7 @@ snapshots:
fsevents: 2.3.3
jiti: 1.21.7
vitest@4.0.16(@types/node@20.19.27)(jiti@1.21.7)(jsdom@27.3.0):
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@1.21.7)(jsdom@27.3.0):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7))
@@ -6013,6 +6106,7 @@ snapshots:
vite: 7.3.0(@types/node@20.19.27)(jiti@1.21.7)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 20.19.27
jsdom: 27.3.0
transitivePeerDependencies:

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'whisky-vault-v13-offline';
const CACHE_NAME = 'whisky-vault-v14-offline';
// CONFIG: Assets
const STATIC_ASSETS = [
@@ -6,6 +6,7 @@ const STATIC_ASSETS = [
'/icon-192.png',
'/icon-512.png',
'/favicon.ico',
'/lib/browser-image-compression.js',
];
const CORE_PAGES = [

26
scripts/add-credits.sql Normal file
View File

@@ -0,0 +1,26 @@
-- Quick script to add credits for development/testing
-- Run this in Supabase SQL Editor
-- Add 1000 credits to all users (for development)
UPDATE user_credits
SET balance = balance + 1000,
updated_at = NOW()
WHERE user_id IN (
SELECT id FROM auth.users
);
-- Or add credits to a specific user by email:
-- UPDATE user_credits
-- SET balance = balance + 1000,
-- updated_at = NOW()
-- WHERE user_id = (
-- SELECT id FROM auth.users WHERE email = 'your-email@example.com'
-- );
SELECT
u.email,
uc.balance,
uc.updated_at
FROM user_credits uc
JOIN auth.users u ON u.id = uc.user_id
ORDER BY uc.updated_at DESC;

View File

@@ -0,0 +1,117 @@
'use server';
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
import { createClient } from '@/lib/supabase/server';
import { trackApiUsage } from '@/services/track-api-usage';
import { deductCredits } from '@/services/credit-service';
import { getAllSystemTags } from '@/services/tags';
// Native Schema Definition for Enrichment Data
const enrichmentSchema = {
description: "Sensory profile and search metadata for whisky",
type: SchemaType.OBJECT as const,
properties: {
suggested_tags: {
type: SchemaType.ARRAY,
description: "Array of suggested aroma/taste tags from the available system tags",
items: { type: SchemaType.STRING },
nullable: true
},
suggested_custom_tags: {
type: SchemaType.ARRAY,
description: "Array of custom dominant notes not in the system tags",
items: { type: SchemaType.STRING },
nullable: true
},
search_string: {
type: SchemaType.STRING,
description: "Optimized search query for Whiskybase discovery",
nullable: true
}
},
required: [],
};
export async function enrichData(name: string, distillery: string, availableTags?: string, language: string = 'de') {
if (!process.env.GEMINI_API_KEY) {
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
}
let supabase;
try {
let tagsToUse = availableTags;
if (!tagsToUse) {
const systemTags = await getAllSystemTags();
tagsToUse = systemTags.map(t => t.name).join(', ');
}
supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert.' };
}
const userId = user.id;
// Initialize Gemini with native schema validation
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash',
generationConfig: {
responseMimeType: "application/json",
responseSchema: enrichmentSchema as any,
temperature: 0.3,
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
] as any,
});
const instruction = `Identify the sensory profile for the following whisky.
Whisky: ${name} (${distillery})
Language: ${language}
Available system tags (pick relevant ones): ${tagsToUse}
Instructions:
1. Select the most appropriate sensory tags from the "Available system tags" list.
2. If there are dominant notes that are NOT in the system list, add them to "suggested_custom_tags".
3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`;
const startApi = performance.now();
const result = await model.generateContent(instruction);
const endApi = performance.now();
const responseText = result.response.text();
console.log('[EnrichData] Raw Response:', responseText);
const jsonData = JSON.parse(responseText);
// Track usage
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'enrichData',
success: true
});
await deductCredits(userId, 'gemini_ai', 'Data enrichment');
return {
success: true,
data: jsonData,
perf: {
apiDuration: endApi - startApi
}
};
} catch (error) {
console.error('Enrich Data Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Fehler bei der Daten-Anreicherung.',
};
}
}

View File

@@ -0,0 +1,207 @@
'use server';
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
import { BottleMetadataSchema, AnalysisResponse } from '@/types/whisky';
import { createClient } from '@/lib/supabase/server';
import { createHash } from 'crypto';
import { trackApiUsage } from '@/services/track-api-usage';
import { checkCreditBalance, deductCredits } from '@/services/credit-service';
// Native Schema Definition for Gemini API
const metadataSchema = {
description: "Technical metadata extracted from whisky label",
type: SchemaType.OBJECT as const,
properties: {
name: { type: SchemaType.STRING, description: "Full whisky name including vintage/age", nullable: false },
distillery: { type: SchemaType.STRING, description: "Distillery name", nullable: true },
bottler: { type: SchemaType.STRING, description: "Independent bottler if applicable", nullable: true },
category: { type: SchemaType.STRING, description: "Whisky category (e.g., Single Malt, Blended)", nullable: true },
abv: { type: SchemaType.NUMBER, description: "Alcohol by volume percentage", nullable: true },
age: { type: SchemaType.NUMBER, description: "Age statement in years", nullable: true },
vintage: { type: SchemaType.STRING, description: "Vintage year", nullable: true },
distilled_at: { type: SchemaType.STRING, description: "Distillation date", nullable: true },
bottled_at: { type: SchemaType.STRING, description: "Bottling date", nullable: true },
batch_info: { type: SchemaType.STRING, description: "Batch or cask information", nullable: true },
bottleCode: { type: SchemaType.STRING, description: "Bottle code or serial number", nullable: true },
is_whisky: { type: SchemaType.BOOLEAN, description: "Whether this is a whisky product", nullable: false },
confidence: { type: SchemaType.NUMBER, description: "Confidence score 0-1", nullable: false },
},
required: ["name", "is_whisky", "confidence"],
};
export async function scanLabel(input: any): Promise<AnalysisResponse> {
if (!process.env.GEMINI_API_KEY) {
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
}
let supabase;
try {
const getValue = (obj: any, key: string): any => {
if (obj && typeof obj.get === 'function') return obj.get(key);
if (obj && typeof obj[key] !== 'undefined') return obj[key];
return null;
};
const file = getValue(input, 'file') as File;
if (!file) {
return { success: false, error: 'Kein Bild empfangen.' };
}
supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert.' };
}
const userId = user.id;
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
return {
success: false,
error: `Nicht genügend Credits. Benötigt: ${creditCheck.cost}, Verfügbar: ${creditCheck.balance}.`
};
}
const perfTotal = performance.now();
// Step 1: Image Preparation
const startImagePrep = performance.now();
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const imageHash = createHash('sha256').update(buffer).digest('hex');
const endImagePrep = performance.now();
// Step 2: Cache Check
const startCacheCheck = performance.now();
const { data: cachedResult } = await supabase
.from('vision_cache')
.select('result')
.eq('hash', imageHash)
.maybeSingle();
const endCacheCheck = performance.now();
if (cachedResult) {
return {
success: true,
data: cachedResult.result as any,
perf: {
imagePrep: endImagePrep - startImagePrep,
cacheCheck: endCacheCheck - startCacheCheck,
apiCall: 0,
parsing: 0,
validation: 0,
dbOps: 0,
uploadSize: buffer.length,
total: performance.now() - perfTotal,
cacheHit: true
}
};
}
// Step 3: Base64 Encoding
const startEncoding = performance.now();
const base64Data = buffer.toString('base64');
const mimeType = file.type || 'image/webp';
const uploadSize = buffer.length;
const endEncoding = performance.now();
// Step 4: Model Initialization & Step 5: API Call
const startAiTotal = performance.now();
let jsonData;
let validatedData;
try {
const startModelInit = performance.now();
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash',
generationConfig: {
responseMimeType: "application/json",
responseSchema: metadataSchema as any,
temperature: 0.1,
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
] as any,
});
const endModelInit = performance.now();
const instruction = "Extract whisky label metadata.";
const startApi = performance.now();
const result = await model.generateContent([
{ inlineData: { data: base64Data, mimeType: mimeType } },
{ text: instruction },
]);
const endApi = performance.now();
const startParse = performance.now();
jsonData = JSON.parse(result.response.text());
const endParse = performance.now();
const startValidation = performance.now();
validatedData = BottleMetadataSchema.parse(jsonData);
const endValidation = performance.now();
// Cache record
await supabase.from('vision_cache').insert({ hash: imageHash, result: validatedData });
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'scanLabel',
success: true
});
await deductCredits(userId, 'gemini_ai', 'Bottle OCR scan');
const totalTime = performance.now() - perfTotal;
return {
success: true,
data: validatedData,
perf: {
imagePrep: endImagePrep - startImagePrep,
cacheCheck: endCacheCheck - startCacheCheck,
encoding: endEncoding - startEncoding,
modelInit: endModelInit - startModelInit,
apiCall: endApi - startApi,
parsing: endParse - startParse,
validation: endValidation - startValidation,
total: totalTime,
cacheHit: false
},
raw: jsonData
} as any;
} catch (aiError: any) {
console.warn('[ScanLabel] AI Analysis failed, providing fallback path:', aiError.message);
// Track failure
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'scanLabel',
success: false,
errorMessage: aiError.message
});
// Return a specific structure that ScanAndTasteFlow can use to fallback to placeholder
return {
success: false,
isAiError: true,
error: aiError.message,
imageHash: imageHash // Useful for local tracking
} as any;
}
} catch (error) {
console.error('Scan Label Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Fehler bei der Label-Analyse.',
};
}
}

View File

@@ -9,12 +9,12 @@ export async function POST(req: Request) {
const supabase = await createClient();
// Check session
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
}
const userId = session.user.id;
const userId = user.id;
const formData = await req.formData();
const file = formData.get('file') as File;

View File

@@ -44,7 +44,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="de">
<html lang="de" suppressHydrationWarning={true}>
<body className={`${inter.variable} font-sans`}>
<I18nProvider>
<SessionProvider>

View File

@@ -120,7 +120,6 @@ export default function Home() {
.order('created_at', { ascending: false });
if (error) {
console.error('Supabase fetch error:', error);
throw error;
}
@@ -143,8 +142,20 @@ export default function Home() {
setBottles(processedBottles);
} catch (err: any) {
console.error('Detailed fetch error:', err);
setFetchError(err.message || JSON.stringify(err));
// Silently skip if offline
const isNetworkError = !navigator.onLine ||
err.message?.includes('Failed to fetch') ||
err.message?.includes('NetworkError') ||
err.message?.includes('ERR_INTERNET_DISCONNECTED') ||
(err && Object.keys(err).length === 0); // Empty error object from Supabase when offline
if (isNetworkError) {
console.log('[fetchCollection] Skipping due to offline mode');
setFetchError(null);
} else {
console.error('Detailed fetch error:', err);
setFetchError(err.message || JSON.stringify(err));
}
} finally {
setIsLoading(false);
}
@@ -271,6 +282,7 @@ export default function Home() {
isOpen={isFlowOpen}
onClose={() => setIsFlowOpen(false)}
imageFile={capturedFile}
onBottleSaved={() => fetchCollection()}
/>
</main>
);

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useRef, useState } from 'react';
import React, { useRef, useState, useEffect } from 'react';
import { Camera, Upload, CheckCircle2, AlertCircle, X, Search, ExternalLink, ArrowRight, Loader2, Wand2, Plus, Sparkles, Droplets, ChevronRight, User, Clock } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
@@ -8,7 +8,6 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { saveBottle } from '@/services/save-bottle';
import { BottleMetadata } from '@/types/whisky';
import { db } from '@/lib/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';
@@ -17,8 +16,10 @@ import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import { shortenCategory } from '@/lib/format';
import { magicScan } from '@/services/magic-scan';
import { scanLabel } from '@/app/actions/scan-label';
import { enrichData } from '@/app/actions/enrich-data';
import { processImageForAI } from '@/utils/image-processing';
interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void;
onAnalysisComplete?: (data: BottleMetadata) => void;
@@ -32,14 +33,12 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const searchParams = useSearchParams();
const { activeSession } = useSession();
// Maintain sessionId from query param for backwards compatibility,
// but prefer global activeSession
const sessionIdFromUrl = searchParams.get('session_id');
const effectiveSessionId = activeSession?.id || sessionIdFromUrl;
const [validatedSessionId, setValidatedSessionId] = React.useState<string | null>(null);
const [validatedSessionId, setValidatedSessionId] = useState<string | null>(null);
React.useEffect(() => {
useEffect(() => {
const checkSession = async () => {
if (effectiveSessionId) {
const isValid = await validateSession(effectiveSessionId);
@@ -67,14 +66,25 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const [isAdmin, setIsAdmin] = useState(false);
const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini');
// Performance Tracking (Admin only)
const [perfMetrics, setPerfMetrics] = useState<{
compression: number;
ai: number;
aiApi: number;
aiParse: number;
uploadSize: number;
prep: number;
// Detailed metrics
imagePrep?: number;
cacheCheck?: number;
encoding?: number;
modelInit?: number;
validation?: number;
dbOps?: number;
total?: number;
cacheHit?: boolean;
} | null>(null);
React.useEffect(() => {
useEffect(() => {
const checkAdmin = async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
@@ -85,10 +95,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
.eq('user_id', user.id)
.maybeSingle();
if (error) {
console.error('[CameraCapture] Admin check error:', error);
}
console.log('[CameraCapture] Admin status:', !!data);
if (error) console.error('[CameraCapture] Admin check error:', error);
setIsAdmin(!!data);
}
} catch (err) {
@@ -111,122 +118,101 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
try {
let fileToProcess = file;
// HEIC / HEIF Check
const isHeic = file.type === 'image/heic' || file.type === 'image/heif' || file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
if (isHeic) {
console.log('HEIC detected, converting...');
const heic2any = (await import('heic2any')).default;
const convertedBlob = await heic2any({
blob: file,
toType: 'image/jpeg',
quality: 0.8
});
const convertedBlob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.8 });
const blob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob;
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
type: 'image/jpeg'
});
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), { type: 'image/jpeg' });
}
setOriginalFile(fileToProcess);
const startComp = performance.now();
const processed = await processImageForAI(fileToProcess);
const endComp = performance.now();
const compressedBase64 = processed.base64;
setPreviewUrl(compressedBase64);
setPreviewUrl(processed.base64);
if (onImageCaptured) onImageCaptured(processed.base64);
if (onImageCaptured) {
onImageCaptured(compressedBase64);
}
// Check if Offline
if (!navigator.onLine) {
console.log('Offline detected. Queuing image...');
await db.pending_scans.add({
temp_id: crypto.randomUUID(),
imageBase64: compressedBase64,
timestamp: Date.now(),
provider: aiProvider,
locale: locale
});
// Check for existing pending scan with same image to prevent duplicates
const existingScan = await db.pending_scans
.filter(s => s.imageBase64 === processed.base64)
.first();
if (existingScan) {
console.log('[CameraCapture] Existing pending scan found, skipping dual add');
} else {
await db.pending_scans.add({
temp_id: crypto.randomUUID(),
imageBase64: processed.base64,
timestamp: Date.now(),
provider: aiProvider,
locale: locale
});
}
setIsQueued(true);
return;
}
const formData = new FormData();
formData.append('file', processed.file);
formData.append('provider', aiProvider);
formData.append('locale', locale);
const startAi = performance.now();
const response = await magicScan(formData);
const response = await scanLabel(formData);
const endAi = performance.now();
const startPrep = performance.now();
if (response.success && response.data) {
setAnalysisResult(response.data);
if (response.wb_id) {
setWbDiscovery({
id: response.wb_id,
url: `https://www.whiskybase.com/whiskies/whisky/${response.wb_id}`,
title: `${response.data.distillery || ''} ${response.data.name || ''}`
});
}
// Duplicate Check
const match = await findMatchingBottle(response.data);
if (match) {
setMatchingBottle(match);
}
if (onAnalysisComplete) {
onAnalysisComplete(response.data);
}
if (match) setMatchingBottle(match);
if (onAnalysisComplete) onAnalysisComplete(response.data);
const endPrep = performance.now();
if (isAdmin) {
if (isAdmin && response.perf) {
setPerfMetrics({
compression: endComp - startComp,
ai: endAi - startAi,
prep: endPrep - startPrep
aiApi: response.perf.apiCall || response.perf.apiDuration || 0,
aiParse: response.perf.parsing || response.perf.parseDuration || 0,
uploadSize: response.perf.uploadSize || 0,
prep: endPrep - startPrep,
imagePrep: response.perf.imagePrep,
cacheCheck: response.perf.cacheCheck,
encoding: response.perf.encoding,
modelInit: response.perf.modelInit,
validation: response.perf.validation,
dbOps: response.perf.dbOps,
total: response.perf.total,
cacheHit: response.perf.cacheHit
});
}
} else {
// If scan fails but it looks like a network issue, offer to queue
const isNetworkError = !navigator.onLine ||
response.error?.toLowerCase().includes('fetch') ||
response.error?.toLowerCase().includes('network') ||
response.error?.toLowerCase().includes('timeout');
if (isNetworkError) {
console.log('Network issue detected during scan. Queuing...');
await db.pending_scans.add({
temp_id: crypto.randomUUID(),
imageBase64: compressedBase64,
timestamp: Date.now(),
provider: aiProvider,
locale: locale
});
setIsQueued(true);
setError(null); // Clear error as we are queuing
} else {
setError(response.error || t('camera.analysisError'));
if (response.data.is_whisky && response.data.name && response.data.distillery) {
enrichData(response.data.name, response.data.distillery, undefined, locale)
.then(enrichResult => {
if (enrichResult.success && enrichResult.data) {
setAnalysisResult(prev => {
if (!prev) return prev;
return {
...prev,
suggested_tags: enrichResult.data.suggested_tags,
suggested_custom_tags: enrichResult.data.suggested_custom_tags,
search_string: enrichResult.data.search_string
};
});
}
})
.catch(err => console.error('[CameraCapture] Enrichment failed:', err));
}
}
} catch (err) {
console.error('Processing failed:', err);
// Even on generic error, if we have a compressed image, consider queuing if it looks like connection
if (previewUrl && !analysisResult) {
setError(t('camera.processingError') + " - " + t('camera.offlineNotice'));
} else {
setError(t('camera.processingError'));
throw new Error(t('camera.analysisError'));
}
} catch (err: any) {
console.error('Processing failed:', err);
setError(err.message || t('camera.processingError'));
} finally {
setIsProcessing(false);
}
@@ -234,28 +220,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const handleQuickSave = async () => {
if (!analysisResult || !previewUrl) return;
setIsSaving(true);
setError(null);
try {
const { data: { user } = {} } = await supabase.auth.getUser();
if (!user) {
throw new Error(t('camera.authRequired'));
}
if (!user) throw new Error(t('camera.authRequired'));
const response = await saveBottle(analysisResult, previewUrl, user.id);
if (response.success && response.data) {
const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
router.push(url);
} else {
setError(response.error || t('common.error'));
}
} catch (err) {
console.error('Quick save failed:', err);
setError(err instanceof Error ? err.message : t('common.error'));
} catch (err: any) {
setError(err.message || t('common.error'));
} finally {
setIsSaving(false);
}
@@ -263,28 +241,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const handleSave = async () => {
if (!analysisResult || !previewUrl) return;
setIsSaving(true);
setError(null);
try {
const { data: { user } = {} } = await supabase.auth.getUser();
if (!user) {
throw new Error(t('camera.authRequired'));
}
if (!user) throw new Error(t('camera.authRequired'));
const response = await saveBottle(analysisResult, previewUrl, user.id);
if (response.success && response.data) {
setLastSavedId(response.data.id);
if (onSaveComplete) onSaveComplete();
} else {
setError(response.error || t('common.error'));
}
} catch (err) {
console.error('Save failed:', err);
setError(err instanceof Error ? err.message : t('common.error'));
} catch (err: any) {
setError(err.message || t('common.error'));
} finally {
setIsSaving(false);
}
@@ -299,7 +269,6 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
abv: analysisResult.abv || undefined,
age: analysisResult.age || undefined
});
if (result.success && result.id) {
setWbDiscovery({ id: result.id, url: result.url!, title: result.title! });
}
@@ -308,30 +277,18 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
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 res = await updateBottle(lastSavedId, { whiskybase_id: wbDiscovery.id });
if (res.success) setWbDiscovery(null);
};
const triggerUpload = () => {
fileInputRef.current?.click();
};
const triggerGallery = () => {
galleryInputRef.current?.click();
};
const triggerUpload = () => fileInputRef.current?.click();
const triggerGallery = () => galleryInputRef.current?.click();
return (
<div className="flex flex-col items-center gap-4 md:gap-6 w-full max-w-md mx-auto p-4 md:p-6 bg-zinc-900 rounded-3xl shadow-2xl border border-zinc-800 transition-all hover:shadow-orange-950/20">
<div className="flex flex-col w-full gap-1">
<div className="flex items-center justify-between w-full">
<h2 className="text-xl md:text-2xl font-bold text-zinc-100 italic">{t('camera.magicShot')}</h2>
{isAdmin && (
<div className="flex items-center gap-1 bg-zinc-800 p-1 rounded-xl border border-zinc-700">
<button
@@ -414,22 +371,8 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
)}
</div>
<input
type="file"
accept="image/*"
capture="environment"
ref={fileInputRef}
onChange={handleCapture}
className="hidden"
/>
<input
type="file"
accept="image/*"
ref={galleryInputRef}
onChange={handleCapture}
className="hidden"
/>
<input type="file" accept="image/*" capture="environment" ref={fileInputRef} onChange={handleCapture} className="hidden" />
<input type="file" accept="image/*" ref={galleryInputRef} onChange={handleCapture} className="hidden" />
{lastSavedId ? (
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
@@ -437,7 +380,6 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
<CheckCircle2 size={24} className="text-green-500" />
{t('camera.saveSuccess')}
</div>
<button
onClick={() => {
const url = `/bottles/${lastSavedId}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
@@ -448,258 +390,145 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
{t('camera.tastingNow')}
<ChevronRight size={20} />
</button>
{!wbDiscovery && !isDiscovering && (
<button
onClick={handleDiscoverWb}
className="w-full py-3 px-6 bg-orange-900/10 text-orange-500 rounded-xl font-bold flex items-center justify-center gap-2 border border-orange-900/20 hover:bg-orange-900/20 transition-all text-sm"
>
<button onClick={handleDiscoverWb} className="w-full py-3 px-6 bg-orange-900/10 text-orange-500 rounded-xl font-bold flex items-center justify-center gap-2 border border-orange-900/20 hover:bg-orange-900/20 transition-all text-sm">
<Search size={16} />
{t('camera.whiskybaseSearch')}
</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" />
{t('camera.searchingWb')}
</div>
)}
{wbDiscovery && (
<div className="p-4 bg-zinc-950 border border-orange-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-orange-600">
<Sparkles size={12} /> {t('camera.wbMatchFound')}
</div>
<p className="text-xs font-bold text-zinc-200 line-clamp-2 leading-snug">
{wbDiscovery.title}
</p>
<p className="text-xs font-bold 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-orange-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-orange-700 transition-colors"
>
{t('common.link')}
</button>
<a
href={wbDiscovery.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-2.5 bg-zinc-800 text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-700 transition-colors flex items-center justify-center gap-1"
>
<button onClick={handleLinkWb} className="flex-1 py-2.5 bg-orange-600 text-white text-[10px] font-black uppercase rounded-lg hover:bg-orange-700 transition-colors">{t('common.link')}</button>
<a href={wbDiscovery.url} target="_blank" rel="noopener noreferrer" className="flex-1 py-2.5 bg-zinc-800 text-zinc-300 text-[10px] font-black uppercase rounded-lg hover:bg-zinc-700 transition-colors flex items-center justify-center gap-1">
<ExternalLink size={12} /> {t('common.check')}
</a>
</div>
</div>
)}
<button
onClick={() => {
setPreviewUrl(null);
setAnalysisResult(null);
setLastSavedId(null);
}}
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors"
>
{t('camera.later')}
</button>
<button onClick={() => { setPreviewUrl(null); setAnalysisResult(null); setLastSavedId(null); }} className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors">{t('camera.later')}</button>
</div>
) : matchingBottle ? (
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
<Link
href={`/bottles/${matchingBottle.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`}
className="w-full py-4 px-6 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-orange-950/40"
>
<Link href={`/bottles/${matchingBottle.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`} className="w-full py-4 px-6 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg shadow-orange-950/40">
<ExternalLink size={20} />
{t('camera.toVault')}
</Link>
<button
onClick={() => setMatchingBottle(null)}
className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors"
>
{t('camera.saveAnyway')}
</button>
<button onClick={() => setMatchingBottle(null)} className="w-full py-3 px-6 text-zinc-500 hover:text-zinc-200 font-bold transition-colors">{t('camera.saveAnyway')}</button>
</div>
) : (
<div className="flex flex-col gap-3 w-full">
<button
onClick={() => {
if (isQueued) {
setPreviewUrl(null);
} else if (previewUrl && analysisResult) {
if (validatedSessionId) {
handleQuickSave();
} else {
handleSave();
}
} else {
triggerUpload();
}
if (isQueued) setPreviewUrl(null);
else if (previewUrl && analysisResult) validatedSessionId ? handleQuickSave() : handleSave();
else triggerUpload();
}}
disabled={isProcessing || isSaving}
className={`w-full py-4 px-6 rounded-xl font-semibold flex items-center justify-center gap-2 transition-all active:scale-[0.98] shadow-lg disabled:opacity-50 ${validatedSessionId && previewUrl && analysisResult ? 'bg-zinc-100 text-zinc-900 shadow-black/10' : 'bg-orange-600 hover:bg-orange-700 text-white shadow-orange-950/40'}`}
>
{isSaving ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>
{t('camera.saving')}
</>
<><div className="animate-spin rounded-full h-5 w-5 border-b-2 border-current"></div>{t('camera.saving')}</>
) : isQueued ? (
<>
<CheckCircle2 size={20} />
{t('camera.nextBottle')}
</>
<><CheckCircle2 size={20} />{t('camera.nextBottle')}</>
) : previewUrl && analysisResult ? (
validatedSessionId ? (
<>
<Droplets size={20} className="text-orange-500" />
{t('camera.quickTasting')}
</>
) : (
<>
<CheckCircle2 size={20} />
{t('camera.inVault')}
</>
)
validatedSessionId ? <><Droplets size={20} className="text-orange-500" />{t('camera.quickTasting')}</> : <><CheckCircle2 size={20} />{t('camera.inVault')}</>
) : previewUrl ? (
<>
<Upload size={20} />
{t('camera.newPhoto')}
</>
<><Upload size={20} />{t('camera.newPhoto')}</>
) : (
<>
<Camera size={20} />
{t('camera.openingCamera')}
</>
<><Camera size={20} />{t('camera.openingCamera')}</>
)}
</button>
{!previewUrl && !isProcessing && (
<button
onClick={triggerGallery}
className="w-full py-3 px-6 bg-zinc-800 text-zinc-300 rounded-xl font-bold flex items-center justify-center gap-2 border border-zinc-700 hover:bg-zinc-700 transition-all text-sm"
>
<Upload size={18} />
{t('camera.uploadGallery')}
<button onClick={triggerGallery} className="w-full py-3 px-6 bg-zinc-800 text-zinc-300 rounded-xl font-bold flex items-center justify-center gap-2 border border-zinc-700 hover:bg-zinc-700 transition-all text-sm">
<Upload size={18} />{t('camera.uploadGallery')}
</button>
)}
</div>
)}
{/* Status Messages */}
{error && (
<div className="flex items-center gap-2 text-red-500 text-sm bg-red-50 dark:bg-red-900/10 p-3 rounded-lg w-full">
<AlertCircle size={16} />
{error}
<AlertCircle size={16} />{error}
</div>
)}
{isQueued && (
<div className="flex flex-col gap-3 p-5 bg-gradient-to-br from-purple-500/10 to-amber-500/10 dark:from-purple-900/20 dark:to-amber-900/20 border border-purple-500/20 rounded-3xl w-full animate-in zoom-in-95 duration-500">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30">
<Sparkles size={20} />
</div>
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30"><Sparkles size={20} /></div>
<div className="flex flex-col">
<span className="text-sm font-black text-zinc-800 dark:text-zinc-100 italic">Lokal gespeichert!</span>
<span className="text-[10px] font-bold text-purple-600 dark:text-purple-400 uppercase tracking-widest">Warteschlange aktiv</span>
</div>
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">
Keine Sorge, dein Scan wurde sicher im Vault gespeichert. Sobald du wieder Empfang hast, wird die Analyse automatisch im Hintergrund gestartet.
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">Keine Sorge, dein Scan wurde sicher im Vault gespeichert. Sobald du wieder Empfang hast, wird die Analyse automatisch im Hintergrund gestartet.</p>
</div>
)}
{matchingBottle && !lastSavedId && (
<div className="flex flex-col gap-2 p-4 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-900/30 rounded-xl w-full">
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm">
<AlertCircle size={16} />
{t('camera.alreadyInVault')}
</div>
<p className="text-xs text-blue-500/80">
{t('camera.alreadyInVaultDesc')}
</p>
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-bold text-sm"><AlertCircle size={16} />{t('camera.alreadyInVault')}</div>
<p className="text-xs text-blue-500/80">{t('camera.alreadyInVaultDesc')}</p>
</div>
)}
{/* Analysis Results Display */}
{previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && analysisResult && (
<div className="flex flex-col gap-3 w-full animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-2 text-green-400 text-sm bg-green-900/10 p-3 rounded-lg w-full border border-green-900/30">
<CheckCircle2 size={16} />
{t('camera.analysisSuccess')}
<CheckCircle2 size={16} />{t('camera.analysisSuccess')}
</div>
<div className="p-3 md:p-4 bg-zinc-950 rounded-2xl border border-zinc-800">
<div className="flex items-center gap-2 mb-2 md:mb-3 text-orange-600">
<Sparkles size={18} />
<span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span>
</div>
<div className="flex items-center gap-2 mb-2 md:mb-3 text-orange-600"><Sparkles size={18} /><span className="font-bold text-[10px] md:text-sm uppercase tracking-wider">{t('camera.results')}</span></div>
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.nameLabel')}:</span>
<span className="font-semibold text-right text-zinc-100">{analysisResult.name || '-'}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span>
<span className="font-semibold text-right">{analysisResult.distillery || '-'}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.categoryLabel')}:</span>
<span className="font-semibold text-right">{shortenCategory(analysisResult.category || '-')}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.abvLabel')}:</span>
<span className="font-semibold text-right">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span>
</div>
{analysisResult.age && (
<div className="flex justify-between items-center text-sm">
<span className="text-zinc-500">{t('bottle.ageLabel')}:</span>
<span className="font-semibold text-right">{analysisResult.age} {t('bottle.years')}</span>
</div>
)}
{analysisResult.distilled_at && (
<div className="flex justify-between text-sm">
<span className="text-zinc-500">{t('bottle.distilledLabel')}:</span>
<span className="font-semibold">{analysisResult.distilled_at}</span>
</div>
)}
{analysisResult.bottled_at && (
<div className="flex justify-between text-sm">
<span className="text-zinc-500">{t('bottle.bottledLabel')}:</span>
<span className="font-semibold">{analysisResult.bottled_at}</span>
</div>
)}
{analysisResult.batch_info && (
<div className="flex justify-between text-sm">
<span className="text-zinc-500">{t('bottle.batchLabel')}:</span>
<span className="font-semibold text-zinc-100">{analysisResult.batch_info}</span>
</div>
)}
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.nameLabel')}:</span><span className="font-semibold text-right text-zinc-100">{analysisResult.name || '-'}</span></div>
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span><span className="font-semibold text-right">{analysisResult.distillery || '-'}</span></div>
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.categoryLabel')}:</span><span className="font-semibold text-right">{shortenCategory(analysisResult.category || '-')}</span></div>
<div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.abvLabel')}:</span><span className="font-semibold text-right">{analysisResult.abv ? `${analysisResult.abv}%` : '-'}</span></div>
{analysisResult.age && <div className="flex justify-between items-center text-sm"><span className="text-zinc-500">{t('bottle.ageLabel')}:</span><span className="font-semibold text-right">{analysisResult.age} {t('bottle.years')}</span></div>}
{analysisResult.vintage && <div className="flex justify-between items-center text-sm"><span className="text-zinc-500">Vintage:</span><span className="font-semibold text-right">{analysisResult.vintage}</span></div>}
{analysisResult.bottler && <div className="flex justify-between text-sm"><span className="text-zinc-500">Bottler:</span><span className="font-semibold">{analysisResult.bottler}</span></div>}
{analysisResult.distilled_at && <div className="flex justify-between text-sm"><span className="text-zinc-500">{t('bottle.distilledLabel')}:</span><span className="font-semibold">{analysisResult.distilled_at}</span></div>}
{analysisResult.bottled_at && <div className="flex justify-between text-sm"><span className="text-zinc-500">{t('bottle.bottledLabel')}:</span><span className="font-semibold">{analysisResult.bottled_at}</span></div>}
{analysisResult.batch_info && <div className="flex justify-between text-sm"><span className="text-zinc-500">{t('bottle.batchLabel')}:</span><span className="font-semibold text-zinc-100">{analysisResult.batch_info}</span></div>}
{analysisResult.bottleCode && <div className="flex justify-between text-sm"><span className="text-zinc-500">Bottle Code:</span><span className="font-semibold text-zinc-400 font-mono text-xs">{analysisResult.bottleCode}</span></div>}
{isAdmin && perfMetrics && (
<div className="pt-4 mt-2 border-t border-zinc-900/50 space-y-1">
<div className="flex items-center gap-1.5 text-[9px] font-black text-orange-600 uppercase tracking-widest mb-1">
<Clock size={10} /> Performance Data
</div>
<div className="flex items-center gap-1.5 text-[9px] font-black text-orange-600 uppercase tracking-widest mb-1"><Clock size={10} /> Performance Data</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px]">
<div className="flex justify-between">
<span className="text-zinc-600">Comp:</span>
<span className="text-zinc-400 font-mono">{perfMetrics.compression.toFixed(0)}ms</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-600">AI:</span>
<span className="text-zinc-400 font-mono">{perfMetrics.ai.toFixed(0)}ms</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-600">Prep:</span>
<span className="text-zinc-400 font-mono">{perfMetrics.prep.toFixed(0)}ms</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-600">Total:</span>
<span className="text-orange-600 font-mono font-bold">{(perfMetrics.compression + perfMetrics.ai + perfMetrics.prep).toFixed(0)}ms</span>
</div>
<div className="flex justify-between"><span className="text-zinc-600">CLIENT:</span><span className="text-zinc-400 font-mono">{perfMetrics.compression.toFixed(0)}ms</span></div>
<div className="flex justify-between"><span className="text-zinc-600">({(perfMetrics.uploadSize / 1024).toFixed(0)}KB)</span></div>
{perfMetrics.cacheHit ? (
<div className="col-span-2 text-center py-1">
<span className="text-green-500 font-bold text-[11px]">CACHE HIT</span>
</div>
) : (
<>
<div className="col-span-2 border-t border-zinc-900/30 pt-1 mt-1">
<div className="text-[8px] text-zinc-600 mb-0.5">AI BREAKDOWN:</div>
</div>
{perfMetrics.imagePrep !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Prep:</span><span className="text-zinc-400 font-mono">{perfMetrics.imagePrep.toFixed(0)}ms</span></div>}
{perfMetrics.encoding !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Encode:</span><span className="text-zinc-400 font-mono">{perfMetrics.encoding.toFixed(0)}ms</span></div>}
{perfMetrics.modelInit !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Init:</span><span className="text-zinc-400 font-mono">{perfMetrics.modelInit.toFixed(0)}ms</span></div>}
<div className="flex justify-between"><span className="text-orange-400">API:</span><span className="text-orange-400 font-mono font-bold">{perfMetrics.aiApi.toFixed(0)}ms</span></div>
{perfMetrics.validation !== undefined && <div className="flex justify-between"><span className="text-zinc-600">Valid:</span><span className="text-zinc-400 font-mono">{perfMetrics.validation.toFixed(0)}ms</span></div>}
{perfMetrics.dbOps !== undefined && <div className="flex justify-between"><span className="text-zinc-600">DB:</span><span className="text-zinc-400 font-mono">{perfMetrics.dbOps.toFixed(0)}ms</span></div>}
<div className="col-span-2 border-t border-zinc-900/30 pt-1 mt-1">
<div className="flex justify-between"><span className="text-zinc-500 font-bold">TOTAL:</span><span className="text-orange-500 font-mono font-bold">{(perfMetrics.compression + perfMetrics.ai).toFixed(0)}ms</span></div>
</div>
</>
)}
</div>
</div>
)}

View File

@@ -7,13 +7,17 @@ import TastingEditor from './TastingEditor';
import SessionBottomSheet from './SessionBottomSheet';
import ResultCard from './ResultCard';
import { useSession } from '@/context/SessionContext';
import { magicScan } from '@/services/magic-scan';
import { scanLabel } from '@/app/actions/scan-label';
import { enrichData } from '@/app/actions/enrich-data';
import { saveBottle } from '@/services/save-bottle';
import { saveTasting } from '@/services/save-tasting';
import { BottleMetadata } from '@/types/whisky';
import { useI18n } from '@/i18n/I18nContext';
import { createClient } from '@/lib/supabase/client';
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
import { generateDummyMetadata } from '@/utils/generate-dummy-metadata';
import { db } from '@/lib/db';
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
@@ -21,9 +25,10 @@ interface ScanAndTasteFlowProps {
isOpen: boolean;
onClose: () => void;
imageFile: File | null;
onBottleSaved?: (bottleId: string) => void;
}
export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAndTasteFlowProps) {
export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleSaved }: ScanAndTasteFlowProps) {
const [state, setState] = useState<FlowState>('IDLE');
const [isSessionsOpen, setIsSessionsOpen] = useState(false);
const { activeSession } = useSession();
@@ -35,13 +40,23 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
const { locale } = useI18n();
const supabase = createClient();
const [isAdmin, setIsAdmin] = useState(false);
const [isOffline, setIsOffline] = useState(!navigator.onLine);
const [perfMetrics, setPerfMetrics] = useState<{
comp: number;
aiTotal: number;
aiApi: number;
aiParse: number;
uploadSize: number;
prep: number
prep: number;
// Detailed metrics
imagePrep?: number;
cacheCheck?: number;
encoding?: number;
modelInit?: number;
validation?: number;
dbOps?: number;
total?: number;
cacheHit?: boolean;
} | null>(null);
// Admin Check
@@ -57,7 +72,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
.maybeSingle();
if (error) console.error('[ScanFlow] Admin check error:', error);
console.log('[ScanFlow] Admin status:', !!data);
setIsAdmin(!!data);
}
} catch (err) {
@@ -67,10 +81,13 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
checkAdmin();
}, [supabase]);
const [aiFallbackActive, setAiFallbackActive] = useState(false);
const [isEnriching, setIsEnriching] = useState(false);
// Trigger scan when open and image provided
useEffect(() => {
if (isOpen && imageFile) {
console.log('[ScanFlow] Starting handleScan...');
setAiFallbackActive(false);
handleScan(imageFile);
} else if (!isOpen) {
setState('IDLE');
@@ -79,59 +96,143 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
setProcessedImage(null);
setError(null);
setIsSaving(false);
setAiFallbackActive(false);
}
}, [isOpen, imageFile]);
// Online/Offline detection
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const handleScan = async (file: File) => {
setState('SCANNING');
setError(null);
setPerfMetrics(null);
try {
console.log('[ScanFlow] Starting image processing...');
const startComp = performance.now();
const processed = await processImageForAI(file);
const endComp = performance.now();
setProcessedImage(processed);
console.log('[ScanFlow] Calling magicScan service with FormData (optimized WebP)...');
// OFFLINE: Skip AI scan, use dummy metadata
if (isOffline) {
const dummyMetadata = generateDummyMetadata(file);
setBottleMetadata(dummyMetadata);
setState('EDITOR');
if (isAdmin) {
setPerfMetrics({
comp: endComp - startComp,
aiTotal: 0,
aiApi: 0,
aiParse: 0,
uploadSize: processed.file.size,
prep: 0,
cacheCheck: 0,
cacheHit: false
});
}
return;
}
// ONLINE: Normal AI scan
const formData = new FormData();
formData.append('file', processed.file);
formData.append('provider', 'gemini');
formData.append('locale', locale);
const startAi = performance.now();
const result = await magicScan(formData);
const result = await scanLabel(formData);
const endAi = performance.now();
const startPrep = performance.now();
if (result.success && result.data) {
console.log('[ScanFlow] magicScan success');
if (result.raw) {
console.log('[ScanFlow] RAW AI RESPONSE:', result.raw);
}
setBottleMetadata(result.data);
const endPrep = performance.now();
if (isAdmin) {
if (isAdmin && result.perf) {
setPerfMetrics({
comp: endComp - startComp,
aiTotal: endAi - startAi,
aiApi: result.perf?.apiDuration || 0,
aiParse: result.perf?.parseDuration || 0,
uploadSize: result.perf?.uploadSize || 0,
prep: endPrep - startPrep
aiApi: result.perf.apiCall || result.perf.apiDuration || 0,
aiParse: result.perf.parsing || result.perf.parseDuration || 0,
uploadSize: result.perf.uploadSize || 0,
prep: endPrep - startPrep,
imagePrep: result.perf.imagePrep,
cacheCheck: result.perf.cacheCheck,
encoding: result.perf.encoding,
modelInit: result.perf.modelInit,
validation: result.perf.validation,
dbOps: result.perf.dbOps,
total: result.perf.total,
cacheHit: result.perf.cacheHit
});
}
setState('EDITOR');
// Step 2: Background Enrichment
if (result.data.name && result.data.distillery) {
setIsEnriching(true);
console.log('[ScanFlow] Starting background enrichment for:', result.data.name);
enrichData(result.data.name, result.data.distillery, undefined, locale)
.then(enrichResult => {
if (enrichResult.success && enrichResult.data) {
console.log('[ScanFlow] Enrichment data received:', enrichResult.data);
setBottleMetadata(prev => {
if (!prev) return prev;
const updated = {
...prev,
suggested_tags: enrichResult.data.suggested_tags,
suggested_custom_tags: enrichResult.data.suggested_custom_tags,
search_string: enrichResult.data.search_string
};
console.log('[ScanFlow] State updated with enriched metadata');
return updated;
});
} else {
console.warn('[ScanFlow] Enrichment result unsuccessful:', enrichResult.error);
}
})
.catch(err => console.error('[ScanFlow] Enrichment failed:', err))
.finally(() => setIsEnriching(false));
}
} else if (result.isAiError) {
console.warn('[ScanFlow] AI Analysis failed, falling back to offline mode');
setIsOffline(true);
setAiFallbackActive(true);
const dummyMetadata = generateDummyMetadata(file);
setBottleMetadata(dummyMetadata);
setState('EDITOR');
return;
} else {
console.error('[ScanFlow] magicScan failure:', result.error);
throw new Error(result.error || 'Flasche konnte nicht erkannt werden.');
throw new Error(result.error || 'Fehler bei der Analyse.');
}
} catch (err: any) {
console.error('[ScanFlow] handleScan error:', err);
// Check if this is a network error (offline)
if (err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError') || err.message?.includes('ERR_INTERNET_DISCONNECTED')) {
console.log('[ScanFlow] Network error detected - switching to offline mode');
setIsOffline(true);
// Use dummy metadata for offline scan
const dummyMetadata = generateDummyMetadata(file);
setBottleMetadata(dummyMetadata);
setState('EDITOR');
return;
}
// Other errors
setError(err.message);
setState('ERROR');
}
@@ -144,11 +245,119 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
setError(null);
try {
const { data: { user } = {} } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
// OFFLINE: Save to IndexedDB queue (skip auth check)
if (isOffline) {
console.log('[ScanFlow] Offline mode - queuing for upload');
const tempId = `temp_${Date.now()}`;
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
// 1. Save Bottle - Use compressed base64 for storage as well
const bottleResult = await saveBottle(bottleMetadata, processedImage.base64, user.id);
// Check for existing pending scan with same image to prevent duplicates
const existingScan = await db.pending_scans
.filter(s => s.imageBase64 === processedImage.base64)
.first();
let currentTempId = tempId;
if (existingScan) {
console.log('[ScanFlow] Existing pending scan found, reusing temp_id:', existingScan.temp_id);
currentTempId = existingScan.temp_id;
} else {
// Save pending scan with metadata
await db.pending_scans.add({
temp_id: tempId,
imageBase64: processedImage.base64,
timestamp: Date.now(),
locale,
// Store bottle metadata in a custom field
metadata: bottleDataToSave as any
});
}
// Save pending tasting linked to temp bottle
await db.pending_tastings.add({
pending_bottle_id: currentTempId,
data: {
session_id: activeSession?.id,
rating: formData.rating,
nose_notes: formData.nose_notes,
palate_notes: formData.palate_notes,
finish_notes: formData.finish_notes,
is_sample: formData.is_sample,
buddy_ids: formData.buddy_ids,
tag_ids: formData.tag_ids,
},
tasted_at: new Date().toISOString()
});
setTastingData(formData);
setState('RESULT');
setIsSaving(false);
return;
}
// ONLINE: Normal save to Supabase
let user;
try {
const { data: { user: authUser } = {} } = await supabase.auth.getUser();
if (!authUser) throw new Error('Nicht autorisiert');
user = authUser;
} catch (authError: any) {
// If auth fails due to network, treat as offline
if (authError.message?.includes('Failed to fetch') || authError.message?.includes('NetworkError')) {
console.log('[ScanFlow] Auth failed due to network - switching to offline mode');
setIsOffline(true);
// Save to queue instead
const tempId = `temp_${Date.now()}`;
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
// Check for existing pending scan with same image to prevent duplicates
const existingScan = await db.pending_scans
.filter(s => s.imageBase64 === processedImage.base64)
.first();
let currentTempId = tempId;
if (existingScan) {
console.log('[ScanFlow] Existing pending scan found, reusing temp_id:', existingScan.temp_id);
currentTempId = existingScan.temp_id;
} else {
await db.pending_scans.add({
temp_id: tempId,
imageBase64: processedImage.base64,
timestamp: Date.now(),
locale,
metadata: bottleDataToSave as any
});
}
await db.pending_tastings.add({
pending_bottle_id: currentTempId,
data: {
session_id: activeSession?.id,
rating: formData.rating,
nose_notes: formData.nose_notes,
palate_notes: formData.palate_notes,
finish_notes: formData.finish_notes,
is_sample: formData.is_sample,
buddy_ids: formData.buddy_ids,
tag_ids: formData.tag_ids,
},
tasted_at: new Date().toISOString()
});
setTastingData(formData);
setState('RESULT');
setIsSaving(false);
return;
}
// Other auth errors
throw authError;
}
// 1. Save Bottle - Use edited metadata if provided
const bottleDataToSave = formData.bottleMetadata || bottleMetadata;
const bottleResult = await saveBottle(bottleDataToSave, processedImage.base64, user.id);
if (!bottleResult.success || !bottleResult.data) {
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
}
@@ -168,6 +377,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
setTastingData(tastingNote);
setState('RESULT');
// Trigger bottle list refresh in parent
if (onBottleSaved) {
onBottleSaved(bottleId);
}
} catch (err: any) {
setError(err.message);
setState('ERROR');
@@ -250,7 +464,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
</div>
<div>
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">AI Engine</p>
{perfMetrics.aiApi === 0 ? (
{perfMetrics.cacheHit ? (
<div className="flex flex-col items-center">
<p className="text-green-500 font-bold tracking-tighter">CACHE HIT</p>
<p className="text-[7px] opacity-40 mt-1">DB RESULTS</p>
@@ -259,8 +473,12 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
<>
<p className="text-orange-500 font-bold">{perfMetrics.aiTotal.toFixed(0)}ms</p>
<div className="flex flex-col gap-0.5 mt-1 text-[7px] opacity-60">
<span>API: {perfMetrics.aiApi.toFixed(0)}ms</span>
<span>Parse: {perfMetrics.aiParse.toFixed(0)}ms</span>
{perfMetrics.imagePrep !== undefined && <span>Prep: {perfMetrics.imagePrep.toFixed(0)}ms</span>}
{perfMetrics.encoding !== undefined && <span>Encode: {perfMetrics.encoding.toFixed(0)}ms</span>}
{perfMetrics.modelInit !== undefined && <span>Init: {perfMetrics.modelInit.toFixed(0)}ms</span>}
<span className="text-orange-400">API: {perfMetrics.aiApi.toFixed(0)}ms</span>
{perfMetrics.validation !== undefined && <span>Valid: {perfMetrics.validation.toFixed(0)}ms</span>}
{perfMetrics.dbOps !== undefined && <span>DB: {perfMetrics.dbOps.toFixed(0)}ms</span>}
</div>
</>
)}
@@ -307,6 +525,18 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
exit={{ y: -50, opacity: 0 }}
className="flex-1 w-full h-full flex flex-col min-h-0"
>
{isOffline && (
<div className="bg-orange-500/10 border-b border-orange-500/20 p-4">
<div className="max-w-2xl mx-auto flex items-center gap-3">
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse" />
<p className="text-xs font-bold text-orange-500 uppercase tracking-wider">
{aiFallbackActive
? 'KI-Dienst nicht erreichbar. Nutze Platzhalter-Daten; Anreicherung erfolgt automatisch im Hintergrund.'
: 'Offline Modus - Daten werden hochgeladen wenn du online bist'}
</p>
</div>
</div>
)}
<TastingEditor
bottleMetadata={bottleMetadata}
image={processedImage?.base64 || null}
@@ -314,6 +544,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
onOpenSessions={() => setIsSessionsOpen(true)}
activeSessionName={activeSession?.name}
activeSessionId={activeSession?.id}
isEnriching={isEnriching}
/>
{isAdmin && perfMetrics && (
<div className="absolute top-24 left-6 right-6 z-50 p-3 bg-zinc-950/80 backdrop-blur-md rounded-2xl border border-orange-500/20 text-[9px] font-mono text-white/90 shadow-xl overflow-x-auto">
@@ -326,12 +557,21 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
</div>
<div className="flex items-center gap-2">
<span className="text-zinc-500">AI:</span>
{perfMetrics.aiApi === 0 ? (
<span className="text-green-500 font-bold tracking-tight">CACHE HIT </span>
{perfMetrics.cacheHit ? (
<span className="text-green-500 font-bold tracking-tight">CACHE HIT</span>
) : (
<>
<span className="text-orange-500 font-bold">{perfMetrics.aiTotal.toFixed(0)}ms</span>
<span className="text-zinc-600 ml-1">(API: {perfMetrics.aiApi.toFixed(0)}ms / Pars: {perfMetrics.aiParse.toFixed(0)}ms)</span>
<span className="text-zinc-600 ml-1 text-[10px]">
(
{perfMetrics.imagePrep !== undefined && `Prep:${perfMetrics.imagePrep.toFixed(0)} `}
{perfMetrics.encoding !== undefined && `Enc:${perfMetrics.encoding.toFixed(0)} `}
{perfMetrics.modelInit !== undefined && `Init:${perfMetrics.modelInit.toFixed(0)} `}
<span className="text-orange-400 font-bold">API:{perfMetrics.aiApi.toFixed(0)}</span>
{perfMetrics.validation !== undefined && ` Val:${perfMetrics.validation.toFixed(0)}`}
{perfMetrics.dbOps !== undefined && ` DB:${perfMetrics.dbOps.toFixed(0)}`}
)
</span>
</>
)}
</div>

View File

@@ -14,9 +14,10 @@ interface TagSelectorProps {
label?: string;
suggestedTagNames?: string[];
suggestedCustomTagNames?: string[];
isLoading?: boolean;
}
export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames }: TagSelectorProps) {
export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames, isLoading }: TagSelectorProps) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [isCreating, setIsCreating] = useState(false);
@@ -28,7 +29,6 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
[]
);
const isLoading = tags === undefined;
const filteredTags = useMemo(() => {
const tagList = tags || [];
@@ -57,9 +57,9 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
const selectedTags = (tags || []).filter(t => selectedTagIds.includes(t.id));
return (
<div className="space-y-3">
<div className="space-y-4">
{label && (
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest block">{label}</label>
<label className="text-[10px] font-black text-zinc-500 uppercase tracking-[0.15em] block">{label}</label>
)}
{/* Selected Tags */}
@@ -70,36 +70,36 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
key={tag.id}
type="button"
onClick={() => onToggleTag(tag.id)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 text-white rounded-full text-[10px] font-bold uppercase tracking-tight shadow-sm shadow-orange-600/20 animate-in fade-in zoom-in-95"
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 text-white rounded-xl text-[10px] font-black uppercase tracking-tight shadow-lg shadow-orange-950/20 animate-in fade-in zoom-in-95 hover:bg-orange-500 transition-colors"
>
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
<X size={12} />
<X size={12} strokeWidth={3} />
</button>
))
) : (
<span className="text-[10px] italic text-zinc-500 font-medium">Noch keine Tags gewählt...</span>
<span className="text-[10px] italic text-zinc-600 font-medium">Noch keine Tags gewählt...</span>
)}
</div>
{/* Search and Suggest */}
<div className="relative">
<div className="relative flex items-center">
<Search className="absolute left-3 text-zinc-500" size={14} />
<Search className="absolute left-3.5 text-zinc-500" size={14} />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Tag suchen oder hinzufügen..."
className="w-full pl-9 pr-4 py-2 bg-zinc-900 border border-zinc-800 rounded-xl text-xs focus:ring-1 focus:ring-orange-600 outline-none transition-all text-zinc-200 placeholder:text-zinc-600"
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl text-[11px] font-medium focus:ring-1 focus:ring-orange-600/50 focus:border-orange-600/50 outline-none transition-all text-zinc-200 placeholder:text-zinc-600"
/>
{isCreating && (
<Loader2 className="absolute right-3 animate-spin text-orange-600" size={14} />
<Loader2 className="absolute right-3.5 animate-spin text-orange-600" size={14} />
)}
</div>
{search && (
<div className="absolute z-10 w-full mt-2 bg-zinc-900 border border-zinc-800 rounded-2xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-2">
<div className="max-h-48 overflow-y-auto">
<div className="absolute z-50 w-full mt-2 bg-zinc-950 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-top-2">
<div className="max-h-60 overflow-y-auto">
{filteredTags.length > 0 ? (
filteredTags.map(tag => (
<button
@@ -109,19 +109,19 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
onToggleTag(tag.id);
setSearch('');
}}
className="w-full px-4 py-2.5 text-left text-xs font-bold text-zinc-300 hover:bg-zinc-800/50 flex items-center justify-between border-b border-zinc-800 last:border-0"
className="w-full px-4 py-3 text-left text-[11px] font-bold text-zinc-300 hover:bg-zinc-900 flex items-center justify-between border-b border-zinc-900 last:border-0 transition-colors"
>
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
{selectedTagIds.includes(tag.id) && <Check size={12} className="text-orange-600" />}
{selectedTagIds.includes(tag.id) && <Check size={12} className="text-orange-600" strokeWidth={3} />}
</button>
))
) : (
<button
type="button"
onClick={handleCreateTag}
className="w-full px-4 py-3 text-left text-xs font-bold text-orange-600 hover:bg-orange-950/10 flex items-center gap-2"
className="w-full px-4 py-4 text-left text-[11px] font-bold text-orange-500 hover:bg-orange-500/5 flex items-center gap-2 transition-colors"
>
<Plus size={14} />
<Plus size={14} strokeWidth={3} />
"{search}" als neuen Tag hinzufügen
</button>
)}
@@ -131,36 +131,43 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
</div>
{/* AI Suggestions */}
{!search && suggestedTagNames && suggestedTagNames.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-widest text-orange-500">
<Sparkles size={10} /> {t('camera.wbMatchFound') ? 'KI Vorschläge' : 'AI Suggestions'}
{!search && (isLoading || (suggestedTagNames && suggestedTagNames.length > 0)) && (
<div className="space-y-2 py-1">
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-[0.2em] text-orange-600">
{isLoading ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} className="fill-orange-600/20" />}
{isLoading ? 'Analysiere Aromen...' : 'KI Vorschläge'}
</div>
<div className="flex flex-wrap gap-1.5">
{(tags || [])
.filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames.some((s: string) => s.toLowerCase() === t.name.toLowerCase()))
.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => onToggleTag(tag.id)}
className="px-2.5 py-1 rounded-lg bg-orange-950/20 text-orange-500 text-[10px] font-bold uppercase tracking-tight hover:bg-orange-900/30 transition-colors border border-orange-900/50 flex items-center gap-1.5"
>
<Sparkles size={10} />
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
</button>
))}
<div className="flex flex-wrap gap-2">
{isLoading ? (
[1, 2, 3].map(i => (
<div key={i} className="h-7 w-16 bg-zinc-900 animate-pulse rounded-xl" />
))
) : (
(tags || [])
.filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames?.some((s: string) => s.toLowerCase() === t.name.toLowerCase()))
.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => onToggleTag(tag.id)}
className="px-3 py-1.5 rounded-xl bg-orange-950/20 text-orange-500 text-[10px] font-black uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-orange-600/20 flex items-center gap-1.5 shadow-sm"
>
<Sparkles size={10} />
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
</button>
))
)}
</div>
</div>
)}
{/* AI Custom Suggestions */}
{!search && suggestedCustomTagNames && suggestedCustomTagNames.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-widest text-zinc-500">
<div className="space-y-2 py-1">
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-[0.2em] text-zinc-500">
Dominante Note anlegen?
</div>
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap gap-2">
{suggestedCustomTagNames
.filter(name => !(tags || []).some(t => t.name.toLowerCase() === name.toLowerCase()))
.map(name => (
@@ -177,7 +184,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
}
setCreatingSuggestion(null);
}}
className="px-2.5 py-1 rounded-lg bg-zinc-900/50 text-zinc-400 text-[10px] font-bold uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-dashed border-zinc-800 flex items-center gap-1.5 disabled:opacity-50"
className="px-3 py-1.5 rounded-xl bg-zinc-950/50 text-zinc-500 text-[10px] font-black uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-200 transition-all border border-dashed border-zinc-800 flex items-center gap-1.5 disabled:opacity-50"
>
{creatingSuggestion === name ? <Loader2 size={10} className="animate-spin" /> : <Plus size={10} />}
{name}
@@ -187,7 +194,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
</div>
)}
{/* Suggestions Chips (limit to 6 random or most common) */}
{/* Suggestions Chips */}
{!search && (tags || []).length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{(tags || [])
@@ -198,7 +205,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
key={tag.id}
type="button"
onClick={() => onToggleTag(tag.id)}
className="px-2.5 py-1 rounded-lg bg-zinc-900 text-zinc-500 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-200 transition-colors border border-zinc-800"
className="px-2.5 py-1.5 rounded-xl bg-zinc-900 text-zinc-500 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-300 transition-colors border border-zinc-800/50"
>
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
</button>

View File

@@ -2,13 +2,14 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { ChevronDown, Wind, Utensils, Droplets, Sparkles, Send, Users, Star, AlertTriangle, Check, Zap } from 'lucide-react';
import { ChevronDown, Wind, Utensils, Droplets, Sparkles, Send, Users, Star, AlertTriangle, Check, Zap, Loader2 } from 'lucide-react';
import { BottleMetadata } from '@/types/whisky';
import TagSelector from './TagSelector';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';
import { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext';
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
interface TastingEditorProps {
bottleMetadata: BottleMetadata;
@@ -17,9 +18,10 @@ interface TastingEditorProps {
onOpenSessions: () => void;
activeSessionName?: string;
activeSessionId?: string;
isEnriching?: boolean;
}
export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId }: TastingEditorProps) {
export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId, isEnriching }: TastingEditorProps) {
const { t } = useI18n();
const supabase = createClient();
const [rating, setRating] = useState(85);
@@ -38,6 +40,27 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
const [finishTagIds, setFinishTagIds] = useState<string[]>([]);
// Editable bottle metadata
const [bottleName, setBottleName] = useState(bottleMetadata.name || '');
const [bottleDistillery, setBottleDistillery] = useState(bottleMetadata.distillery || '');
const [bottleAbv, setBottleAbv] = useState(bottleMetadata.abv?.toString() || '');
const [bottleAge, setBottleAge] = useState(bottleMetadata.age?.toString() || '');
const [bottleCategory, setBottleCategory] = useState(bottleMetadata.category || 'Whisky');
const [bottleVintage, setBottleVintage] = useState(bottleMetadata.vintage || '');
const [bottleBottler, setBottleBottler] = useState(bottleMetadata.bottler || '');
const [bottleBatchInfo, setBottleBatchInfo] = useState(bottleMetadata.batch_info || '');
const [bottleCode, setBottleCode] = useState(bottleMetadata.bottleCode || '');
const [bottleDistilledAt, setBottleDistilledAt] = useState(bottleMetadata.distilled_at || '');
const [bottleBottledAt, setBottleBottledAt] = useState(bottleMetadata.bottled_at || '');
const [showBottleDetails, setShowBottleDetails] = useState(false);
// Whiskybase discovery
const [whiskybaseId, setWhiskybaseId] = useState(bottleMetadata.whiskybaseId || '');
const [whiskybaseDiscovery, setWhiskybaseDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
const [isDiscoveringWb, setIsDiscoveringWb] = useState(false);
const [whiskybaseError, setWhiskybaseError] = useState<string | null>(null);
const [textureTagIds, setTextureTagIds] = useState<string[]>([]);
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
@@ -100,6 +123,42 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
}
}, [lastDramInSession]);
// Automatic Whiskybase discovery when details are expanded
useEffect(() => {
const searchWhiskybase = async () => {
if (showBottleDetails && !whiskybaseId && !whiskybaseDiscovery && !isDiscoveringWb) {
setIsDiscoveringWb(true);
try {
const result = await discoverWhiskybaseId({
name: bottleMetadata.name || '',
distillery: bottleMetadata.distillery ?? undefined,
abv: bottleMetadata.abv ?? undefined,
age: bottleMetadata.age ?? undefined,
batch_info: bottleMetadata.batch_info ?? undefined,
distilled_at: bottleMetadata.distilled_at ?? undefined,
bottled_at: bottleMetadata.bottled_at ?? undefined,
});
if (result.success && result.id) {
setWhiskybaseDiscovery({ id: result.id, url: result.url, title: result.title });
setWhiskybaseId(result.id);
setWhiskybaseError(null);
} else {
// No results found
setWhiskybaseError('Keine Ergebnisse gefunden');
}
} catch (err: any) {
console.error('[TastingEditor] Whiskybase discovery failed:', err);
setWhiskybaseError(err.message || 'Suche fehlgeschlagen');
} finally {
setIsDiscoveringWb(false);
}
}
};
searchWhiskybase();
}, [showBottleDetails]); // Only trigger when details are expanded
const toggleBuddy = (id: string) => {
setSelectedBuddyIds(prev => prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]);
};
@@ -118,27 +177,29 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
taste: tasteScore,
finish: finishScore,
complexity: complexityScore,
balance: balanceScore
balance: balanceScore,
// Edited bottle metadata
bottleMetadata: {
...bottleMetadata,
name: bottleName || bottleMetadata.name,
distillery: bottleDistillery || bottleMetadata.distillery,
abv: bottleAbv ? parseFloat(bottleAbv) : bottleMetadata.abv,
age: bottleAge ? parseInt(bottleAge) : bottleMetadata.age,
category: bottleCategory || bottleMetadata.category,
vintage: bottleVintage || null,
bottler: bottleBottler || null,
batch_info: bottleBatchInfo || null,
bottleCode: bottleCode || null,
distilled_at: bottleDistilledAt || null,
bottled_at: bottleBottledAt || null,
whiskybaseId: whiskybaseId || null,
}
});
};
return (
<div className="flex-1 flex flex-col w-full bg-zinc-950 h-full overflow-hidden">
{/* Top Context Bar - Flex Child 1 */}
<div className="w-full bg-zinc-900 border-b border-zinc-800 shrink-0">
<button
onClick={onOpenSessions}
className="max-w-2xl mx-auto w-full p-6 flex items-center justify-between group"
>
<div className="text-left">
<p className="text-[10px] font-bold uppercase tracking-widest text-orange-500">Kontext</p>
<p className="font-bold text-zinc-50 leading-none mt-1">{activeSessionName || 'Trinkst du in Gesellschaft?'}</p>
</div>
<ChevronDown size={20} className="text-orange-500 group-hover:translate-y-1 transition-transform" />
</button>
</div>
{/* Main Scrollable Content - Flex Child 2 */}
{/* Main Scrollable Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<div className="max-w-2xl mx-auto px-6 py-12 space-y-12">
{/* Palette Warning */}
@@ -170,15 +231,254 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
</div>
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-bold text-orange-600 mb-1 truncate leading-none uppercase tracking-tight">
{bottleMetadata.distillery || 'Destillerie'}
{bottleDistillery || 'Destillerie'}
</h1>
<p className="text-zinc-50 text-xl font-bold truncate mb-2">{bottleMetadata.name || 'Unbekannter Malt'}</p>
<p className="text-zinc-50 text-xl font-bold truncate mb-2">{bottleName || 'Unbekannter Malt'}</p>
<p className="text-zinc-500 text-[10px] font-bold uppercase tracking-widest leading-none">
{bottleMetadata.category || 'Whisky'} {bottleMetadata.abv ? `${bottleMetadata.abv}%` : ''} {bottleMetadata.age ? `${bottleMetadata.age}y` : ''}
{bottleCategory || 'Whisky'} {bottleAbv ? `${bottleAbv}%` : ''} {bottleAge ? `${bottleAge}y` : ''}
</p>
</div>
</div>
{/* Expandable Bottle Details */}
<div className="bg-zinc-900/50 rounded-2xl border border-zinc-800 overflow-hidden">
<button
onClick={() => setShowBottleDetails(!showBottleDetails)}
className="w-full p-4 flex items-center justify-between hover:bg-zinc-900/70 transition-colors"
>
<span className="text-[10px] font-bold uppercase tracking-widest text-zinc-500">
Bottle Details
</span>
<ChevronDown
size={16}
className={`text-zinc-500 transition-transform ${showBottleDetails ? 'rotate-180' : ''}`}
/>
</button>
{showBottleDetails && (
<div className="p-4 pt-0 space-y-3 border-t border-zinc-800/50">
{/* Name */}
<div className="mt-3">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Flaschenname
</label>
<input
type="text"
value={bottleName}
onChange={(e) => setBottleName(e.target.value)}
placeholder="e.g. 12 Year Old"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Distillery */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Destillerie
</label>
<input
type="text"
value={bottleDistillery}
onChange={(e) => setBottleDistillery(e.target.value)}
placeholder="e.g. Lagavulin"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
<div className="grid grid-cols-2 gap-3">
{/* ABV */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Alkohol (ABV %)
</label>
<input
type="number"
step="0.1"
value={bottleAbv}
onChange={(e) => setBottleAbv(e.target.value)}
placeholder="43.0"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Age */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Alter (Jahre)
</label>
<input
type="number"
value={bottleAge}
onChange={(e) => setBottleAge(e.target.value)}
placeholder="12"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
</div>
{/* Category */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Kategorie
</label>
<input
type="text"
value={bottleCategory}
onChange={(e) => setBottleCategory(e.target.value)}
placeholder="e.g. Single Malt"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Vintage */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Vintage
</label>
<input
type="text"
value={bottleVintage}
onChange={(e) => setBottleVintage(e.target.value)}
placeholder="e.g. 2007"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Bottler */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Bottler
</label>
<input
type="text"
value={bottleBottler}
onChange={(e) => setBottleBottler(e.target.value)}
placeholder="e.g. Independent Bottler"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Distilled At */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Distilled At
</label>
<input
type="text"
value={bottleDistilledAt}
onChange={(e) => setBottleDistilledAt(e.target.value)}
placeholder="e.g. 2007"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Bottled At */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Bottled At
</label>
<input
type="text"
value={bottleBottledAt}
onChange={(e) => setBottleBottledAt(e.target.value)}
placeholder="e.g. 2024"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Batch Info */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Batch Info
</label>
<input
type="text"
value={bottleBatchInfo}
onChange={(e) => setBottleBatchInfo(e.target.value)}
placeholder="e.g. Oloroso Sherry Cask"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Bottle Code */}
<div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
Bottle Code
</label>
<input
type="text"
value={bottleCode}
onChange={(e) => setBottleCode(e.target.value)}
placeholder="e.g. WB271235"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-none focus:border-orange-600 transition-colors"
/>
</div>
{/* Whiskybase Discovery */}
{isDiscoveringWb && (
<div className="flex items-center gap-2 p-3 bg-zinc-900 rounded-lg border border-zinc-800">
<Loader2 size={16} className="animate-spin text-orange-500" />
<span className="text-xs text-zinc-400">Searching Whiskybase...</span>
</div>
)}
{whiskybaseDiscovery && (
<div className="p-3 bg-orange-500/10 border border-orange-500/20 rounded-lg space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-[9px] font-bold uppercase tracking-wider text-orange-500 mb-1">
Whiskybase Found
</p>
<p className="text-xs text-zinc-200 truncate">
{whiskybaseDiscovery.title}
</p>
<a
href={whiskybaseDiscovery.url}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-orange-500 hover:underline mt-1 inline-block"
>
View on Whiskybase
</a>
</div>
</div>
<p className="text-[10px] text-zinc-500 font-mono">
ID: {whiskybaseDiscovery.id}
</p>
</div>
)}
{/* Whiskybase Error */}
{whiskybaseError && !whiskybaseDiscovery && !isDiscoveringWb && (
<div className="p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
<p className="text-xs text-zinc-500">
{whiskybaseError}
</p>
</div>
)}
</div>
)}
</div>
{/* Session Selector */}
<button
onClick={onOpenSessions}
className="w-full p-6 bg-zinc-900/50 rounded-2xl border border-zinc-800 flex items-center justify-between group hover:bg-zinc-900/70 hover:border-orange-500/30 transition-all"
>
<div className="text-left">
<p className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-1">
Session
</p>
<p className="font-bold text-zinc-50 leading-none">
{activeSessionName || 'Trinkst du in Gesellschaft?'}
</p>
</div>
<div className="flex items-center gap-2">
{activeSessionName && (
<Users size={18} className="text-orange-500" />
)}
<ChevronDown size={18} className="text-zinc-500 group-hover:text-orange-500 group-hover:translate-y-0.5 transition-all" />
</div>
</button>
{/* Rating Slider */}
<div className="space-y-6 bg-zinc-900 p-8 rounded-3xl border border-zinc-800 shadow-inner relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
@@ -252,6 +552,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
onToggleTag={(id) => setNoseTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
isLoading={isEnriching}
/>
</div>
<div className="space-y-3">
@@ -289,6 +590,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
onToggleTag={(id) => setPalateTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
isLoading={isEnriching}
/>
</div>
<div className="space-y-3">
@@ -383,19 +685,19 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
)}
</div>
</div>
</div>
{/* Sticky Footer - Flex Child 3 */}
<div className="w-full p-8 bg-zinc-950 border-t border-zinc-800 shrink-0">
<div className="max-w-2xl mx-auto">
<button
onClick={handleInternalSave}
className="w-full py-5 bg-orange-600 text-white rounded-2xl font-bold uppercase tracking-widest text-xs flex items-center justify-center gap-4 shadow-xl active:scale-[0.98] transition-all"
>
<Send size={20} />
{t('tasting.saveTasting')}
<div className="ml-auto bg-black/20 px-3 py-1 rounded-full text-[10px] font-bold text-white/60">{rating}</div>
</button>
</div>
{/* Fixed/Sticky Footer for Save Action */}
<div className="w-full p-6 bg-gradient-to-t from-zinc-950 via-zinc-950/95 to-transparent border-t border-white/5 shrink-0 z-20">
<div className="max-w-2xl mx-auto">
<button
onClick={handleInternalSave}
className="w-full py-5 bg-orange-600 text-white rounded-2xl font-bold uppercase tracking-widest text-xs flex items-center justify-center gap-4 shadow-2xl shadow-orange-950/40 active:scale-[0.98] transition-all hover:bg-orange-500"
>
<Send size={20} />
{t('tasting.saveTasting')}
<div className="ml-auto bg-black/20 px-3 py-1 rounded-full text-[10px] font-bold text-white/60">{rating}</div>
</button>
</div>
</div>
</div>

View File

@@ -439,18 +439,21 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-4 bg-zinc-100 text-zinc-900 font-black uppercase tracking-widest text-xs rounded-2xl flex items-center justify-center gap-3 hover:bg-orange-600 hover:text-white transition-all active:scale-[0.98] disabled:opacity-50 shadow-xl shadow-black/10"
>
{loading ? <Loader2 className="animate-spin" size={18} /> : (
<>
<Send size={16} />
{t('tasting.saveTasting')}
</>
)}
</button>
{/* Sticky Save Button Container */}
<div className="sticky bottom-0 -mx-6 px-6 py-4 bg-gradient-to-t from-zinc-950 via-zinc-950/90 to-transparent z-10">
<button
type="submit"
disabled={loading}
className="w-full py-4 bg-zinc-100 text-zinc-900 font-black uppercase tracking-widest text-xs rounded-2xl flex items-center justify-center gap-3 hover:bg-orange-600 hover:text-white transition-all active:scale-[0.98] disabled:opacity-50 shadow-2xl shadow-black/50 border border-white/5"
>
{loading ? <Loader2 className="animate-spin" size={18} /> : (
<>
<Send size={16} />
{t('tasting.saveTasting')}
</>
)}
</button>
</div>
</form>
);
}

View File

@@ -3,13 +3,31 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db, PendingScan, PendingTasting } from '@/lib/db';
import { magicScan } from '@/services/magic-scan';
import { scanLabel } from '@/app/actions/scan-label';
import { enrichData } from '@/app/actions/enrich-data';
import { saveBottle } from '@/services/save-bottle';
import { saveTasting } from '@/services/save-tasting';
import { createClient } from '@/lib/supabase/client';
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info, Send } from 'lucide-react';
import TastingNoteForm from './TastingNoteForm';
// Helper to convert base64 to FormData
function base64ToFormData(base64: string, filename: string = 'image.webp'): FormData {
const arr = base64.split(',');
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/webp';
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
const file = new File([u8arr], filename, { type: mime });
const formData = new FormData();
formData.append('file', file);
return formData;
}
export default function UploadQueue() {
const supabase = createClient();
const [isSyncing, setIsSyncing] = useState(false);
@@ -23,9 +41,12 @@ export default function UploadQueue() {
const totalInQueue = pendingScans.length + pendingTastings.length;
const syncQueue = useCallback(async () => {
if (isSyncing || !navigator.onLine || totalInQueue === 0) return;
const syncInProgress = React.useRef(false);
const syncQueue = useCallback(async () => {
if (syncInProgress.current || !navigator.onLine) return;
syncInProgress.current = true;
setIsSyncing(true);
const { data: { user } } = await supabase.auth.getUser();
@@ -36,61 +57,139 @@ export default function UploadQueue() {
}
try {
// 1. Sync Scans (Magic Shots)
for (const item of pendingScans) {
// 1. Sync Scans (Magic Shots) - Two-Step Flow
// We use a transaction to "claim" items currently not being synced by another tab/instance
const scansToSync = await db.transaction('rw', db.pending_scans, async () => {
const all = await db.pending_scans.toArray();
const now = Date.now();
const available = all.filter(i => {
if (i.syncing) return false;
// Exponential backoff: don't retry immediately if it failed before
if (i.attempts && i.attempts > 0) {
const backoff = Math.min(Math.pow(2, i.attempts) * 1000, 30000); // Max 30s
const lastAttempt = i.timestamp; // We use timestamp for simplicity or add last_attempt
// For now we trust timestamp + backoff if timestamp is updated on fail
return (now - i.timestamp) > backoff;
}
return true;
});
for (const item of available) {
await db.pending_scans.update(item.id!, { syncing: 1 });
}
return available;
});
for (const item of scansToSync) {
const itemId = `scan-${item.id}`;
setCurrentProgress({ id: itemId, status: 'Analysiere Scan...' });
setCurrentProgress({ id: itemId, status: 'OCR Analyse...' });
try {
const analysis = await magicScan(item.imageBase64, item.provider, item.locale);
if (analysis.success && analysis.data) {
const bottleData = analysis.data;
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
const save = await saveBottle(bottleData, item.imageBase64, user.id);
if (save.success && save.data) {
const newBottleId = save.data.id;
let bottleData;
// Reconcile pending tastings linked to this temp_id
if (item.temp_id) {
const linkedTastings = await db.pending_tastings
.where('pending_bottle_id')
.equals(item.temp_id)
.toArray();
// Check if this is an offline scan with pre-filled metadata
// CRITICAL: If name is empty, it's placeholder metadata and needs OCR enrichment
if (item.metadata && item.metadata.name && item.metadata.name.trim().length > 0) {
console.log('[UploadQueue] Valid offline metadata found - skipping OCR');
bottleData = item.metadata;
setCurrentProgress({ id: itemId, status: 'Speichere Offline-Scan...' });
} else {
console.log('[UploadQueue] No valid metadata - running OCR analysis');
// Normal online scan - perform AI analysis
// Step 1: Fast OCR
const formData = base64ToFormData(item.imageBase64);
const ocrResult = await scanLabel(formData);
for (const lt of linkedTastings) {
await db.pending_tastings.update(lt.id!, {
bottle_id: newBottleId,
pending_bottle_id: undefined
});
if (ocrResult.success && ocrResult.data) {
bottleData = ocrResult.data;
// Step 2: Background enrichment (before saving)
if (bottleData.is_whisky && bottleData.name && bottleData.distillery) {
setCurrentProgress({ id: itemId, status: 'Enrichment...' });
const enrichResult = await enrichData(
bottleData.name,
bottleData.distillery,
undefined,
item.locale
);
if (enrichResult.success && enrichResult.data) {
// Merge enrichment data into bottle data
bottleData = {
...bottleData,
suggested_tags: enrichResult.data.suggested_tags,
suggested_custom_tags: enrichResult.data.suggested_custom_tags
};
}
}
setCompletedItems(prev => [...prev.slice(-4), {
id: itemId,
name: bottleData.name || 'Unbekannter Whisky',
bottleId: newBottleId,
type: 'scan'
}]);
await db.pending_scans.delete(item.id!);
} else {
throw new Error(ocrResult.error || 'Analyse fehlgeschlagen');
}
} else {
throw new Error(analysis.error || 'Analyse fehlgeschlagen');
}
// Step 3: Save bottle with all data
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
const save = await saveBottle(bottleData, item.imageBase64, user.id);
if (save.success && save.data) {
const newBottleId = save.data.id;
// Reconcile pending tastings linked to this temp_id
if (item.temp_id) {
const linkedTastings = await db.pending_tastings
.where('pending_bottle_id')
.equals(item.temp_id)
.toArray();
for (const lt of linkedTastings) {
await db.pending_tastings.update(lt.id!, {
bottle_id: newBottleId,
pending_bottle_id: undefined
});
}
}
setCompletedItems(prev => [...prev.slice(-4), {
id: itemId,
name: bottleData.name || 'Unbekannter Whisky',
bottleId: newBottleId,
type: 'scan'
}]);
await db.pending_scans.delete(item.id!);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unbekannter Fehler';
console.error('Scan sync failed:', err);
setCurrentProgress({ id: itemId, status: 'Fehler bei Scan' });
// Wait a bit before next
await new Promise(r => setTimeout(r, 2000));
setCurrentProgress({ id: itemId, status: `Fehler: ${errorMessage.substring(0, 20)}...` });
// Unmark as syncing on failure, update attempts and timestamp for backoff
await db.pending_scans.update(item.id!, {
syncing: 0,
attempts: (item.attempts || 0) + 1,
last_error: errorMessage,
timestamp: Date.now() // Update timestamp to use for backoff
});
await new Promise(r => setTimeout(r, 1000));
}
}
// 2. Sync Tastings
for (const item of pendingTastings) {
// If it still has a pending_bottle_id, it means the scan hasn't synced yet.
// We SKIP this tasting and wait for the scan to finish in a future loop.
if (item.pending_bottle_id) {
continue;
const tastingsToSync = await db.transaction('rw', db.pending_tastings, async () => {
const all = await db.pending_tastings.toArray();
const now = Date.now();
const available = all.filter(i => {
if (i.syncing || i.pending_bottle_id) return false;
// Exponential backoff
if (i.attempts && i.attempts > 0) {
const backoff = Math.min(Math.pow(2, i.attempts) * 1000, 30000);
return (now - new Date(i.tasted_at).getTime()) > backoff;
}
return true;
});
for (const item of available) {
await db.pending_tastings.update(item.id!, { syncing: 1 });
}
return available;
});
for (const item of tastingsToSync) {
const itemId = `tasting-${item.id}`;
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
try {
@@ -112,36 +211,61 @@ export default function UploadQueue() {
throw new Error(result.error);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unbekannter Fehler';
console.error('Tasting sync failed:', err);
setCurrentProgress({ id: itemId, status: 'Fehler bei Tasting' });
await new Promise(r => setTimeout(r, 2000));
setCurrentProgress({ id: itemId, status: `Fehler: ${errorMessage.substring(0, 20)}...` });
await db.pending_tastings.update(item.id!, {
syncing: 0,
attempts: (item.attempts || 0) + 1,
last_error: errorMessage
// Note: we use tasted_at or add a last_attempt for backoff.
// For now let's just use the tried attempts as a counter and a fixed wait.
});
await new Promise(r => setTimeout(r, 1000));
}
}
} catch (err) {
console.error('Global Sync Error:', err);
} finally {
syncInProgress.current = false;
setIsSyncing(false);
setCurrentProgress(null);
}
}, [isSyncing, pendingScans, pendingTastings, totalInQueue, supabase]);
}, [supabase]); // Removed pendingScans, pendingTastings, totalInQueue, isSyncing
useEffect(() => {
const handleOnline = () => {
console.log('Online! Waiting 2s for network stability...');
setTimeout(() => {
syncQueue();
}, 2000);
console.log('Online! Syncing in 2s...');
setTimeout(syncQueue, 2000);
};
window.addEventListener('online', handleOnline);
// Initial check if we are online and have items
// Initial check: only trigger if online and items exist,
// and we aren't already syncing.
if (navigator.onLine && totalInQueue > 0 && !isSyncing) {
syncQueue();
// we use a small timeout to debounce background sync
const timer = setTimeout(syncQueue, 3000);
return () => {
window.removeEventListener('online', handleOnline);
clearTimeout(timer);
};
}
return () => window.removeEventListener('online', handleOnline);
}, [totalInQueue, syncQueue, isSyncing]);
// Trigger when the presence of items changes or online status
}, [syncQueue, totalInQueue > 0]); // Removed isSyncing to break the loop
// Clear stale syncing flags on mount
useEffect(() => {
const clearStaleFlags = async () => {
await db.transaction('rw', [db.pending_scans, db.pending_tastings], async () => {
await db.pending_scans.where('syncing').equals(1).modify({ syncing: 0 });
await db.pending_tastings.where('syncing').equals(1).modify({ syncing: 0 });
});
};
clearStaleFlags();
}, []);
if (totalInQueue === 0) return null;

View File

@@ -1,46 +1,68 @@
export const getSystemPrompt = (availableTags: string, language: string) => `
TASK: Analyze this whisky bottle image. Return raw JSON.
export const getOcrPrompt = () => `
ROLE: High-Precision OCR Engine for Whisky Labels.
OBJECTIVE: Extract visible metadata strictly from the image.
SPEED PRIORITY: Do NOT analyze flavor. Do NOT provide descriptions. Do NOT add tags.
STEP 1: IDENTIFICATION (OCR & EXTRACTION)
Extract exact text and details from the label. Look closely for specific dates and codes.
- name: Full whisky name (e.g. "Lagavulin 16 Year Old")
- distillery: Distillery name
- bottler: Independent bottler if applicable
- category: Type (e.g. "Islay Single Malt", "Bourbon")
- abv: Alcohol percentage (number only)
- age: Age statement in years (number only)
- vintage: Vintage year (e.g. "1995")
- distilled_at: Distillation date/year if specified
- bottled_at: Bottling date/year if specified
- batch_info: Cask number, Batch ID, or Bottle number (e.g. "Batch 001", "Cask #402")
- bottleCode: Laser codes etched on glass/label (e.g. "L1234...")
- whiskybaseId: Whiskybase ID if clearly printed (rare, but check)
TASK:
1. Identify if the image contains a whisky/spirit bottle.
2. Extract the following technical details into the JSON schema below.
3. If a value is not visible or cannot be inferred with high certainty, use null.
STEP 2: SENSORY "MAGIC" (KNOWLEDGE RETRIEVAL)
Use the IDENTIFIED NAME from Step 1 to query your internal knowledge base for the flavor profile.
DO NOT try to "see" the flavor in the pixels. Use your expert knowledge about this specific whisky edition.
- Match flavors strictly against this list: ${availableTags}
- Select top 5-8 matching tags.
- If distinct notes are missing from the list, add 1-2 unique ones to "suggested_custom_tags" (localized in ${language === 'de' ? 'German' : 'English'}).
EXTRACTION RULES:
- Name: Combine Distillery + Age + Edition + Vintage (e.g., "Signatory Vintage Ben Nevis 2019 4 Year Old").
- Distillery: The producer of the spirit.
- Bottler: Independent bottler (e.g., "Signatory", "Gordon & MacPhail") if applicable.
- Batch Info: Capture ALL Cask numbers, Batch IDs, Bottle numbers, Cask Types (e.g., "Refill Oloroso Sherry Butt, Bottle 1135").
- Codes: Look for laser codes etched on glass/label (e.g., "L20394...").
- Dates: Distinguish clearly between Vintage (distilled year), Bottled year, and Age.
OUTPUT SCHEMA (Strict JSON):
{
"name": "string",
"distillery": "string",
"category": "string",
"abv": number or null,
"age": number or null,
"vintage": "string or null",
"distilled_at": "string or null",
"bottled_at": "string or null",
"batch_info": "string or null",
"bottleCode": "string or null",
"whiskybaseId": "string or null",
"bottler": "stringOrNull",
"category": "string (e.g. Single Malt Scotch Whisky)",
"abv": numberOrNull,
"age": numberOrNull,
"vintage": "stringOrNull",
"distilled_at": "stringOrNull (Year/Date)",
"bottled_at": "stringOrNull (Year/Date)",
"batch_info": "stringOrNull",
"bottleCode": "stringOrNull",
"whiskybaseId": "stringOrNull",
"is_whisky": boolean,
"confidence": number,
"suggested_tags": ["tag1", "tag2"],
"suggested_custom_tags": ["custom1"],
"search_string": "site:whiskybase.com [Distillery] [Name] [Vintage/Age]"
"confidence": number
}
`;
export const getEnrichmentPrompt = (name: string, distillery: string, availableTags: string, language: string) => `
TASK: You are a Whisky Sommelier.
INPUT: A whisky named "${name}" from distillery "${distillery}".
1. DATABASE LOOKUP:
Retrieve the sensory profile and specific Whiskybase search string for this bottling.
Use your expert knowledge.
2. TAGGING:
Select the top 5-8 flavor tags strictly from this list:
[${availableTags}]
3. SEARCH STRING:
Create a precise search string for Whiskybase using: "site:whiskybase.com [Distillery] [Vintage/Age] [Bottler/Edition]"
OUTPUT JSON:
{
"suggested_tags": ["tag1", "tag2", "tag3"],
"suggested_custom_tags": ["uniquer_note_if_missing_in_list"],
"search_string": "string"
}
`;
// Legacy support (to avoid immediate breaking changes while refactoring)
export const getSystemPrompt = (availableTags: string, language: string) => `
${getOcrPrompt()}
Additionally, provide:
- suggested_tags: string[] (matched against [${availableTags}])
- suggested_custom_tags: string[]
- search_string: string
`;

View File

@@ -7,6 +7,10 @@ export interface PendingScan {
timestamp: number;
provider?: 'gemini' | 'mistral';
locale?: string;
metadata?: any; // Bottle metadata for offline scans
syncing?: number; // 0 or 1 for indexing
attempts?: number;
last_error?: string;
}
export interface PendingTasting {
@@ -25,6 +29,9 @@ export interface PendingTasting {
};
photo?: string;
tasted_at: string;
syncing?: number; // 0 or 1 for indexing
attempts?: number;
last_error?: string;
}
export interface CachedTag {
@@ -80,9 +87,9 @@ export class WhiskyDexie extends Dexie {
constructor() {
super('WhiskyVault');
this.version(4).stores({
pending_scans: '++id, temp_id, timestamp, locale',
pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at',
this.version(6).stores({
pending_scans: '++id, temp_id, timestamp, locale, syncing, attempts',
pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at, syncing, attempts',
cache_tags: 'id, category, name',
cache_buddies: 'id, name',
cache_bottles: 'id, name, distillery',

View File

@@ -5,7 +5,6 @@ const apiKey = process.env.GEMINI_API_KEY!;
const genAI = new GoogleGenerativeAI(apiKey);
export const geminiModel = genAI.getGenerativeModel({
//model: 'gemini-3-flash-preview',
model: 'gemini-2.5-flash',
generationConfig: {
responseMimeType: 'application/json',

View File

@@ -33,13 +33,13 @@ export async function proxy(request: NextRequest) {
}
);
const { data: { session } } = await supabase.auth.getSession();
const { data: { user } } = await supabase.auth.getUser();
const url = new URL(request.url);
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
if (!isStatic) {
const status = session ? `User:${session.user.id.slice(0, 8)}` : 'No Session';
const status = user ? `User:${user.id.slice(0, 8)}` : 'No Session';
console.log(`[Proxy] ${request.method} ${url.pathname} | ${status}`);
}

View File

@@ -36,12 +36,12 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
// 2. Auth & Credits
supabase = await createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session || !session.user) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
}
const userId = session.user.id;
const userId = user.id;
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
return {
@@ -85,98 +85,98 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
const prompt = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale);
const startApi = performance.now();
const chatResponse = await client.chat.complete({
model: 'mistral-large-latest',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', imageUrl: dataUrl }
]
}
],
responseFormat: { type: 'json_object' },
temperature: 0.1
});
const endApi = performance.now();
const startParse = performance.now();
const rawContent = chatResponse.choices?.[0].message.content;
if (!rawContent) throw new Error("Keine Antwort von Mistral");
let jsonData;
try {
jsonData = JSON.parse(rawContent as string);
} catch (e) {
const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
jsonData = JSON.parse(cleanedText);
const startApi = performance.now();
const chatResponse = await client.chat.complete({
model: 'mistral-large-latest',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', imageUrl: dataUrl }
]
}
],
responseFormat: { type: 'json_object' },
temperature: 0.1
});
const endApi = performance.now();
const startParse = performance.now();
const rawContent = chatResponse.choices?.[0].message.content;
if (!rawContent) throw new Error("Keine Antwort von Mistral");
let jsonData;
try {
jsonData = JSON.parse(rawContent as string);
} catch (e) {
const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
jsonData = JSON.parse(cleanedText);
}
if (Array.isArray(jsonData)) jsonData = jsonData[0];
console.log('[Mistral AI] JSON Response:', jsonData);
const searchString = jsonData.search_string;
delete jsonData.search_string;
if (typeof jsonData.abv === 'string') {
jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim());
}
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
const validatedData = BottleMetadataSchema.parse(jsonData);
const endParse = performance.now();
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: true
});
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
await supabase
.from('vision_cache')
.insert({ hash: imageHash, result: validatedData });
return {
success: true,
data: validatedData,
search_string: searchString,
perf: {
apiDuration: endApi - startApi,
parseDuration: endParse - startParse,
uploadSize: uploadSize
},
raw: jsonData
};
} catch (aiError: any) {
console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: false,
errorMessage: aiError.message
});
return {
success: false,
isAiError: true,
error: aiError.message,
imageHash: imageHash
} as any;
}
if (Array.isArray(jsonData)) jsonData = jsonData[0];
console.log('[Mistral AI] JSON Response:', jsonData);
// Extract search_string before validation
const searchString = jsonData.search_string;
delete jsonData.search_string;
// Ensure abv is a number if it came as a string
if (typeof jsonData.abv === 'string') {
jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim());
}
// Ensure age/vintage are numbers
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
const validatedData = BottleMetadataSchema.parse(jsonData);
const endParse = performance.now();
// Track usage
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: true
});
// Deduct credits
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
// Store in Cache
await supabase
.from('vision_cache')
.insert({ hash: imageHash, result: validatedData });
return {
success: true,
data: validatedData,
search_string: searchString,
perf: {
apiDuration: endApi - startApi,
parseDuration: endParse - startParse,
uploadSize: uploadSize
},
raw: jsonData
};
} catch (error) {
console.error('Mistral Analysis Error:', error);
if (supabase) {
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
await trackApiUsage({
userId: session.user.id,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: false,
errorMessage: error instanceof Error ? error.message : 'Unknown error'
});
}
}
console.error('Mistral Analysis Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',

View File

@@ -37,13 +37,13 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
// 2. Auth & Credits (bleibt gleich)
supabase = await createClient();
const { data: { session } } = await supabase.auth.getSession();
const { data: { user } } = await supabase.auth.getUser();
if (!session || !session.user) {
if (!user) {
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
}
const userId = session.user.id;
const userId = user.id;
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
@@ -80,96 +80,96 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
}
// 5. Für Gemini vorbereiten
// Wir müssen es hier zwar zu Base64 machen, aber Node.js (C++) macht das
// extrem effizient. Das Problem vorher war der JSON Parser von Next.js.
const base64Data = buffer.toString('base64');
const mimeType = file.type || 'image/webp'; // Fallback
const mimeType = file.type || 'image/webp';
const uploadSize = buffer.length;
const instruction = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'No tags available', locale);
// API Call
const startApi = performance.now();
const result = await geminiModel.generateContent([
{
inlineData: {
data: base64Data,
mimeType: mimeType,
},
},
{ text: instruction },
]);
const endApi = performance.now();
const startParse = performance.now();
const responseText = result.response.text();
// JSON Parsing der ANTWORT (das ist klein, das schafft der N100 locker)
let jsonData;
try {
jsonData = JSON.parse(responseText);
} catch (e) {
// Fallback falls Gemini Markdown ```json Blöcke schickt
const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, '');
jsonData = JSON.parse(cleanedText);
// API Call
const startApi = performance.now();
const result = await geminiModel.generateContent([
{
inlineData: {
data: base64Data,
mimeType: mimeType,
},
},
{ text: instruction },
]);
const endApi = performance.now();
const startParse = performance.now();
const responseText = result.response.text();
let jsonData;
try {
jsonData = JSON.parse(responseText);
} catch (e) {
const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, '');
jsonData = JSON.parse(cleanedText);
}
if (Array.isArray(jsonData)) jsonData = jsonData[0];
console.log('[Gemini AI] JSON Response:', jsonData);
const searchString = jsonData.search_string;
delete jsonData.search_string;
if (!jsonData) throw new Error('Keine Daten in der KI-Antwort gefunden.');
const validatedData = BottleMetadataSchema.parse(jsonData);
const endParse = performance.now();
// 6. Tracking & Credits
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'generateContent',
success: true
});
await deductCredits(userId, 'gemini_ai', 'Bottle analysis');
// Cache speichern
await supabase
.from('vision_cache')
.insert({ hash: imageHash, result: validatedData });
return {
success: true,
data: validatedData,
search_string: searchString,
perf: {
apiDuration: endApi - startApi,
parseDuration: endParse - startParse,
uploadSize: uploadSize
},
raw: jsonData
} as any;
} catch (aiError: any) {
console.warn('[AnalyzeBottle] AI Analysis failed, providing fallback path:', aiError.message);
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'generateContent',
success: false,
errorMessage: aiError.message
});
return {
success: false,
isAiError: true,
error: aiError.message,
imageHash: imageHash
} as any;
}
if (Array.isArray(jsonData)) jsonData = jsonData[0];
console.log('[Gemini AI] JSON Response:', jsonData);
const searchString = jsonData.search_string;
delete jsonData.search_string;
if (!jsonData) throw new Error('Keine Daten in der KI-Antwort gefunden.');
const validatedData = BottleMetadataSchema.parse(jsonData);
const endParse = performance.now();
// 6. Tracking & Credits (bleibt gleich)
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'generateContent',
success: true
});
await deductCredits(userId, 'gemini_ai', 'Bottle analysis');
// Cache speichern
const { error: storeError } = await supabase
.from('vision_cache')
.insert({ hash: imageHash, result: validatedData });
if (storeError) console.warn(`[AI Cache] Storage failed: ${storeError.message}`);
return {
success: true,
data: validatedData,
search_string: searchString,
perf: {
apiDuration: endApi - startApi,
parseDuration: endParse - startParse,
uploadSize: uploadSize
},
raw: jsonData
} as any;
} catch (error) {
console.error('Gemini Analysis Error:', error);
// Error Tracking Logic (bleibt gleich)
if (supabase) {
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
await trackApiUsage({
userId: session.user.id,
apiType: 'gemini_ai',
endpoint: 'generateContent',
success: false,
errorMessage: error instanceof Error ? error.message : 'Unknown error'
});
}
}
console.error('Gemini Analysis Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'An unknown error occurred during analysis.',

View File

@@ -8,12 +8,12 @@ export async function addBuddy(rawData: BuddyData) {
try {
const { name } = BuddySchema.parse(rawData);
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
const { data, error } = await supabase
.from('buddies')
.insert([{ name, user_id: session.user.id }])
.insert([{ name, user_id: user.id }])
.select()
.single();
@@ -32,14 +32,14 @@ export async function deleteBuddy(id: string) {
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
const { error } = await supabase
.from('buddies')
.delete()
.eq('id', id)
.eq('user_id', session.user.id);
.eq('user_id', user.id);
if (error) throw error;
return { success: true };

View File

@@ -7,8 +7,8 @@ export async function deleteBottle(bottleId: string) {
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Nicht autorisiert.');
}
@@ -23,7 +23,7 @@ export async function deleteBottle(bottleId: string) {
throw new Error('Flasche nicht gefunden.');
}
if (bottle.user_id !== session.user.id) {
if (bottle.user_id !== user.id) {
throw new Error('Keine Berechtigung.');
}

View File

@@ -7,8 +7,8 @@ export async function deleteSession(sessionId: string) {
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Nicht autorisiert.');
}
@@ -16,7 +16,7 @@ export async function deleteSession(sessionId: string) {
.from('tasting_sessions')
.delete()
.eq('id', sessionId)
.eq('user_id', session.user.id);
.eq('user_id', user.id);
if (deleteError) throw deleteError;

View File

@@ -7,8 +7,8 @@ export async function deleteTasting(tastingId: string, bottleId: string) {
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Nicht autorisiert.');
}
@@ -16,7 +16,7 @@ export async function deleteTasting(tastingId: string, bottleId: string) {
.from('tastings')
.delete()
.eq('id', tastingId)
.eq('user_id', session.user.id);
.eq('user_id', user.id);
if (deleteError) throw deleteError;

View File

@@ -7,10 +7,10 @@ export async function findMatchingBottle(metadata: BottleMetadata) {
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) return null;
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const userId = session.user.id;
const userId = user.id;
// 1. Try matching by Whiskybase ID (most reliable)
if (metadata.whiskybaseId) {

View File

@@ -14,12 +14,12 @@ export async function saveBottle(
try {
const metadata = BottleMetadataSchema.parse(rawMetadata);
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Nicht autorisiert oder Session abgelaufen.');
}
const userId = session.user.id;
const userId = user.id;
let finalImageUrl = preUploadedUrl;
// 1. Upload Image to Storage if not already uploaded
@@ -50,6 +50,26 @@ export async function saveBottle(
throw new Error('Kein Bild zum Speichern vorhanden.');
}
// 1.5 Deduplication Check
// If a bottle with the same name/distillery was created by the same user in the last 5 minutes,
// we treat it as a duplicate (likely from a race condition or double sync).
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const { data: existingBottle } = await supabase
.from('bottles')
.select('*')
.eq('user_id', userId)
.eq('name', metadata.name)
.eq('distillery', metadata.distillery)
.gte('created_at', fiveMinutesAgo)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (existingBottle) {
console.log('[saveBottle] Potential duplicate detected, returning existing bottle:', existingBottle.id);
return { success: true, data: existingBottle };
}
// 2. Save Metadata to Database
const { data: bottleData, error: dbError } = await supabase
.from('bottles')
@@ -64,7 +84,7 @@ export async function saveBottle(
image_url: finalImageUrl,
status: 'sealed',
is_whisky: metadata.is_whisky ?? true,
confidence: metadata.confidence ?? 100,
confidence: metadata.confidence ? Math.round(metadata.confidence * 100) : 100,
distilled_at: metadata.distilled_at,
bottled_at: metadata.bottled_at,
batch_info: metadata.batch_info,

View File

@@ -11,8 +11,8 @@ export async function saveTasting(rawData: TastingNoteData) {
try {
const data = TastingNoteSchema.parse(rawData);
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
// Validate Session Age (12 hour limit)
if (data.session_id) {
@@ -26,7 +26,7 @@ export async function saveTasting(rawData: TastingNoteData) {
.from('tastings')
.insert({
bottle_id: data.bottle_id,
user_id: session.user.id,
user_id: user.id,
session_id: data.session_id,
rating: data.rating,
nose_notes: data.nose_notes,
@@ -46,7 +46,7 @@ export async function saveTasting(rawData: TastingNoteData) {
const buddies = data.buddy_ids.map(buddyId => ({
tasting_id: tasting.id,
buddy_id: buddyId,
user_id: session.user.id
user_id: user.id
}));
const { error: tagError } = await supabase
.from('tasting_buddies')
@@ -64,7 +64,7 @@ export async function saveTasting(rawData: TastingNoteData) {
const aromaTags = data.tag_ids.map(tagId => ({
tasting_id: tasting.id,
tag_id: tagId,
user_id: session.user.id
user_id: user.id
}));
const { error: aromaTagError } = await supabase
.from('tasting_tags')

View File

@@ -74,8 +74,8 @@ export async function createCustomTag(rawName: string, rawCategory: TagCategory)
try {
const { name, category } = TagSchema.parse({ name: rawName, category: rawCategory });
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
const { data, error } = await supabase
.from('tags')
@@ -83,7 +83,7 @@ export async function createCustomTag(rawName: string, rawCategory: TagCategory)
name,
category,
is_system_default: false,
created_by: session.user.id
created_by: user.id
})
.select()
.single();

View File

@@ -7,8 +7,8 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o
const supabase = await createClient();
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
throw new Error('Nicht autorisiert');
}
@@ -20,7 +20,7 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o
finished_at: status === 'empty' ? new Date().toISOString() : null
})
.eq('id', bottleId)
.eq('user_id', session.user.id);
.eq('user_id', user.id);
if (error) {
throw error;

View File

@@ -10,8 +10,8 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
try {
const data = UpdateBottleSchema.parse(rawData);
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Nicht autorisiert');
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
const { error } = await supabase
.from('bottles')
@@ -29,7 +29,7 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
updated_at: new Date().toISOString(),
})
.eq('id', bottleId)
.eq('user_id', session.user.id);
.eq('user_id', user.id);
if (error) throw error;

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
export const BottleMetadataSchema = z.object({
name: z.string().trim().min(1).max(255).nullish(),
distillery: z.string().trim().max(255).nullish(),
bottler: z.string().trim().max(255).nullish(),
category: z.string().trim().max(100).nullish(),
abv: z.number().min(0).max(100).nullish(),
age: z.number().min(0).max(100).nullish(),
@@ -76,12 +77,12 @@ export type AdminSettingsData = z.infer<typeof AdminSettingsSchema>;
export const DiscoveryDataSchema = z.object({
name: z.string().trim().min(1).max(255),
distillery: z.string().trim().max(255).optional(),
abv: z.number().min(0).max(100).optional(),
age: z.number().min(0).max(100).optional(),
distilled_at: z.string().trim().max(50).optional(),
bottled_at: z.string().trim().max(50).optional(),
batch_info: z.string().trim().max(255).optional(),
distillery: z.string().trim().max(255).nullish(),
abv: z.number().min(0).max(100).nullish(),
age: z.number().min(0).max(100).nullish(),
distilled_at: z.string().trim().max(50).nullish(),
bottled_at: z.string().trim().max(50).nullish(),
batch_info: z.string().trim().max(255).nullish(),
});
export type DiscoveryData = z.infer<typeof DiscoveryDataSchema>;
@@ -96,10 +97,25 @@ export interface AnalysisResponse {
success: boolean;
data?: BottleMetadata;
error?: string;
isAiError?: boolean;
imageHash?: string;
perf?: {
apiDuration: number;
parseDuration: number;
// Legacy fields (kept for backward compatibility)
apiDuration?: number;
parseDuration?: number;
// Detailed metrics
imagePrep?: number;
cacheCheck?: number;
encoding?: number;
modelInit?: number;
apiCall?: number;
parsing?: number;
validation?: number;
dbOps?: number;
uploadSize: number;
total?: number;
cacheHit?: boolean;
};
raw?: any;
}

View File

@@ -0,0 +1,24 @@
import { BottleMetadata } from '@/types/whisky';
/**
* Generate placeholder metadata for offline scans.
* Returns editable dummy data that user can fill in manually.
*/
export function generateDummyMetadata(imageFile: File): BottleMetadata {
return {
name: '', // Empty - user must fill in
distillery: '', // Empty - user must fill in
category: 'Whisky',
is_whisky: true,
confidence: 0,
abv: null,
age: null,
vintage: null,
bottler: null,
batch_info: null,
bottleCode: null,
distilled_at: null,
bottled_at: null,
whiskybaseId: null,
};
}

View File

@@ -47,7 +47,8 @@ export async function processImageForAI(file: File): Promise<ProcessedImage> {
maxSizeMB: 0.4,
maxWidthOrHeight: 1024,
useWebWorker: true,
fileType: 'image/webp'
fileType: 'image/webp',
libURL: '/lib/browser-image-compression.js'
};
try {