feat: improve AI resilience, add background enrichment loading states, and fix duplicate identifier in TagSelector
This commit is contained in:
105
.aiideas
105
.aiideas
@@ -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:**
|
Refactor the whisky analysis logic to optimize for perceived performance using a "Optimistic UI" approach.
|
||||||
Create a utility file `src/utils/image-processing.ts`.
|
Split the current `analyzeBottle` logic into **two separate Server Actions**.
|
||||||
This file should export a function `processImageForAI` that uses the library `browser-image-compression`.
|
|
||||||
|
|
||||||
**Requirements:**
|
### 1. Create `src/app/actions/scan-label.ts` (Fast OCR)
|
||||||
1. **Input:** The function takes a raw `File` object (from an HTML input).
|
This action handles the image upload via `FormData`.
|
||||||
2. **Processing Logic:**
|
It must use the model with `safetySettings: BLOCK_NONE`.
|
||||||
- Resize the image to a maximum of **1024x1024** pixels (maintain aspect ratio).
|
It must **NOT** generate flavor tags or search strings.
|
||||||
- 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.
|
|
||||||
|
|
||||||
**Step 1:** Give me the `npm install` command to add the necessary library.
|
**System Prompt for this Action:**
|
||||||
**Step 2:** Write the complete `src/utils/image-processing.ts` code with proper JSDoc comments.
|
```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
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/google": "^2.0.51",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@mistralai/mistralai": "^1.11.0",
|
"@mistralai/mistralai": "^1.11.0",
|
||||||
"@supabase/ssr": "^0.5.2",
|
"@supabase/ssr": "^0.5.2",
|
||||||
"@supabase/supabase-js": "^2.47.10",
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
|
"ai": "^5.0.116",
|
||||||
"browser-image-compression": "^2.0.2",
|
"browser-image-compression": "^2.0.2",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
"dexie": "^4.2.1",
|
"dexie": "^4.2.1",
|
||||||
@@ -31,7 +33,8 @@
|
|||||||
"recharts": "^3.6.0",
|
"recharts": "^3.6.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8",
|
||||||
|
"zod-to-json-schema": "^3.25.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
|
|||||||
102
pnpm-lock.yaml
generated
102
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@ai-sdk/google':
|
||||||
|
specifier: ^2.0.51
|
||||||
|
version: 2.0.51(zod@3.25.76)
|
||||||
'@google/generative-ai':
|
'@google/generative-ai':
|
||||||
specifier: ^0.24.1
|
specifier: ^0.24.1
|
||||||
version: 0.24.1
|
version: 0.24.1
|
||||||
@@ -23,6 +26,9 @@ importers:
|
|||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.62.7
|
specifier: ^5.62.7
|
||||||
version: 5.90.12(react@19.2.3)
|
version: 5.90.12(react@19.2.3)
|
||||||
|
ai:
|
||||||
|
specifier: ^5.0.116
|
||||||
|
version: 5.0.116(zod@3.25.76)
|
||||||
browser-image-compression:
|
browser-image-compression:
|
||||||
specifier: ^2.0.2
|
specifier: ^2.0.2
|
||||||
version: 2.0.2
|
version: 2.0.2
|
||||||
@@ -46,7 +52,7 @@ importers:
|
|||||||
version: 0.468.0(react@19.2.3)
|
version: 0.468.0(react@19.2.3)
|
||||||
next:
|
next:
|
||||||
specifier: 16.1.0
|
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:
|
openai:
|
||||||
specifier: ^6.15.0
|
specifier: ^6.15.0
|
||||||
version: 6.15.0(ws@8.18.3)(zod@3.25.76)
|
version: 6.15.0(ws@8.18.3)(zod@3.25.76)
|
||||||
@@ -68,6 +74,9 @@ importers:
|
|||||||
zod:
|
zod:
|
||||||
specifier: ^3.23.8
|
specifier: ^3.23.8
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
|
zod-to-json-schema:
|
||||||
|
specifier: ^3.25.0
|
||||||
|
version: 3.25.0(zod@3.25.76)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.57.0
|
specifier: ^1.57.0
|
||||||
@@ -116,7 +125,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.16
|
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:
|
packages:
|
||||||
|
|
||||||
@@ -126,6 +135,28 @@ packages:
|
|||||||
'@adobe/css-tools@4.4.4':
|
'@adobe/css-tools@4.4.4':
|
||||||
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
|
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':
|
'@alloc/quick-lru@5.2.0':
|
||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -687,6 +718,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
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':
|
'@playwright/test@1.57.0':
|
||||||
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
|
resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1128,6 +1163,10 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@vercel/oidc@3.0.5':
|
||||||
|
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
|
||||||
'@vitejs/plugin-react@5.1.2':
|
'@vitejs/plugin-react@5.1.2':
|
||||||
resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
|
resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -1177,6 +1216,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
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:
|
ajv@6.12.6:
|
||||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||||
|
|
||||||
@@ -1713,6 +1758,10 @@ packages:
|
|||||||
eventemitter3@5.0.1:
|
eventemitter3@5.0.1:
|
||||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
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:
|
expect-type@1.3.0:
|
||||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -2115,6 +2164,9 @@ packages:
|
|||||||
json-schema-traverse@0.4.1:
|
json-schema-traverse@0.4.1:
|
||||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
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:
|
json-stable-stringify-without-jsonify@1.0.1:
|
||||||
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
|
||||||
|
|
||||||
@@ -3039,6 +3091,30 @@ snapshots:
|
|||||||
|
|
||||||
'@adobe/css-tools@4.4.4': {}
|
'@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': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
'@asamuzakjp/css-color@4.1.1':
|
'@asamuzakjp/css-color@4.1.1':
|
||||||
@@ -3497,6 +3573,8 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@opentelemetry/api@1.9.0': {}
|
||||||
|
|
||||||
'@playwright/test@1.57.0':
|
'@playwright/test@1.57.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.57.0
|
playwright: 1.57.0
|
||||||
@@ -3911,6 +3989,8 @@ snapshots:
|
|||||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
optional: true
|
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))':
|
'@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
@@ -3970,6 +4050,14 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@7.1.4: {}
|
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:
|
ajv@6.12.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
@@ -4690,6 +4778,8 @@ snapshots:
|
|||||||
|
|
||||||
eventemitter3@5.0.1: {}
|
eventemitter3@5.0.1: {}
|
||||||
|
|
||||||
|
eventsource-parser@3.0.6: {}
|
||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
@@ -5103,6 +5193,8 @@ snapshots:
|
|||||||
|
|
||||||
json-schema-traverse@0.4.1: {}
|
json-schema-traverse@0.4.1: {}
|
||||||
|
|
||||||
|
json-schema@0.4.0: {}
|
||||||
|
|
||||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||||
|
|
||||||
json5@1.0.2:
|
json5@1.0.2:
|
||||||
@@ -5206,7 +5298,7 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
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:
|
dependencies:
|
||||||
'@next/env': 16.1.0
|
'@next/env': 16.1.0
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
@@ -5225,6 +5317,7 @@ snapshots:
|
|||||||
'@next/swc-linux-x64-musl': 16.1.0
|
'@next/swc-linux-x64-musl': 16.1.0
|
||||||
'@next/swc-win32-arm64-msvc': 16.1.0
|
'@next/swc-win32-arm64-msvc': 16.1.0
|
||||||
'@next/swc-win32-x64-msvc': 16.1.0
|
'@next/swc-win32-x64-msvc': 16.1.0
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
'@playwright/test': 1.57.0
|
'@playwright/test': 1.57.0
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -5990,7 +6083,7 @@ snapshots:
|
|||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 1.21.7
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.0.16
|
'@vitest/expect': 4.0.16
|
||||||
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7))
|
'@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)
|
vite: 7.3.0(@types/node@20.19.27)(jiti@1.21.7)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@opentelemetry/api': 1.9.0
|
||||||
'@types/node': 20.19.27
|
'@types/node': 20.19.27
|
||||||
jsdom: 27.3.0
|
jsdom: 27.3.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
|||||||
9
public/lib/browser-image-compression.js
Normal file
9
public/lib/browser-image-compression.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = 'whisky-vault-v13-offline';
|
const CACHE_NAME = 'whisky-vault-v14-offline';
|
||||||
|
|
||||||
// CONFIG: Assets
|
// CONFIG: Assets
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
@@ -6,6 +6,7 @@ const STATIC_ASSETS = [
|
|||||||
'/icon-192.png',
|
'/icon-192.png',
|
||||||
'/icon-512.png',
|
'/icon-512.png',
|
||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
|
'/lib/browser-image-compression.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
const CORE_PAGES = [
|
const CORE_PAGES = [
|
||||||
|
|||||||
26
scripts/add-credits.sql
Normal file
26
scripts/add-credits.sql
Normal 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;
|
||||||
117
src/app/actions/enrich-data.ts
Normal file
117
src/app/actions/enrich-data.ts
Normal 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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
207
src/app/actions/scan-label.ts
Normal file
207
src/app/actions/scan-label.ts
Normal 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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,12 @@ export async function POST(req: Request) {
|
|||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
// Check session
|
// Check session
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
|
return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = session.user.id;
|
const userId = user.id;
|
||||||
const formData = await req.formData();
|
const formData = await req.formData();
|
||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de" suppressHydrationWarning={true}>
|
||||||
<body className={`${inter.variable} font-sans`}>
|
<body className={`${inter.variable} font-sans`}>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export default function Home() {
|
|||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Supabase fetch error:', error);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,8 +142,20 @@ export default function Home() {
|
|||||||
|
|
||||||
setBottles(processedBottles);
|
setBottles(processedBottles);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
// 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);
|
console.error('Detailed fetch error:', err);
|
||||||
setFetchError(err.message || JSON.stringify(err));
|
setFetchError(err.message || JSON.stringify(err));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -271,6 +282,7 @@ export default function Home() {
|
|||||||
isOpen={isFlowOpen}
|
isOpen={isFlowOpen}
|
||||||
onClose={() => setIsFlowOpen(false)}
|
onClose={() => setIsFlowOpen(false)}
|
||||||
imageFile={capturedFile}
|
imageFile={capturedFile}
|
||||||
|
onBottleSaved={() => fetchCollection()}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'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 { 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';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
@@ -8,7 +8,6 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { saveBottle } from '@/services/save-bottle';
|
import { saveBottle } from '@/services/save-bottle';
|
||||||
import { BottleMetadata } from '@/types/whisky';
|
import { BottleMetadata } from '@/types/whisky';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { findMatchingBottle } from '@/services/find-matching-bottle';
|
import { findMatchingBottle } from '@/services/find-matching-bottle';
|
||||||
import { validateSession } from '@/services/validate-session';
|
import { validateSession } from '@/services/validate-session';
|
||||||
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
|
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
|
||||||
@@ -17,8 +16,10 @@ import Link from 'next/link';
|
|||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { useSession } from '@/context/SessionContext';
|
import { useSession } from '@/context/SessionContext';
|
||||||
import { shortenCategory } from '@/lib/format';
|
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';
|
import { processImageForAI } from '@/utils/image-processing';
|
||||||
|
|
||||||
interface CameraCaptureProps {
|
interface CameraCaptureProps {
|
||||||
onImageCaptured?: (base64Image: string) => void;
|
onImageCaptured?: (base64Image: string) => void;
|
||||||
onAnalysisComplete?: (data: BottleMetadata) => void;
|
onAnalysisComplete?: (data: BottleMetadata) => void;
|
||||||
@@ -32,14 +33,12 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { activeSession } = useSession();
|
const { activeSession } = useSession();
|
||||||
|
|
||||||
// Maintain sessionId from query param for backwards compatibility,
|
|
||||||
// but prefer global activeSession
|
|
||||||
const sessionIdFromUrl = searchParams.get('session_id');
|
const sessionIdFromUrl = searchParams.get('session_id');
|
||||||
const effectiveSessionId = activeSession?.id || sessionIdFromUrl;
|
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 () => {
|
const checkSession = async () => {
|
||||||
if (effectiveSessionId) {
|
if (effectiveSessionId) {
|
||||||
const isValid = await validateSession(effectiveSessionId);
|
const isValid = await validateSession(effectiveSessionId);
|
||||||
@@ -67,14 +66,25 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini');
|
const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini');
|
||||||
|
|
||||||
// Performance Tracking (Admin only)
|
|
||||||
const [perfMetrics, setPerfMetrics] = useState<{
|
const [perfMetrics, setPerfMetrics] = useState<{
|
||||||
compression: number;
|
compression: number;
|
||||||
ai: number;
|
ai: 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);
|
} | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAdmin = async () => {
|
const checkAdmin = async () => {
|
||||||
try {
|
try {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
@@ -85,10 +95,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) {
|
if (error) console.error('[CameraCapture] Admin check error:', error);
|
||||||
console.error('[CameraCapture] Admin check error:', error);
|
|
||||||
}
|
|
||||||
console.log('[CameraCapture] Admin status:', !!data);
|
|
||||||
setIsAdmin(!!data);
|
setIsAdmin(!!data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -111,122 +118,101 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let fileToProcess = file;
|
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');
|
const isHeic = file.type === 'image/heic' || file.type === 'image/heif' || file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
|
||||||
|
|
||||||
if (isHeic) {
|
if (isHeic) {
|
||||||
console.log('HEIC detected, converting...');
|
|
||||||
const heic2any = (await import('heic2any')).default;
|
const heic2any = (await import('heic2any')).default;
|
||||||
const convertedBlob = await heic2any({
|
const convertedBlob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.8 });
|
||||||
blob: file,
|
|
||||||
toType: 'image/jpeg',
|
|
||||||
quality: 0.8
|
|
||||||
});
|
|
||||||
|
|
||||||
const blob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob;
|
const blob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob;
|
||||||
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
|
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), { type: 'image/jpeg' });
|
||||||
type: 'image/jpeg'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setOriginalFile(fileToProcess);
|
setOriginalFile(fileToProcess);
|
||||||
|
|
||||||
const startComp = performance.now();
|
const startComp = performance.now();
|
||||||
const processed = await processImageForAI(fileToProcess);
|
const processed = await processImageForAI(fileToProcess);
|
||||||
const endComp = performance.now();
|
const endComp = performance.now();
|
||||||
|
|
||||||
const compressedBase64 = processed.base64;
|
setPreviewUrl(processed.base64);
|
||||||
setPreviewUrl(compressedBase64);
|
if (onImageCaptured) onImageCaptured(processed.base64);
|
||||||
|
|
||||||
if (onImageCaptured) {
|
|
||||||
onImageCaptured(compressedBase64);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if Offline
|
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
console.log('Offline detected. Queuing image...');
|
// 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({
|
await db.pending_scans.add({
|
||||||
temp_id: crypto.randomUUID(),
|
temp_id: crypto.randomUUID(),
|
||||||
imageBase64: compressedBase64,
|
imageBase64: processed.base64,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: aiProvider,
|
provider: aiProvider,
|
||||||
locale: locale
|
locale: locale
|
||||||
});
|
});
|
||||||
|
}
|
||||||
setIsQueued(true);
|
setIsQueued(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', processed.file);
|
formData.append('file', processed.file);
|
||||||
formData.append('provider', aiProvider);
|
|
||||||
formData.append('locale', locale);
|
|
||||||
|
|
||||||
const startAi = performance.now();
|
const startAi = performance.now();
|
||||||
const response = await magicScan(formData);
|
const response = await scanLabel(formData);
|
||||||
const endAi = performance.now();
|
const endAi = performance.now();
|
||||||
|
|
||||||
const startPrep = performance.now();
|
const startPrep = performance.now();
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setAnalysisResult(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);
|
const match = await findMatchingBottle(response.data);
|
||||||
if (match) {
|
if (match) setMatchingBottle(match);
|
||||||
setMatchingBottle(match);
|
if (onAnalysisComplete) onAnalysisComplete(response.data);
|
||||||
}
|
|
||||||
|
|
||||||
if (onAnalysisComplete) {
|
|
||||||
onAnalysisComplete(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const endPrep = performance.now();
|
const endPrep = performance.now();
|
||||||
|
if (isAdmin && response.perf) {
|
||||||
if (isAdmin) {
|
|
||||||
setPerfMetrics({
|
setPerfMetrics({
|
||||||
compression: endComp - startComp,
|
compression: endComp - startComp,
|
||||||
ai: endAi - startAi,
|
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) {
|
if (response.data.is_whisky && response.data.name && response.data.distillery) {
|
||||||
console.log('Network issue detected during scan. Queuing...');
|
enrichData(response.data.name, response.data.distillery, undefined, locale)
|
||||||
await db.pending_scans.add({
|
.then(enrichResult => {
|
||||||
temp_id: crypto.randomUUID(),
|
if (enrichResult.success && enrichResult.data) {
|
||||||
imageBase64: compressedBase64,
|
setAnalysisResult(prev => {
|
||||||
timestamp: Date.now(),
|
if (!prev) return prev;
|
||||||
provider: aiProvider,
|
return {
|
||||||
locale: locale
|
...prev,
|
||||||
|
suggested_tags: enrichResult.data.suggested_tags,
|
||||||
|
suggested_custom_tags: enrichResult.data.suggested_custom_tags,
|
||||||
|
search_string: enrichResult.data.search_string
|
||||||
|
};
|
||||||
});
|
});
|
||||||
setIsQueued(true);
|
}
|
||||||
setError(null); // Clear error as we are queuing
|
})
|
||||||
|
.catch(err => console.error('[CameraCapture] Enrichment failed:', err));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(response.error || t('camera.analysisError'));
|
throw new Error(t('camera.analysisError'));
|
||||||
}
|
}
|
||||||
}
|
} catch (err: any) {
|
||||||
} catch (err) {
|
|
||||||
console.error('Processing failed:', err);
|
console.error('Processing failed:', err);
|
||||||
// Even on generic error, if we have a compressed image, consider queuing if it looks like connection
|
setError(err.message || t('camera.processingError'));
|
||||||
if (previewUrl && !analysisResult) {
|
|
||||||
setError(t('camera.processingError') + " - " + t('camera.offlineNotice'));
|
|
||||||
} else {
|
|
||||||
setError(t('camera.processingError'));
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
@@ -234,28 +220,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
|
|
||||||
const handleQuickSave = async () => {
|
const handleQuickSave = async () => {
|
||||||
if (!analysisResult || !previewUrl) return;
|
if (!analysisResult || !previewUrl) return;
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
const { data: { user } = {} } = await supabase.auth.getUser();
|
||||||
if (!user) {
|
if (!user) throw new Error(t('camera.authRequired'));
|
||||||
throw new Error(t('camera.authRequired'));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
||||||
router.push(url);
|
router.push(url);
|
||||||
} else {
|
} else {
|
||||||
setError(response.error || t('common.error'));
|
setError(response.error || t('common.error'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('Quick save failed:', err);
|
setError(err.message || t('common.error'));
|
||||||
setError(err instanceof Error ? err.message : t('common.error'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -263,28 +241,20 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!analysisResult || !previewUrl) return;
|
if (!analysisResult || !previewUrl) return;
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
const { data: { user } = {} } = await supabase.auth.getUser();
|
||||||
if (!user) {
|
if (!user) throw new Error(t('camera.authRequired'));
|
||||||
throw new Error(t('camera.authRequired'));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
const response = await saveBottle(analysisResult, previewUrl, user.id);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setLastSavedId(response.data.id);
|
setLastSavedId(response.data.id);
|
||||||
if (onSaveComplete) onSaveComplete();
|
if (onSaveComplete) onSaveComplete();
|
||||||
} else {
|
} else {
|
||||||
setError(response.error || t('common.error'));
|
setError(response.error || t('common.error'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('Save failed:', err);
|
setError(err.message || t('common.error'));
|
||||||
setError(err instanceof Error ? err.message : t('common.error'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -299,7 +269,6 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
abv: analysisResult.abv || undefined,
|
abv: analysisResult.abv || undefined,
|
||||||
age: analysisResult.age || undefined
|
age: analysisResult.age || undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success && result.id) {
|
if (result.success && result.id) {
|
||||||
setWbDiscovery({ id: result.id, url: result.url!, title: result.title! });
|
setWbDiscovery({ id: result.id, url: result.url!, title: result.title! });
|
||||||
}
|
}
|
||||||
@@ -308,30 +277,18 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
|
|
||||||
const handleLinkWb = async () => {
|
const handleLinkWb = async () => {
|
||||||
if (!lastSavedId || !wbDiscovery) return;
|
if (!lastSavedId || !wbDiscovery) return;
|
||||||
const res = await updateBottle(lastSavedId, {
|
const res = await updateBottle(lastSavedId, { whiskybase_id: wbDiscovery.id });
|
||||||
whiskybase_id: wbDiscovery.id
|
if (res.success) setWbDiscovery(null);
|
||||||
});
|
|
||||||
if (res.success) {
|
|
||||||
setWbDiscovery(null);
|
|
||||||
// Show some success feedback if needed, but the button will disappear anyway
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const triggerUpload = () => fileInputRef.current?.click();
|
||||||
const triggerUpload = () => {
|
const triggerGallery = () => galleryInputRef.current?.click();
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerGallery = () => {
|
|
||||||
galleryInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
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 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 flex-col w-full gap-1">
|
||||||
<div className="flex items-center justify-between w-full">
|
<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>
|
<h2 className="text-xl md:text-2xl font-bold text-zinc-100 italic">{t('camera.magicShot')}</h2>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex items-center gap-1 bg-zinc-800 p-1 rounded-xl border border-zinc-700">
|
<div className="flex items-center gap-1 bg-zinc-800 p-1 rounded-xl border border-zinc-700">
|
||||||
<button
|
<button
|
||||||
@@ -414,22 +371,8 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input type="file" accept="image/*" capture="environment" ref={fileInputRef} onChange={handleCapture} className="hidden" />
|
||||||
type="file"
|
<input type="file" accept="image/*" ref={galleryInputRef} onChange={handleCapture} className="hidden" />
|
||||||
accept="image/*"
|
|
||||||
capture="environment"
|
|
||||||
ref={fileInputRef}
|
|
||||||
onChange={handleCapture}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
ref={galleryInputRef}
|
|
||||||
onChange={handleCapture}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{lastSavedId ? (
|
{lastSavedId ? (
|
||||||
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
|
<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" />
|
<CheckCircle2 size={24} className="text-green-500" />
|
||||||
{t('camera.saveSuccess')}
|
{t('camera.saveSuccess')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = `/bottles/${lastSavedId}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
const url = `/bottles/${lastSavedId}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
||||||
@@ -448,258 +390,145 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
{t('camera.tastingNow')}
|
{t('camera.tastingNow')}
|
||||||
<ChevronRight size={20} />
|
<ChevronRight size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!wbDiscovery && !isDiscovering && (
|
{!wbDiscovery && !isDiscovering && (
|
||||||
<button
|
<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">
|
||||||
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} />
|
<Search size={16} />
|
||||||
{t('camera.whiskybaseSearch')}
|
{t('camera.whiskybaseSearch')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isDiscovering && (
|
{isDiscovering && (
|
||||||
<div className="w-full py-3 px-6 text-zinc-400 font-bold flex items-center justify-center gap-2 text-sm italic">
|
<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" />
|
<Loader2 size={16} className="animate-spin" />
|
||||||
{t('camera.searchingWb')}
|
{t('camera.searchingWb')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{wbDiscovery && (
|
{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="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">
|
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-orange-600">
|
||||||
<Sparkles size={12} /> {t('camera.wbMatchFound')}
|
<Sparkles size={12} /> {t('camera.wbMatchFound')}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-bold text-zinc-200 line-clamp-2 leading-snug">
|
<p className="text-xs font-bold text-zinc-200 line-clamp-2 leading-snug">{wbDiscovery.title}</p>
|
||||||
{wbDiscovery.title}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<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>
|
||||||
onClick={handleLinkWb}
|
<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">
|
||||||
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')}
|
<ExternalLink size={12} /> {t('common.check')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
) : matchingBottle ? (
|
) : matchingBottle ? (
|
||||||
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
|
<div className="flex flex-col gap-3 w-full animate-in zoom-in-95 duration-300">
|
||||||
<Link
|
<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">
|
||||||
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} />
|
<ExternalLink size={20} />
|
||||||
{t('camera.toVault')}
|
{t('camera.toVault')}
|
||||||
</Link>
|
</Link>
|
||||||
<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>
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isQueued) {
|
if (isQueued) setPreviewUrl(null);
|
||||||
setPreviewUrl(null);
|
else if (previewUrl && analysisResult) validatedSessionId ? handleQuickSave() : handleSave();
|
||||||
} else if (previewUrl && analysisResult) {
|
else triggerUpload();
|
||||||
if (validatedSessionId) {
|
|
||||||
handleQuickSave();
|
|
||||||
} else {
|
|
||||||
handleSave();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
triggerUpload();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={isProcessing || isSaving}
|
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'}`}
|
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 ? (
|
{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 ? (
|
) : isQueued ? (
|
||||||
<>
|
<><CheckCircle2 size={20} />{t('camera.nextBottle')}</>
|
||||||
<CheckCircle2 size={20} />
|
|
||||||
{t('camera.nextBottle')}
|
|
||||||
</>
|
|
||||||
) : previewUrl && analysisResult ? (
|
) : previewUrl && analysisResult ? (
|
||||||
validatedSessionId ? (
|
validatedSessionId ? <><Droplets size={20} className="text-orange-500" />{t('camera.quickTasting')}</> : <><CheckCircle2 size={20} />{t('camera.inVault')}</>
|
||||||
<>
|
|
||||||
<Droplets size={20} className="text-orange-500" />
|
|
||||||
{t('camera.quickTasting')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 size={20} />
|
|
||||||
{t('camera.inVault')}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
) : previewUrl ? (
|
) : 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>
|
</button>
|
||||||
|
|
||||||
{!previewUrl && !isProcessing && (
|
{!previewUrl && !isProcessing && (
|
||||||
<button
|
<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">
|
||||||
onClick={triggerGallery}
|
<Upload size={18} />{t('camera.uploadGallery')}
|
||||||
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status Messages */}
|
|
||||||
{error && (
|
{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">
|
<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} />
|
<AlertCircle size={16} />{error}
|
||||||
{error}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isQueued && (
|
{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 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="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">
|
<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>
|
||||||
<Sparkles size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
<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-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>
|
<span className="text-[10px] font-bold text-purple-600 dark:text-purple-400 uppercase tracking-widest">Warteschlange aktiv</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-relaxed">
|
<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>
|
||||||
Keine Sorge, dein Scan wurde sicher im Vault gespeichert. Sobald du wieder Empfang hast, wird die Analyse automatisch im Hintergrund gestartet.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{matchingBottle && !lastSavedId && (
|
{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 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">
|
<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>
|
||||||
<AlertCircle size={16} />
|
<p className="text-xs text-blue-500/80">{t('camera.alreadyInVaultDesc')}</p>
|
||||||
{t('camera.alreadyInVault')}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-blue-500/80">
|
|
||||||
{t('camera.alreadyInVaultDesc')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Analysis Results Display */}
|
|
||||||
{previewUrl && !isProcessing && !error && !isQueued && !matchingBottle && analysisResult && (
|
{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 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">
|
<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} />
|
<CheckCircle2 size={16} />{t('camera.analysisSuccess')}
|
||||||
{t('camera.analysisSuccess')}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 md:p-4 bg-zinc-950 rounded-2xl border border-zinc-800">
|
<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">
|
<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>
|
||||||
<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="space-y-2">
|
||||||
<div className="flex justify-between items-center text-sm">
|
<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>
|
||||||
<span className="text-zinc-500">{t('bottle.nameLabel')}:</span>
|
<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>
|
||||||
<span className="font-semibold text-right text-zinc-100">{analysisResult.name || '-'}</span>
|
<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>
|
<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>
|
||||||
<div className="flex justify-between items-center text-sm">
|
{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>}
|
||||||
<span className="text-zinc-500">{t('bottle.distilleryLabel')}:</span>
|
{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>}
|
||||||
<span className="font-semibold text-right">{analysisResult.distillery || '-'}</span>
|
{analysisResult.bottler && <div className="flex justify-between text-sm"><span className="text-zinc-500">Bottler:</span><span className="font-semibold">{analysisResult.bottler}</span></div>}
|
||||||
</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>}
|
||||||
<div className="flex justify-between items-center text-sm">
|
{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>}
|
||||||
<span className="text-zinc-500">{t('bottle.categoryLabel')}:</span>
|
{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>}
|
||||||
<span className="font-semibold text-right">{shortenCategory(analysisResult.category || '-')}</span>
|
{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>}
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isAdmin && perfMetrics && (
|
{isAdmin && perfMetrics && (
|
||||||
<div className="pt-4 mt-2 border-t border-zinc-900/50 space-y-1">
|
<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">
|
<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>
|
||||||
<Clock size={10} /> Performance Data
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px]">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[10px]">
|
||||||
<div className="flex justify-between">
|
<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>
|
||||||
<span className="text-zinc-600">Comp:</span>
|
<div className="flex justify-between"><span className="text-zinc-600">({(perfMetrics.uploadSize / 1024).toFixed(0)}KB)</span></div>
|
||||||
<span className="text-zinc-400 font-mono">{perfMetrics.compression.toFixed(0)}ms</span>
|
|
||||||
|
{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>
|
||||||
<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 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>
|
</div>
|
||||||
<div className="flex justify-between">
|
{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>}
|
||||||
<span className="text-zinc-600">Prep:</span>
|
{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>}
|
||||||
<span className="text-zinc-400 font-mono">{perfMetrics.prep.toFixed(0)}ms</span>
|
{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>
|
<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>
|
||||||
<div className="flex justify-between">
|
{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>}
|
||||||
<span className="text-zinc-600">Total:</span>
|
{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>}
|
||||||
<span className="text-orange-600 font-mono font-bold">{(perfMetrics.compression + perfMetrics.ai + perfMetrics.prep).toFixed(0)}ms</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ import TastingEditor from './TastingEditor';
|
|||||||
import SessionBottomSheet from './SessionBottomSheet';
|
import SessionBottomSheet from './SessionBottomSheet';
|
||||||
import ResultCard from './ResultCard';
|
import ResultCard from './ResultCard';
|
||||||
import { useSession } from '@/context/SessionContext';
|
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 { saveBottle } from '@/services/save-bottle';
|
||||||
import { saveTasting } from '@/services/save-tasting';
|
import { saveTasting } from '@/services/save-tasting';
|
||||||
import { BottleMetadata } from '@/types/whisky';
|
import { BottleMetadata } from '@/types/whisky';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { processImageForAI, ProcessedImage } from '@/utils/image-processing';
|
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';
|
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
|
||||||
|
|
||||||
@@ -21,9 +25,10 @@ interface ScanAndTasteFlowProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
imageFile: File | null;
|
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 [state, setState] = useState<FlowState>('IDLE');
|
||||||
const [isSessionsOpen, setIsSessionsOpen] = useState(false);
|
const [isSessionsOpen, setIsSessionsOpen] = useState(false);
|
||||||
const { activeSession } = useSession();
|
const { activeSession } = useSession();
|
||||||
@@ -35,13 +40,23 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
|||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||||
const [perfMetrics, setPerfMetrics] = useState<{
|
const [perfMetrics, setPerfMetrics] = useState<{
|
||||||
comp: number;
|
comp: number;
|
||||||
aiTotal: number;
|
aiTotal: number;
|
||||||
aiApi: number;
|
aiApi: number;
|
||||||
aiParse: number;
|
aiParse: number;
|
||||||
uploadSize: 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);
|
} | null>(null);
|
||||||
|
|
||||||
// Admin Check
|
// Admin Check
|
||||||
@@ -57,7 +72,6 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
|||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (error) console.error('[ScanFlow] Admin check error:', error);
|
if (error) console.error('[ScanFlow] Admin check error:', error);
|
||||||
console.log('[ScanFlow] Admin status:', !!data);
|
|
||||||
setIsAdmin(!!data);
|
setIsAdmin(!!data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -67,10 +81,13 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
|||||||
checkAdmin();
|
checkAdmin();
|
||||||
}, [supabase]);
|
}, [supabase]);
|
||||||
|
|
||||||
|
const [aiFallbackActive, setAiFallbackActive] = useState(false);
|
||||||
|
const [isEnriching, setIsEnriching] = useState(false);
|
||||||
|
|
||||||
// Trigger scan when open and image provided
|
// Trigger scan when open and image provided
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && imageFile) {
|
if (isOpen && imageFile) {
|
||||||
console.log('[ScanFlow] Starting handleScan...');
|
setAiFallbackActive(false);
|
||||||
handleScan(imageFile);
|
handleScan(imageFile);
|
||||||
} else if (!isOpen) {
|
} else if (!isOpen) {
|
||||||
setState('IDLE');
|
setState('IDLE');
|
||||||
@@ -79,59 +96,143 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
|||||||
setProcessedImage(null);
|
setProcessedImage(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
setAiFallbackActive(false);
|
||||||
}
|
}
|
||||||
}, [isOpen, imageFile]);
|
}, [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) => {
|
const handleScan = async (file: File) => {
|
||||||
setState('SCANNING');
|
setState('SCANNING');
|
||||||
setError(null);
|
setError(null);
|
||||||
setPerfMetrics(null);
|
setPerfMetrics(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[ScanFlow] Starting image processing...');
|
|
||||||
const startComp = performance.now();
|
const startComp = performance.now();
|
||||||
const processed = await processImageForAI(file);
|
const processed = await processImageForAI(file);
|
||||||
const endComp = performance.now();
|
const endComp = performance.now();
|
||||||
setProcessedImage(processed);
|
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();
|
const formData = new FormData();
|
||||||
formData.append('file', processed.file);
|
formData.append('file', processed.file);
|
||||||
formData.append('provider', 'gemini');
|
|
||||||
formData.append('locale', locale);
|
|
||||||
|
|
||||||
const startAi = performance.now();
|
const startAi = performance.now();
|
||||||
const result = await magicScan(formData);
|
const result = await scanLabel(formData);
|
||||||
const endAi = performance.now();
|
const endAi = performance.now();
|
||||||
|
|
||||||
const startPrep = performance.now();
|
const startPrep = performance.now();
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
console.log('[ScanFlow] magicScan success');
|
|
||||||
if (result.raw) {
|
|
||||||
console.log('[ScanFlow] RAW AI RESPONSE:', result.raw);
|
|
||||||
}
|
|
||||||
setBottleMetadata(result.data);
|
setBottleMetadata(result.data);
|
||||||
|
|
||||||
const endPrep = performance.now();
|
const endPrep = performance.now();
|
||||||
if (isAdmin) {
|
if (isAdmin && result.perf) {
|
||||||
setPerfMetrics({
|
setPerfMetrics({
|
||||||
comp: endComp - startComp,
|
comp: endComp - startComp,
|
||||||
aiTotal: endAi - startAi,
|
aiTotal: endAi - startAi,
|
||||||
aiApi: result.perf?.apiDuration || 0,
|
aiApi: result.perf.apiCall || result.perf.apiDuration || 0,
|
||||||
aiParse: result.perf?.parseDuration || 0,
|
aiParse: result.perf.parsing || result.perf.parseDuration || 0,
|
||||||
uploadSize: result.perf?.uploadSize || 0,
|
uploadSize: result.perf.uploadSize || 0,
|
||||||
prep: endPrep - startPrep
|
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');
|
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 {
|
} else {
|
||||||
console.error('[ScanFlow] magicScan failure:', result.error);
|
console.warn('[ScanFlow] Enrichment result unsuccessful:', enrichResult.error);
|
||||||
throw new Error(result.error || 'Flasche konnte nicht erkannt werden.');
|
}
|
||||||
|
})
|
||||||
|
.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 {
|
||||||
|
throw new Error(result.error || 'Fehler bei der Analyse.');
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[ScanFlow] handleScan error:', err);
|
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);
|
setError(err.message);
|
||||||
setState('ERROR');
|
setState('ERROR');
|
||||||
}
|
}
|
||||||
@@ -144,11 +245,119 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { user } = {} } = await supabase.auth.getUser();
|
// OFFLINE: Save to IndexedDB queue (skip auth check)
|
||||||
if (!user) throw new Error('Nicht autorisiert');
|
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
|
// Check for existing pending scan with same image to prevent duplicates
|
||||||
const bottleResult = await saveBottle(bottleMetadata, processedImage.base64, user.id);
|
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) {
|
if (!bottleResult.success || !bottleResult.data) {
|
||||||
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
|
||||||
}
|
}
|
||||||
@@ -168,6 +377,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
|||||||
|
|
||||||
setTastingData(tastingNote);
|
setTastingData(tastingNote);
|
||||||
setState('RESULT');
|
setState('RESULT');
|
||||||
|
|
||||||
|
// Trigger bottle list refresh in parent
|
||||||
|
if (onBottleSaved) {
|
||||||
|
onBottleSaved(bottleId);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setState('ERROR');
|
setState('ERROR');
|
||||||
@@ -250,7 +464,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-500 mb-2 uppercase tracking-widest text-[8px]">AI Engine</p>
|
<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">
|
<div className="flex flex-col items-center">
|
||||||
<p className="text-green-500 font-bold tracking-tighter">CACHE HIT</p>
|
<p className="text-green-500 font-bold tracking-tighter">CACHE HIT</p>
|
||||||
<p className="text-[7px] opacity-40 mt-1">DB RESULTS</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>
|
<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">
|
<div className="flex flex-col gap-0.5 mt-1 text-[7px] opacity-60">
|
||||||
<span>API: {perfMetrics.aiApi.toFixed(0)}ms</span>
|
{perfMetrics.imagePrep !== undefined && <span>Prep: {perfMetrics.imagePrep.toFixed(0)}ms</span>}
|
||||||
<span>Parse: {perfMetrics.aiParse.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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -307,6 +525,18 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
|||||||
exit={{ y: -50, opacity: 0 }}
|
exit={{ y: -50, opacity: 0 }}
|
||||||
className="flex-1 w-full h-full flex flex-col min-h-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
|
<TastingEditor
|
||||||
bottleMetadata={bottleMetadata}
|
bottleMetadata={bottleMetadata}
|
||||||
image={processedImage?.base64 || null}
|
image={processedImage?.base64 || null}
|
||||||
@@ -314,6 +544,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile }: ScanAnd
|
|||||||
onOpenSessions={() => setIsSessionsOpen(true)}
|
onOpenSessions={() => setIsSessionsOpen(true)}
|
||||||
activeSessionName={activeSession?.name}
|
activeSessionName={activeSession?.name}
|
||||||
activeSessionId={activeSession?.id}
|
activeSessionId={activeSession?.id}
|
||||||
|
isEnriching={isEnriching}
|
||||||
/>
|
/>
|
||||||
{isAdmin && perfMetrics && (
|
{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">
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-zinc-500">AI:</span>
|
<span className="text-zinc-500">AI:</span>
|
||||||
{perfMetrics.aiApi === 0 ? (
|
{perfMetrics.cacheHit ? (
|
||||||
<span className="text-green-500 font-bold tracking-tight">CACHE HIT ⚡</span>
|
<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-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>
|
</div>
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ interface TagSelectorProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
suggestedTagNames?: string[];
|
suggestedTagNames?: string[];
|
||||||
suggestedCustomTagNames?: 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 { t } = useI18n();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
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 filteredTags = useMemo(() => {
|
||||||
const tagList = tags || [];
|
const tagList = tags || [];
|
||||||
@@ -57,9 +57,9 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
|||||||
const selectedTags = (tags || []).filter(t => selectedTagIds.includes(t.id));
|
const selectedTags = (tags || []).filter(t => selectedTagIds.includes(t.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{label && (
|
{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 */}
|
{/* Selected Tags */}
|
||||||
@@ -70,36 +70,36 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
|||||||
key={tag.id}
|
key={tag.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleTag(tag.id)}
|
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}
|
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||||
<X size={12} />
|
<X size={12} strokeWidth={3} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Search and Suggest */}
|
{/* Search and Suggest */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="relative flex items-center">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Tag suchen oder hinzufügen..."
|
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 && (
|
{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>
|
</div>
|
||||||
|
|
||||||
{search && (
|
{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="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-48 overflow-y-auto">
|
<div className="max-h-60 overflow-y-auto">
|
||||||
{filteredTags.length > 0 ? (
|
{filteredTags.length > 0 ? (
|
||||||
filteredTags.map(tag => (
|
filteredTags.map(tag => (
|
||||||
<button
|
<button
|
||||||
@@ -109,19 +109,19 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
|||||||
onToggleTag(tag.id);
|
onToggleTag(tag.id);
|
||||||
setSearch('');
|
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}
|
{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>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCreateTag}
|
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
|
"{search}" als neuen Tag hinzufügen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -131,36 +131,43 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI Suggestions */}
|
{/* AI Suggestions */}
|
||||||
{!search && suggestedTagNames && suggestedTagNames.length > 0 && (
|
{!search && (isLoading || (suggestedTagNames && suggestedTagNames.length > 0)) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 py-1">
|
||||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-widest text-orange-500">
|
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-[0.2em] text-orange-600">
|
||||||
<Sparkles size={10} /> {t('camera.wbMatchFound') ? 'KI Vorschläge' : 'AI Suggestions'}
|
{isLoading ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} className="fill-orange-600/20" />}
|
||||||
|
{isLoading ? 'Analysiere Aromen...' : 'KI Vorschläge'}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(tags || [])
|
{isLoading ? (
|
||||||
.filter(t => !selectedTagIds.includes(t.id) && suggestedTagNames.some((s: string) => s.toLowerCase() === t.name.toLowerCase()))
|
[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 => (
|
.map(tag => (
|
||||||
<button
|
<button
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleTag(tag.id)}
|
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"
|
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} />
|
<Sparkles size={10} />
|
||||||
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Custom Suggestions */}
|
{/* AI Custom Suggestions */}
|
||||||
{!search && suggestedCustomTagNames && suggestedCustomTagNames.length > 0 && (
|
{!search && suggestedCustomTagNames && suggestedCustomTagNames.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 py-1">
|
||||||
<div className="flex items-center gap-1.5 text-[9px] font-bold uppercase tracking-widest text-zinc-500">
|
<div className="flex items-center gap-1.5 text-[9px] font-black uppercase tracking-[0.2em] text-zinc-500">
|
||||||
Dominante Note anlegen?
|
Dominante Note anlegen?
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-2">
|
||||||
{suggestedCustomTagNames
|
{suggestedCustomTagNames
|
||||||
.filter(name => !(tags || []).some(t => t.name.toLowerCase() === name.toLowerCase()))
|
.filter(name => !(tags || []).some(t => t.name.toLowerCase() === name.toLowerCase()))
|
||||||
.map(name => (
|
.map(name => (
|
||||||
@@ -177,7 +184,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
|||||||
}
|
}
|
||||||
setCreatingSuggestion(null);
|
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} />}
|
{creatingSuggestion === name ? <Loader2 size={10} className="animate-spin" /> : <Plus size={10} />}
|
||||||
{name}
|
{name}
|
||||||
@@ -187,7 +194,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Suggestions Chips (limit to 6 random or most common) */}
|
{/* Suggestions Chips */}
|
||||||
{!search && (tags || []).length > 0 && (
|
{!search && (tags || []).length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
{(tags || [])
|
{(tags || [])
|
||||||
@@ -198,7 +205,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
|
|||||||
key={tag.id}
|
key={tag.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleTag(tag.id)}
|
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}
|
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
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 { BottleMetadata } from '@/types/whisky';
|
||||||
import TagSelector from './TagSelector';
|
import TagSelector from './TagSelector';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
|
||||||
|
|
||||||
interface TastingEditorProps {
|
interface TastingEditorProps {
|
||||||
bottleMetadata: BottleMetadata;
|
bottleMetadata: BottleMetadata;
|
||||||
@@ -17,9 +18,10 @@ interface TastingEditorProps {
|
|||||||
onOpenSessions: () => void;
|
onOpenSessions: () => void;
|
||||||
activeSessionName?: string;
|
activeSessionName?: string;
|
||||||
activeSessionId?: 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 { t } = useI18n();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const [rating, setRating] = useState(85);
|
const [rating, setRating] = useState(85);
|
||||||
@@ -38,6 +40,27 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
|
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
|
||||||
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
|
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
|
||||||
const [finishTagIds, setFinishTagIds] = 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 [textureTagIds, setTextureTagIds] = useState<string[]>([]);
|
||||||
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
|
||||||
|
|
||||||
@@ -100,6 +123,42 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
}
|
}
|
||||||
}, [lastDramInSession]);
|
}, [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) => {
|
const toggleBuddy = (id: string) => {
|
||||||
setSelectedBuddyIds(prev => prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]);
|
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,
|
taste: tasteScore,
|
||||||
finish: finishScore,
|
finish: finishScore,
|
||||||
complexity: complexityScore,
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col w-full bg-zinc-950 h-full overflow-hidden">
|
<div className="flex-1 flex flex-col w-full bg-zinc-950 h-full overflow-hidden">
|
||||||
{/* Top Context Bar - Flex Child 1 */}
|
{/* Main Scrollable Content */}
|
||||||
<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 */}
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
<div className="max-w-2xl mx-auto px-6 py-12 space-y-12">
|
<div className="max-w-2xl mx-auto px-6 py-12 space-y-12">
|
||||||
{/* Palette Warning */}
|
{/* Palette Warning */}
|
||||||
@@ -170,15 +231,254 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-3xl font-bold text-orange-600 mb-1 truncate leading-none uppercase tracking-tight">
|
<h1 className="text-3xl font-bold text-orange-600 mb-1 truncate leading-none uppercase tracking-tight">
|
||||||
{bottleMetadata.distillery || 'Destillerie'}
|
{bottleDistillery || 'Destillerie'}
|
||||||
</h1>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* 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="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">
|
<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])}
|
onToggleTag={(id) => setNoseTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
|
||||||
suggestedTagNames={suggestedTags}
|
suggestedTagNames={suggestedTags}
|
||||||
suggestedCustomTagNames={suggestedCustomTags}
|
suggestedCustomTagNames={suggestedCustomTags}
|
||||||
|
isLoading={isEnriching}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<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])}
|
onToggleTag={(id) => setPalateTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
|
||||||
suggestedTagNames={suggestedTags}
|
suggestedTagNames={suggestedTags}
|
||||||
suggestedCustomTagNames={suggestedCustomTags}
|
suggestedCustomTagNames={suggestedCustomTags}
|
||||||
|
isLoading={isEnriching}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -383,13 +685,14 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sticky Footer - Flex Child 3 */}
|
{/* Fixed/Sticky Footer for Save Action */}
|
||||||
<div className="w-full p-8 bg-zinc-950 border-t border-zinc-800 shrink-0">
|
<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">
|
<div className="max-w-2xl mx-auto">
|
||||||
<button
|
<button
|
||||||
onClick={handleInternalSave}
|
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"
|
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} />
|
<Send size={20} />
|
||||||
{t('tasting.saveTasting')}
|
{t('tasting.saveTasting')}
|
||||||
@@ -398,7 +701,6 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -439,10 +439,12 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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"
|
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} /> : (
|
{loading ? <Loader2 className="animate-spin" size={18} /> : (
|
||||||
<>
|
<>
|
||||||
@@ -451,6 +453,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,31 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db, PendingScan, PendingTasting } from '@/lib/db';
|
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 { saveBottle } from '@/services/save-bottle';
|
||||||
import { saveTasting } from '@/services/save-tasting';
|
import { saveTasting } from '@/services/save-tasting';
|
||||||
|
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info, Send } from 'lucide-react';
|
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info, Send } from 'lucide-react';
|
||||||
import TastingNoteForm from './TastingNoteForm';
|
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() {
|
export default function UploadQueue() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
@@ -23,9 +41,12 @@ export default function UploadQueue() {
|
|||||||
|
|
||||||
const totalInQueue = pendingScans.length + pendingTastings.length;
|
const totalInQueue = pendingScans.length + pendingTastings.length;
|
||||||
|
|
||||||
const syncQueue = useCallback(async () => {
|
const syncInProgress = React.useRef(false);
|
||||||
if (isSyncing || !navigator.onLine || totalInQueue === 0) return;
|
|
||||||
|
|
||||||
|
const syncQueue = useCallback(async () => {
|
||||||
|
if (syncInProgress.current || !navigator.onLine) return;
|
||||||
|
|
||||||
|
syncInProgress.current = true;
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
@@ -36,16 +57,78 @@ export default function UploadQueue() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Sync Scans (Magic Shots)
|
// 1. Sync Scans (Magic Shots) - Two-Step Flow
|
||||||
for (const item of pendingScans) {
|
// 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}`;
|
const itemId = `scan-${item.id}`;
|
||||||
setCurrentProgress({ id: itemId, status: 'Analysiere Scan...' });
|
setCurrentProgress({ id: itemId, status: 'OCR Analyse...' });
|
||||||
try {
|
try {
|
||||||
const analysis = await magicScan(item.imageBase64, item.provider, item.locale);
|
let bottleData;
|
||||||
if (analysis.success && analysis.data) {
|
|
||||||
const bottleData = analysis.data;
|
// 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);
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(ocrResult.error || 'Analyse fehlgeschlagen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Save bottle with all data
|
||||||
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
|
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
|
||||||
const save = await saveBottle(bottleData, item.imageBase64, user.id);
|
const save = await saveBottle(bottleData, item.imageBase64, user.id);
|
||||||
|
|
||||||
if (save.success && save.data) {
|
if (save.success && save.data) {
|
||||||
const newBottleId = save.data.id;
|
const newBottleId = save.data.id;
|
||||||
|
|
||||||
@@ -72,25 +155,41 @@ export default function UploadQueue() {
|
|||||||
}]);
|
}]);
|
||||||
await db.pending_scans.delete(item.id!);
|
await db.pending_scans.delete(item.id!);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
throw new Error(analysis.error || 'Analyse fehlgeschlagen');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||||
console.error('Scan sync failed:', err);
|
console.error('Scan sync failed:', err);
|
||||||
setCurrentProgress({ id: itemId, status: 'Fehler bei Scan' });
|
setCurrentProgress({ id: itemId, status: `Fehler: ${errorMessage.substring(0, 20)}...` });
|
||||||
// Wait a bit before next
|
// Unmark as syncing on failure, update attempts and timestamp for backoff
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
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
|
// 2. Sync Tastings
|
||||||
for (const item of pendingTastings) {
|
const tastingsToSync = await db.transaction('rw', db.pending_tastings, async () => {
|
||||||
// If it still has a pending_bottle_id, it means the scan hasn't synced yet.
|
const all = await db.pending_tastings.toArray();
|
||||||
// We SKIP this tasting and wait for the scan to finish in a future loop.
|
const now = Date.now();
|
||||||
if (item.pending_bottle_id) {
|
const available = all.filter(i => {
|
||||||
continue;
|
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}`;
|
const itemId = `tasting-${item.id}`;
|
||||||
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
|
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
|
||||||
try {
|
try {
|
||||||
@@ -112,36 +211,61 @@ export default function UploadQueue() {
|
|||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||||
console.error('Tasting sync failed:', err);
|
console.error('Tasting sync failed:', err);
|
||||||
setCurrentProgress({ id: itemId, status: 'Fehler bei Tasting' });
|
setCurrentProgress({ id: itemId, status: `Fehler: ${errorMessage.substring(0, 20)}...` });
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
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) {
|
} catch (err) {
|
||||||
console.error('Global Sync Error:', err);
|
console.error('Global Sync Error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
syncInProgress.current = false;
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
setCurrentProgress(null);
|
setCurrentProgress(null);
|
||||||
}
|
}
|
||||||
}, [isSyncing, pendingScans, pendingTastings, totalInQueue, supabase]);
|
}, [supabase]); // Removed pendingScans, pendingTastings, totalInQueue, isSyncing
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
console.log('Online! Waiting 2s for network stability...');
|
console.log('Online! Syncing in 2s...');
|
||||||
setTimeout(() => {
|
setTimeout(syncQueue, 2000);
|
||||||
syncQueue();
|
|
||||||
}, 2000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('online', handleOnline);
|
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) {
|
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);
|
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;
|
if (totalInQueue === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,68 @@
|
|||||||
export const getSystemPrompt = (availableTags: string, language: string) => `
|
export const getOcrPrompt = () => `
|
||||||
TASK: Analyze this whisky bottle image. Return raw JSON.
|
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)
|
TASK:
|
||||||
Extract exact text and details from the label. Look closely for specific dates and codes.
|
1. Identify if the image contains a whisky/spirit bottle.
|
||||||
- name: Full whisky name (e.g. "Lagavulin 16 Year Old")
|
2. Extract the following technical details into the JSON schema below.
|
||||||
- distillery: Distillery name
|
3. If a value is not visible or cannot be inferred with high certainty, use null.
|
||||||
- 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)
|
|
||||||
|
|
||||||
STEP 2: SENSORY "MAGIC" (KNOWLEDGE RETRIEVAL)
|
EXTRACTION RULES:
|
||||||
Use the IDENTIFIED NAME from Step 1 to query your internal knowledge base for the flavor profile.
|
- Name: Combine Distillery + Age + Edition + Vintage (e.g., "Signatory Vintage Ben Nevis 2019 4 Year Old").
|
||||||
DO NOT try to "see" the flavor in the pixels. Use your expert knowledge about this specific whisky edition.
|
- Distillery: The producer of the spirit.
|
||||||
- Match flavors strictly against this list: ${availableTags}
|
- Bottler: Independent bottler (e.g., "Signatory", "Gordon & MacPhail") if applicable.
|
||||||
- Select top 5-8 matching tags.
|
- Batch Info: Capture ALL Cask numbers, Batch IDs, Bottle numbers, Cask Types (e.g., "Refill Oloroso Sherry Butt, Bottle 1135").
|
||||||
- If distinct notes are missing from the list, add 1-2 unique ones to "suggested_custom_tags" (localized in ${language === 'de' ? 'German' : 'English'}).
|
- 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):
|
OUTPUT SCHEMA (Strict JSON):
|
||||||
{
|
{
|
||||||
"name": "string",
|
"name": "string",
|
||||||
"distillery": "string",
|
"distillery": "string",
|
||||||
"category": "string",
|
"bottler": "stringOrNull",
|
||||||
"abv": number or null,
|
"category": "string (e.g. Single Malt Scotch Whisky)",
|
||||||
"age": number or null,
|
"abv": numberOrNull,
|
||||||
"vintage": "string or null",
|
"age": numberOrNull,
|
||||||
"distilled_at": "string or null",
|
"vintage": "stringOrNull",
|
||||||
"bottled_at": "string or null",
|
"distilled_at": "stringOrNull (Year/Date)",
|
||||||
"batch_info": "string or null",
|
"bottled_at": "stringOrNull (Year/Date)",
|
||||||
"bottleCode": "string or null",
|
"batch_info": "stringOrNull",
|
||||||
"whiskybaseId": "string or null",
|
"bottleCode": "stringOrNull",
|
||||||
|
"whiskybaseId": "stringOrNull",
|
||||||
"is_whisky": boolean,
|
"is_whisky": boolean,
|
||||||
"confidence": number,
|
"confidence": number
|
||||||
"suggested_tags": ["tag1", "tag2"],
|
|
||||||
"suggested_custom_tags": ["custom1"],
|
|
||||||
"search_string": "site:whiskybase.com [Distillery] [Name] [Vintage/Age]"
|
|
||||||
}
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
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
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export interface PendingScan {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
provider?: 'gemini' | 'mistral';
|
provider?: 'gemini' | 'mistral';
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
metadata?: any; // Bottle metadata for offline scans
|
||||||
|
syncing?: number; // 0 or 1 for indexing
|
||||||
|
attempts?: number;
|
||||||
|
last_error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingTasting {
|
export interface PendingTasting {
|
||||||
@@ -25,6 +29,9 @@ export interface PendingTasting {
|
|||||||
};
|
};
|
||||||
photo?: string;
|
photo?: string;
|
||||||
tasted_at: string;
|
tasted_at: string;
|
||||||
|
syncing?: number; // 0 or 1 for indexing
|
||||||
|
attempts?: number;
|
||||||
|
last_error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CachedTag {
|
export interface CachedTag {
|
||||||
@@ -80,9 +87,9 @@ export class WhiskyDexie extends Dexie {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('WhiskyVault');
|
super('WhiskyVault');
|
||||||
this.version(4).stores({
|
this.version(6).stores({
|
||||||
pending_scans: '++id, temp_id, timestamp, locale',
|
pending_scans: '++id, temp_id, timestamp, locale, syncing, attempts',
|
||||||
pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at',
|
pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at, syncing, attempts',
|
||||||
cache_tags: 'id, category, name',
|
cache_tags: 'id, category, name',
|
||||||
cache_buddies: 'id, name',
|
cache_buddies: 'id, name',
|
||||||
cache_bottles: 'id, name, distillery',
|
cache_bottles: 'id, name, distillery',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const apiKey = process.env.GEMINI_API_KEY!;
|
|||||||
const genAI = new GoogleGenerativeAI(apiKey);
|
const genAI = new GoogleGenerativeAI(apiKey);
|
||||||
|
|
||||||
export const geminiModel = genAI.getGenerativeModel({
|
export const geminiModel = genAI.getGenerativeModel({
|
||||||
//model: 'gemini-3-flash-preview',
|
|
||||||
model: 'gemini-2.5-flash',
|
model: 'gemini-2.5-flash',
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
responseMimeType: 'application/json',
|
responseMimeType: 'application/json',
|
||||||
|
|||||||
@@ -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 url = new URL(request.url);
|
||||||
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
|
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
|
||||||
|
|
||||||
if (!isStatic) {
|
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}`);
|
console.log(`[Proxy] ${request.method} ${url.pathname} | ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
|
|||||||
|
|
||||||
// 2. Auth & Credits
|
// 2. Auth & Credits
|
||||||
supabase = await createClient();
|
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.' };
|
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');
|
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
|
||||||
if (!creditCheck.allowed) {
|
if (!creditCheck.allowed) {
|
||||||
return {
|
return {
|
||||||
@@ -85,6 +85,7 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
|
|||||||
|
|
||||||
const prompt = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale);
|
const prompt = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale);
|
||||||
|
|
||||||
|
try {
|
||||||
const startApi = performance.now();
|
const startApi = performance.now();
|
||||||
const chatResponse = await client.chat.complete({
|
const chatResponse = await client.chat.complete({
|
||||||
model: 'mistral-large-latest',
|
model: 'mistral-large-latest',
|
||||||
@@ -117,23 +118,19 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
|
|||||||
if (Array.isArray(jsonData)) jsonData = jsonData[0];
|
if (Array.isArray(jsonData)) jsonData = jsonData[0];
|
||||||
console.log('[Mistral AI] JSON Response:', jsonData);
|
console.log('[Mistral AI] JSON Response:', jsonData);
|
||||||
|
|
||||||
// Extract search_string before validation
|
|
||||||
const searchString = jsonData.search_string;
|
const searchString = jsonData.search_string;
|
||||||
delete jsonData.search_string;
|
delete jsonData.search_string;
|
||||||
|
|
||||||
// Ensure abv is a number if it came as a string
|
|
||||||
if (typeof jsonData.abv === 'string') {
|
if (typeof jsonData.abv === 'string') {
|
||||||
jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim());
|
jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure age/vintage are numbers
|
|
||||||
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
|
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
|
||||||
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
|
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
|
||||||
|
|
||||||
const validatedData = BottleMetadataSchema.parse(jsonData);
|
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||||
const endParse = performance.now();
|
const endParse = performance.now();
|
||||||
|
|
||||||
// Track usage
|
|
||||||
await trackApiUsage({
|
await trackApiUsage({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
apiType: 'gemini_ai',
|
apiType: 'gemini_ai',
|
||||||
@@ -141,10 +138,8 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
|
|||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Deduct credits
|
|
||||||
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
|
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
|
||||||
|
|
||||||
// Store in Cache
|
|
||||||
await supabase
|
await supabase
|
||||||
.from('vision_cache')
|
.from('vision_cache')
|
||||||
.insert({ hash: imageHash, result: validatedData });
|
.insert({ hash: imageHash, result: validatedData });
|
||||||
@@ -161,22 +156,27 @@ export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse
|
|||||||
raw: jsonData
|
raw: jsonData
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (aiError: any) {
|
||||||
console.error('Mistral Analysis Error:', error);
|
console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
|
||||||
|
|
||||||
if (supabase) {
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
if (session?.user) {
|
|
||||||
await trackApiUsage({
|
await trackApiUsage({
|
||||||
userId: session.user.id,
|
userId: userId,
|
||||||
apiType: 'gemini_ai',
|
apiType: 'gemini_ai',
|
||||||
endpoint: 'mistral/mistral-large',
|
endpoint: 'mistral/mistral-large',
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
errorMessage: aiError.message
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
isAiError: true,
|
||||||
|
error: aiError.message,
|
||||||
|
imageHash: imageHash
|
||||||
|
} as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Mistral Analysis Global Error:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',
|
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
|
|
||||||
// 2. Auth & Credits (bleibt gleich)
|
// 2. Auth & Credits (bleibt gleich)
|
||||||
supabase = await createClient();
|
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.' };
|
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');
|
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
|
||||||
|
|
||||||
if (!creditCheck.allowed) {
|
if (!creditCheck.allowed) {
|
||||||
@@ -80,14 +80,13 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Für Gemini vorbereiten
|
// 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 base64Data = buffer.toString('base64');
|
||||||
const mimeType = file.type || 'image/webp'; // Fallback
|
const mimeType = file.type || 'image/webp';
|
||||||
const uploadSize = buffer.length;
|
const uploadSize = buffer.length;
|
||||||
|
|
||||||
const instruction = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'No tags available', locale);
|
const instruction = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'No tags available', locale);
|
||||||
|
|
||||||
|
try {
|
||||||
// API Call
|
// API Call
|
||||||
const startApi = performance.now();
|
const startApi = performance.now();
|
||||||
const result = await geminiModel.generateContent([
|
const result = await geminiModel.generateContent([
|
||||||
@@ -104,12 +103,10 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
const startParse = performance.now();
|
const startParse = performance.now();
|
||||||
const responseText = result.response.text();
|
const responseText = result.response.text();
|
||||||
|
|
||||||
// JSON Parsing der ANTWORT (das ist klein, das schafft der N100 locker)
|
|
||||||
let jsonData;
|
let jsonData;
|
||||||
try {
|
try {
|
||||||
jsonData = JSON.parse(responseText);
|
jsonData = JSON.parse(responseText);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback falls Gemini Markdown ```json Blöcke schickt
|
|
||||||
const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, '');
|
const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, '');
|
||||||
jsonData = JSON.parse(cleanedText);
|
jsonData = JSON.parse(cleanedText);
|
||||||
}
|
}
|
||||||
@@ -125,7 +122,7 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
const validatedData = BottleMetadataSchema.parse(jsonData);
|
const validatedData = BottleMetadataSchema.parse(jsonData);
|
||||||
const endParse = performance.now();
|
const endParse = performance.now();
|
||||||
|
|
||||||
// 6. Tracking & Credits (bleibt gleich)
|
// 6. Tracking & Credits
|
||||||
await trackApiUsage({
|
await trackApiUsage({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
apiType: 'gemini_ai',
|
apiType: 'gemini_ai',
|
||||||
@@ -136,12 +133,10 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
await deductCredits(userId, 'gemini_ai', 'Bottle analysis');
|
await deductCredits(userId, 'gemini_ai', 'Bottle analysis');
|
||||||
|
|
||||||
// Cache speichern
|
// Cache speichern
|
||||||
const { error: storeError } = await supabase
|
await supabase
|
||||||
.from('vision_cache')
|
.from('vision_cache')
|
||||||
.insert({ hash: imageHash, result: validatedData });
|
.insert({ hash: imageHash, result: validatedData });
|
||||||
|
|
||||||
if (storeError) console.warn(`[AI Cache] Storage failed: ${storeError.message}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: validatedData,
|
data: validatedData,
|
||||||
@@ -154,22 +149,27 @@ export async function analyzeBottle(input: any): Promise<AnalysisResponse> {
|
|||||||
raw: jsonData
|
raw: jsonData
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (aiError: any) {
|
||||||
console.error('Gemini Analysis Error:', error);
|
console.warn('[AnalyzeBottle] AI Analysis failed, providing fallback path:', aiError.message);
|
||||||
// Error Tracking Logic (bleibt gleich)
|
|
||||||
if (supabase) {
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
if (session?.user) {
|
|
||||||
await trackApiUsage({
|
await trackApiUsage({
|
||||||
userId: session.user.id,
|
userId: userId,
|
||||||
apiType: 'gemini_ai',
|
apiType: 'gemini_ai',
|
||||||
endpoint: 'generateContent',
|
endpoint: 'generateContent',
|
||||||
success: false,
|
success: false,
|
||||||
errorMessage: error instanceof Error ? error.message : 'Unknown error'
|
errorMessage: aiError.message
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
isAiError: true,
|
||||||
|
error: aiError.message,
|
||||||
|
imageHash: imageHash
|
||||||
|
} as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gemini Analysis Global Error:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'An unknown error occurred during analysis.',
|
error: error instanceof Error ? error.message : 'An unknown error occurred during analysis.',
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ export async function addBuddy(rawData: BuddyData) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { name } = BuddySchema.parse(rawData);
|
const { name } = BuddySchema.parse(rawData);
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) throw new Error('Nicht autorisiert');
|
if (!user) throw new Error('Nicht autorisiert');
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('buddies')
|
.from('buddies')
|
||||||
.insert([{ name, user_id: session.user.id }])
|
.insert([{ name, user_id: user.id }])
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@@ -32,14 +32,14 @@ export async function deleteBuddy(id: string) {
|
|||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) throw new Error('Nicht autorisiert');
|
if (!user) throw new Error('Nicht autorisiert');
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('buddies')
|
.from('buddies')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('user_id', session.user.id);
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export async function deleteBottle(bottleId: string) {
|
|||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) {
|
if (!user) {
|
||||||
throw new Error('Nicht autorisiert.');
|
throw new Error('Nicht autorisiert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export async function deleteBottle(bottleId: string) {
|
|||||||
throw new Error('Flasche nicht gefunden.');
|
throw new Error('Flasche nicht gefunden.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bottle.user_id !== session.user.id) {
|
if (bottle.user_id !== user.id) {
|
||||||
throw new Error('Keine Berechtigung.');
|
throw new Error('Keine Berechtigung.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export async function deleteSession(sessionId: string) {
|
|||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) {
|
if (!user) {
|
||||||
throw new Error('Nicht autorisiert.');
|
throw new Error('Nicht autorisiert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export async function deleteSession(sessionId: string) {
|
|||||||
.from('tasting_sessions')
|
.from('tasting_sessions')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', sessionId)
|
.eq('id', sessionId)
|
||||||
.eq('user_id', session.user.id);
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
if (deleteError) throw deleteError;
|
if (deleteError) throw deleteError;
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export async function deleteTasting(tastingId: string, bottleId: string) {
|
|||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) {
|
if (!user) {
|
||||||
throw new Error('Nicht autorisiert.');
|
throw new Error('Nicht autorisiert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export async function deleteTasting(tastingId: string, bottleId: string) {
|
|||||||
.from('tastings')
|
.from('tastings')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', tastingId)
|
.eq('id', tastingId)
|
||||||
.eq('user_id', session.user.id);
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
if (deleteError) throw deleteError;
|
if (deleteError) throw deleteError;
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ export async function findMatchingBottle(metadata: BottleMetadata) {
|
|||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const userId = session.user.id;
|
const userId = user.id;
|
||||||
|
|
||||||
// 1. Try matching by Whiskybase ID (most reliable)
|
// 1. Try matching by Whiskybase ID (most reliable)
|
||||||
if (metadata.whiskybaseId) {
|
if (metadata.whiskybaseId) {
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ export async function saveBottle(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const metadata = BottleMetadataSchema.parse(rawMetadata);
|
const metadata = BottleMetadataSchema.parse(rawMetadata);
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) {
|
if (!user) {
|
||||||
throw new Error('Nicht autorisiert oder Session abgelaufen.');
|
throw new Error('Nicht autorisiert oder Session abgelaufen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = session.user.id;
|
const userId = user.id;
|
||||||
let finalImageUrl = preUploadedUrl;
|
let finalImageUrl = preUploadedUrl;
|
||||||
|
|
||||||
// 1. Upload Image to Storage if not already uploaded
|
// 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.');
|
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
|
// 2. Save Metadata to Database
|
||||||
const { data: bottleData, error: dbError } = await supabase
|
const { data: bottleData, error: dbError } = await supabase
|
||||||
.from('bottles')
|
.from('bottles')
|
||||||
@@ -64,7 +84,7 @@ export async function saveBottle(
|
|||||||
image_url: finalImageUrl,
|
image_url: finalImageUrl,
|
||||||
status: 'sealed',
|
status: 'sealed',
|
||||||
is_whisky: metadata.is_whisky ?? true,
|
is_whisky: metadata.is_whisky ?? true,
|
||||||
confidence: metadata.confidence ?? 100,
|
confidence: metadata.confidence ? Math.round(metadata.confidence * 100) : 100,
|
||||||
distilled_at: metadata.distilled_at,
|
distilled_at: metadata.distilled_at,
|
||||||
bottled_at: metadata.bottled_at,
|
bottled_at: metadata.bottled_at,
|
||||||
batch_info: metadata.batch_info,
|
batch_info: metadata.batch_info,
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export async function saveTasting(rawData: TastingNoteData) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = TastingNoteSchema.parse(rawData);
|
const data = TastingNoteSchema.parse(rawData);
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) throw new Error('Nicht autorisiert');
|
if (!user) throw new Error('Nicht autorisiert');
|
||||||
|
|
||||||
// Validate Session Age (12 hour limit)
|
// Validate Session Age (12 hour limit)
|
||||||
if (data.session_id) {
|
if (data.session_id) {
|
||||||
@@ -26,7 +26,7 @@ export async function saveTasting(rawData: TastingNoteData) {
|
|||||||
.from('tastings')
|
.from('tastings')
|
||||||
.insert({
|
.insert({
|
||||||
bottle_id: data.bottle_id,
|
bottle_id: data.bottle_id,
|
||||||
user_id: session.user.id,
|
user_id: user.id,
|
||||||
session_id: data.session_id,
|
session_id: data.session_id,
|
||||||
rating: data.rating,
|
rating: data.rating,
|
||||||
nose_notes: data.nose_notes,
|
nose_notes: data.nose_notes,
|
||||||
@@ -46,7 +46,7 @@ export async function saveTasting(rawData: TastingNoteData) {
|
|||||||
const buddies = data.buddy_ids.map(buddyId => ({
|
const buddies = data.buddy_ids.map(buddyId => ({
|
||||||
tasting_id: tasting.id,
|
tasting_id: tasting.id,
|
||||||
buddy_id: buddyId,
|
buddy_id: buddyId,
|
||||||
user_id: session.user.id
|
user_id: user.id
|
||||||
}));
|
}));
|
||||||
const { error: tagError } = await supabase
|
const { error: tagError } = await supabase
|
||||||
.from('tasting_buddies')
|
.from('tasting_buddies')
|
||||||
@@ -64,7 +64,7 @@ export async function saveTasting(rawData: TastingNoteData) {
|
|||||||
const aromaTags = data.tag_ids.map(tagId => ({
|
const aromaTags = data.tag_ids.map(tagId => ({
|
||||||
tasting_id: tasting.id,
|
tasting_id: tasting.id,
|
||||||
tag_id: tagId,
|
tag_id: tagId,
|
||||||
user_id: session.user.id
|
user_id: user.id
|
||||||
}));
|
}));
|
||||||
const { error: aromaTagError } = await supabase
|
const { error: aromaTagError } = await supabase
|
||||||
.from('tasting_tags')
|
.from('tasting_tags')
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ export async function createCustomTag(rawName: string, rawCategory: TagCategory)
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { name, category } = TagSchema.parse({ name: rawName, category: rawCategory });
|
const { name, category } = TagSchema.parse({ name: rawName, category: rawCategory });
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) throw new Error('Nicht autorisiert');
|
if (!user) throw new Error('Nicht autorisiert');
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('tags')
|
.from('tags')
|
||||||
@@ -83,7 +83,7 @@ export async function createCustomTag(rawName: string, rawCategory: TagCategory)
|
|||||||
name,
|
name,
|
||||||
category,
|
category,
|
||||||
is_system_default: false,
|
is_system_default: false,
|
||||||
created_by: session.user.id
|
created_by: user.id
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o
|
|||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) {
|
if (!user) {
|
||||||
throw new Error('Nicht autorisiert');
|
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
|
finished_at: status === 'empty' ? new Date().toISOString() : null
|
||||||
})
|
})
|
||||||
.eq('id', bottleId)
|
.eq('id', bottleId)
|
||||||
.eq('user_id', session.user.id);
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = UpdateBottleSchema.parse(rawData);
|
const data = UpdateBottleSchema.parse(rawData);
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!session) throw new Error('Nicht autorisiert');
|
if (!user) throw new Error('Nicht autorisiert');
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('bottles')
|
.from('bottles')
|
||||||
@@ -29,7 +29,7 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
|
|||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.eq('id', bottleId)
|
.eq('id', bottleId)
|
||||||
.eq('user_id', session.user.id);
|
.eq('user_id', user.id);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
export const BottleMetadataSchema = z.object({
|
export const BottleMetadataSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(255).nullish(),
|
name: z.string().trim().min(1).max(255).nullish(),
|
||||||
distillery: z.string().trim().max(255).nullish(),
|
distillery: z.string().trim().max(255).nullish(),
|
||||||
|
bottler: z.string().trim().max(255).nullish(),
|
||||||
category: z.string().trim().max(100).nullish(),
|
category: z.string().trim().max(100).nullish(),
|
||||||
abv: z.number().min(0).max(100).nullish(),
|
abv: z.number().min(0).max(100).nullish(),
|
||||||
age: 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({
|
export const DiscoveryDataSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(255),
|
name: z.string().trim().min(1).max(255),
|
||||||
distillery: z.string().trim().max(255).optional(),
|
distillery: z.string().trim().max(255).nullish(),
|
||||||
abv: z.number().min(0).max(100).optional(),
|
abv: z.number().min(0).max(100).nullish(),
|
||||||
age: z.number().min(0).max(100).optional(),
|
age: z.number().min(0).max(100).nullish(),
|
||||||
distilled_at: z.string().trim().max(50).optional(),
|
distilled_at: z.string().trim().max(50).nullish(),
|
||||||
bottled_at: z.string().trim().max(50).optional(),
|
bottled_at: z.string().trim().max(50).nullish(),
|
||||||
batch_info: z.string().trim().max(255).optional(),
|
batch_info: z.string().trim().max(255).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DiscoveryData = z.infer<typeof DiscoveryDataSchema>;
|
export type DiscoveryData = z.infer<typeof DiscoveryDataSchema>;
|
||||||
@@ -96,10 +97,25 @@ export interface AnalysisResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
data?: BottleMetadata;
|
data?: BottleMetadata;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
isAiError?: boolean;
|
||||||
|
imageHash?: string;
|
||||||
perf?: {
|
perf?: {
|
||||||
apiDuration: number;
|
// Legacy fields (kept for backward compatibility)
|
||||||
parseDuration: number;
|
apiDuration?: number;
|
||||||
|
parseDuration?: number;
|
||||||
|
|
||||||
|
// Detailed metrics
|
||||||
|
imagePrep?: number;
|
||||||
|
cacheCheck?: number;
|
||||||
|
encoding?: number;
|
||||||
|
modelInit?: number;
|
||||||
|
apiCall?: number;
|
||||||
|
parsing?: number;
|
||||||
|
validation?: number;
|
||||||
|
dbOps?: number;
|
||||||
uploadSize: number;
|
uploadSize: number;
|
||||||
|
total?: number;
|
||||||
|
cacheHit?: boolean;
|
||||||
};
|
};
|
||||||
raw?: any;
|
raw?: any;
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/utils/generate-dummy-metadata.ts
Normal file
24
src/utils/generate-dummy-metadata.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -47,7 +47,8 @@ export async function processImageForAI(file: File): Promise<ProcessedImage> {
|
|||||||
maxSizeMB: 0.4,
|
maxSizeMB: 0.4,
|
||||||
maxWidthOrHeight: 1024,
|
maxWidthOrHeight: 1024,
|
||||||
useWebWorker: true,
|
useWebWorker: true,
|
||||||
fileType: 'image/webp'
|
fileType: 'image/webp',
|
||||||
|
libURL: '/lib/browser-image-compression.js'
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user