feat: refine Scan & Taste UI, fix desktop scrolling, and resolve production login fetch error

- Reverted theme from gold to amber and restored legacy typography.
- Refactored ScanAndTasteFlow and TastingEditor for robust desktop scrolling.
- Hotfixed sw.js to completely bypass Supabase Auth/API requests to fix 'Failed to fetch' in production.
- Integrated full tasting note persistence (tags, buddies, sessions).
This commit is contained in:
2025-12-21 22:29:16 +01:00
parent 4e8af60488
commit b57f5dc2ad
12 changed files with 1482 additions and 120 deletions

173
.aiideas
View File

@@ -1,123 +1,110 @@
Hier ist der Code, um Pixtral Large (das europäische Flaggschiff-Modell von Mistral) direkt in deine Next.js App zu integrieren.
Damit kannst du einen direkten "A/B-Test" gegen Gemini 3 Flash fahren.
1. Vorbereitung
Rolle: Du bist ein Senior Frontend Engineer und UX - Experte mit Spezialisierung auf "Mobile-First" - Webanwendungen.Dein Fokus liegt auf High - End Ästhetik(Dark Mode), flüssigen Animationen und reibungsloser User Experience.
Du brauchst das Mistral SDK und einen API Key von console.mistral.ai.
Bash
Aufgabe: Wir bauen den Core - Flow einer bestehenden Whisky - Tasting - App um.Implementiere den neuen "Scan & Taste" Flow.Ziel ist eine Single - Page - App - Experience(SPA) ohne Reloads, die sich nativ anfühlt.
npm install @mistralai/mistralai
Tech Stack(Anpassen falls nötig):
Füge deinen Key in die .env ein (nicht NEXT_PUBLIC_!): MISTRAL_API_KEY=dein_key_hier
2. Der Code (Server Action)
Framework: Next.js(Bitte nutze den bestehenden Stack der App)
Erstelle eine neue Datei, z.B. app/actions/scan-mistral.ts.
TypeScript
Styling: Tailwind CSS
'use server'
Icons: Lucide - React oder HeroIcons
import { Mistral } from '@mistralai/mistralai';
Charts: Recharts oder Chart.js(für das Radar Chart)
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
Animation: Framer Motion(für Transitions)
export async function scanWithPixtral(base64Image: string, mimeType: string) {
// Pixtral braucht das Bild als Data-URL
const dataUrl = `data:${mimeType};base64,${base64Image}`;
1. Design System & Vibe
try {
const chatResponse = await client.chat.complete({
model: 'pixtral-large-latest', // Das beste Modell (Stand Dez 2025)
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: `Du bist ein Whisky-Experte und OCR-Spezialist.
Analysiere dieses Etikett präzise.
Theme: Strict Dark Mode.
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Markdown, kein Text davor/danach):
{
"distillery": "Name der Brennerei (z.B. Lagavulin)",
"name": "Exakter Name/Edition (z.B. 16 Year Old)",
"vintage": "Jahrgang oder null",
"age": "Alter oder null (z.B. 16)",
"abv": "Alkoholgehalt (z.B. 43%)",
"search_query": "site:whiskybase.com [Brennerei] [Name] [Alter]"
}`
},
{
type: 'image_url',
imageUrl: dataUrl
}
]
}
],
responseFormat: { type: 'json_object' }, // Erzwingt JSON (wichtig!)
temperature: 0.1 // Niedrig = präziser, weniger Halluzinationen
});
Background: #0F1014(Deep Anthracite / Black)
const rawContent = chatResponse.choices?.[0].message.content;
Surface / Cards: #1A1B20(Lighter Anthracite)
if (!rawContent) throw new Error("Keine Antwort von Pixtral");
Primary Accent: #C89D46(Whisky Gold / Amber)
// JSON parsen
return JSON.parse(rawContent as string);
Text: Sans - Serif(Inter) für UI, Serif(Playfair Display) für Überschriften / Namen.
} catch (error) {
console.error("Pixtral Error:", error);
return null; // Fallback auslösen
}
}
Stil: "Premium & Warm".Runde Ecken(rounded - 2xl), Glassmorphism für Overlays(backdrop - blur - md, bg - white / 5), feine Borders(border - white / 10).
3. Der "A/B-Switcher" (So nutzt du beides)
2. Der Flow(Schritt für Schritt Implementierung)
In deiner Haupt-Logik (app/actions/scan.ts) kannst du jetzt einfach umschalten oder Pixtral als Fallback nutzen, wenn Gemini zickt (oder andersrum).
TypeScript
Bitte implementiere folgende Views / Components als zusammenhängenden Flow:
A.Der Entry Point(Floating Action Button)
'use server'
import { scanWithGemini } from './scan-gemini'; // Deine bestehende Funktion
import { scanWithPixtral } from './scan-mistral';
Erstelle einen prominenten, schwebenden Button(unten mittig, fixed), der über dem Dashboard liegt.
export async function scanBottle(formData: FormData) {
// ... Bild zu Base64 konvertieren ...
const base64 = "...";
const mime = "image/jpeg";
Icon: Kamera - Symbol.
// STRATEGIE A: Der "Qualitäts-Check"
// Wir nutzen standardmäßig Gemini, aber Pixtral als EU-Option
Interaction: Beim Klick simulieren wir einen Kamera - Scan(nutze vorerst ein Mock - Timeout von 2s mit einer Lade - Animation "Analysiere Etikett..."), danach Transition zu View B.
let result;
const useEuModel = process.env.USE_EU_MODEL === 'true'; // Schalter in .env
B.Der Tasting Editor(Main Component)
if (useEuModel) {
console.log("🇪🇺 Nutze Pixtral (Mistral AI)...");
result = await scanWithPixtral(base64, mime);
} else {
console.log("🇺🇸 Nutze Gemini 3 Flash...");
result = await scanWithGemini(base64, mime);
}
Dies ist der wichtigste Screen.Layout - Struktur:
// Wenn das erste Modell versagt (null zurückgibt), versuche das andere
if (!result) {
console.log("⚠️ Erster Versuch fehlgeschlagen, starte Fallback...");
result = useEuModel
? await scanWithGemini(base64, mime)
: await scanWithPixtral(base64, mime);
}
Top Bar(Sticky):
// ... weiter mit Supabase Caching & Brave Search ...
return result;
}
Zeige einen "Context Indicator".
Pixtral vs. Gemini 3 Flash (Dein Check)
Logik: Zeige Text "Trinkst du in Gesellschaft? + Session wählen".
Achte beim Testen auf diese Feinheiten:
Interaction: Klick öffnet ein Bottom Sheet(siehe C).Wenn eine Session gewählt wurde, zeige: "Session: [Name]".
Helle Schrift auf dunklem Grund: Hier ist Gemini oft aggressiver und liest besser. Pixtral ist manchmal vorsichtiger.
Hero Section:
Schreibschrift (Signatory Vintage Abfüllungen): Pixtral Large ist hier erstaunlich gut, fast besser als Gemini, da es Handschrift extrem gut kann.
Zeige das(gemockte) Foto der Flasche links.
JSON-Struktur: Dank responseFormat: { type: 'json_object' } sollten beide Modelle sehr sauberen Code liefern.
Rechts daneben: Name(Serif, Gold), Alter, ABV. (Mock Data: "Lagavulin 16, Islay, 43%").
Form Section(Scrollable):
Slider: Erstelle eine Custom - Komponente für "Nose", "Taste", "Finish".Nutze keine Zahlen - Inputs, sondern Range - Slider(0 - 100).
Smart Tags(Wichtig!): Implementiere eine Chip - Auswahl.
Design: "Ghost Button" Style(transparenter BG, feiner Border).
Active State: Füllt sich mit #C89D46(Gold), Text wird dunkel.
Data: Mocke AI - Vorschläge wie["Rauch", "Torf", "Jod", "Vanille"].
Sticky Footer:
Ein Button "Save Tasting"(Full Width), der immer sichtbar unten schwebt(z - index: 50).
C.Das Session Bottom Sheet(Overlay)
Wenn man in View B auf die Top Bar klickt, fährt von unten ein Sheet hoch(Höhe: 50vh).
Inhalt: Input Feld für "Neue Session" und Liste "Aktuelle Sessions".
Beim Auswählen schließt sich das Sheet und aktualisiert den State in View B(Context Bar).
D.Die Result Card(The Reward)
Nach dem Speichern(Transition: Fade out Editor -> Fade in Card):
Zeige eine "Trading Card" im 9: 16 Verhältnis, zentriert.
Inhalt:
Großes Foto der Flasche mit Vignette.
Ein Radar Chart(Spider Web) für die 5 Geschmacksprofile(Nose, Taste, Finish, Balance, Complexity).
Ein "Badge" oben rechts mit dem Score(z.B. 8.5).
Action: Ein Button "Share Image" unter der Karte. (Logik: Bereite navigator.share vor).
3. Technische Anforderungen & State
Nutze einen lokalen State(oder Context), um die Daten zwischen Editor und Result zu halten.
Mocke die "AI Response"(Flaschenerkennung) mit einem festen Datensatz(JSON), damit wir das UI testen können.
Achte auf Mobile - Viewport - Height(dvh), damit Safari - Bars nichts verdecken.
Wenn du etwas schon hast pass es an und integriere es in den neuen Flow
Wenn Pixtral Large für dich ähnlich gut funktioniert wie Gemini 3 Flash, hast du den großen Vorteil: Daten bleiben in Europa (Server in Frankreich/EU). Das ist ein starkes Marketing-Argument ("We love Whisky & Privacy").

View File

@@ -20,12 +20,14 @@
"canvas-confetti": "^1.9.3",
"dexie": "^4.2.1",
"dexie-react-hooks": "^4.2.0",
"framer-motion": "^12.23.26",
"heic2any": "^0.0.4",
"lucide-react": "^0.468.0",
"next": "16.1.0",
"openai": "^6.15.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.6.0",
"sharp": "^0.34.5",
"uuid": "^13.0.0",
"zod": "^3.23.8"

346
pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
dexie-react-hooks:
specifier: ^4.2.0
version: 4.2.0(@types/react@19.2.7)(dexie@4.2.1)(react@19.2.3)
framer-motion:
specifier: ^12.23.26
version: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
heic2any:
specifier: ^0.0.4
version: 0.0.4
@@ -50,6 +53,9 @@ importers:
react-dom:
specifier: ^19.2.0
version: 19.2.3(react@19.2.3)
recharts:
specifier: ^3.6.0
version: 3.6.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1)
sharp:
specifier: ^0.34.5
version: 0.34.5
@@ -683,6 +689,17 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
@@ -802,6 +819,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@supabase/auth-js@2.88.0':
resolution: {integrity: sha512-r/tlKD1Sv5w5AGmxVdBK17KwVkGOHMjihqw+HeW7Qsyes5ajLeyjL0M7jXZom1+NW4yINacKqOR9gqGmWzW9eA==}
engines: {node: '>=20.0.0'}
@@ -889,6 +909,33 @@ packages:
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.7':
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -912,6 +959,9 @@ packages:
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
@@ -1291,6 +1341,10 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1335,6 +1389,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.0:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -1371,6 +1469,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -1472,6 +1573,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
es-toolkit@1.43.0:
resolution: {integrity: sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==}
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
@@ -1600,6 +1704,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -1659,6 +1766,20 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.23.26:
resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -1800,6 +1921,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.0:
resolution: {integrity: sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -1823,6 +1950,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -2074,6 +2205,12 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2329,6 +2466,18 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
@@ -2344,10 +2493,26 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
recharts@3.6.0:
resolution: {integrity: sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -2360,6 +2525,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2567,6 +2735,9 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2671,6 +2842,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -2678,6 +2854,9 @@ packages:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
hasBin: true
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite@7.3.0:
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3313,6 +3492,18 @@ snapshots:
dependencies:
playwright: 1.57.0
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.0
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.3
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1)
'@rolldown/pluginutils@1.0.0-beta.53': {}
'@rollup/rollup-android-arm-eabi@4.53.5':
@@ -3385,6 +3576,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@supabase/auth-js@2.88.0':
dependencies:
tslib: 2.8.1
@@ -3505,6 +3698,30 @@ snapshots:
'@types/cookie@0.6.0': {}
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.7':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
@@ -3525,6 +3742,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/use-sync-external-store@0.0.6': {}
'@types/uuid@10.0.0': {}
'@types/ws@8.18.1':
@@ -3941,6 +4160,8 @@ snapshots:
client-only@0.0.1: {}
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -3978,6 +4199,44 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.0: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.0
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
damerau-levenshtein@1.0.8: {}
data-urls@6.0.0:
@@ -4011,6 +4270,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
@@ -4170,6 +4431,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
es-toolkit@1.43.0: {}
esbuild@0.27.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.2
@@ -4412,6 +4675,8 @@ snapshots:
esutils@2.0.3: {}
eventemitter3@5.0.1: {}
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
@@ -4471,6 +4736,15 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
motion-dom: 12.23.23
motion-utils: 12.23.6
tslib: 2.8.1
optionalDependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
fs.realpath@1.0.0: {}
fsevents@2.3.2:
@@ -4614,6 +4888,10 @@ snapshots:
ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.0: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -4636,6 +4914,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
internmap@2.0.3: {}
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -4893,6 +5173,12 @@ snapshots:
minimist@1.2.8: {}
motion-dom@12.23.23:
dependencies:
motion-utils: 12.23.6
motion-utils@12.23.6: {}
ms@2.1.3: {}
mz@2.7.0:
@@ -5122,6 +5408,15 @@ snapshots:
react-is@17.0.2: {}
react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.7
redux: 5.0.1
react-refresh@0.18.0: {}
react@19.2.3: {}
@@ -5134,11 +5429,37 @@ snapshots:
dependencies:
picomatch: 2.3.1
recharts@3.6.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react-is@17.0.2)(react@19.2.3)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.43.0
eventemitter3: 5.0.1
immer: 10.2.0
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-is: 17.0.2
react-redux: 9.2.0(@types/react@19.2.7)(react@19.2.3)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.3)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redent@3.0.0:
dependencies:
indent-string: 4.0.0
strip-indent: 3.0.0
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -5161,6 +5482,8 @@ snapshots:
require-from-string@2.0.2: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -5473,6 +5796,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.0.2: {}
@@ -5612,10 +5937,31 @@ snapshots:
dependencies:
punycode: 2.3.1
use-sync-external-store@1.6.0(react@19.2.3):
dependencies:
react: 19.2.3
util-deprecate@1.0.2: {}
uuid@13.0.0: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.7
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7):
dependencies:
esbuild: 0.27.2

View File

@@ -118,15 +118,22 @@ self.addEventListener('message', (event) => {
// 🚀 FETCH: Offline-First Strategy
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Bypass Auth/API
if (url.pathname.includes('/auth/') || url.pathname.includes('/api/') || url.hostname.includes('supabase.co')) {
// CRITICAL: Bypass Auth/API/Supabase early and COMPLETELY.
// We do not call event.respondWith() for these, letting the browser handle them natively.
if (
url.pathname.includes('/auth/') ||
url.pathname.includes('/api/') ||
url.hostname.includes('supabase.co') ||
url.hostname.includes('auth') ||
event.request.headers.get('Authorization')
) {
return;
}
if (event.request.method !== 'GET') return;
// RSC Data
if (url.pathname.startsWith('/_next/data/')) {
event.respondWith(

View File

@@ -2,28 +2,37 @@
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
}
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
--background: #0F1014;
--surface: #1A1B20;
--primary: #C89D46;
--border: rgba(255, 255, 255, 0.1);
}
}
body {
color: rgb(var(--foreground-rgb));
background: linear-gradient(to bottom,
transparent,
rgb(var(--background-end-rgb))) rgb(var(--background-start-rgb));
@apply bg-[#0F1014] text-white antialiased;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
h1,
h2,
h3,
h4,
.font-display {
font-family: var(--font-playfair), serif;
}
@layer utilities {
.glass {
@apply backdrop-blur-md bg-white/5 border border-white/10;
}
.glass-dark {
@apply backdrop-blur-md bg-black/40 border border-white/5;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

View File

@@ -11,7 +11,10 @@ import MainContentWrapper from "@/components/MainContentWrapper";
import AuthListener from "@/components/AuthListener";
import SyncHandler from "@/components/SyncHandler";
const inter = Inter({ subsets: ["latin"] });
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
const playfair = Playfair_Display({ subsets: ["latin"], variable: '--font-playfair' });
import { Playfair_Display } from "next/font/google";
export const metadata: Metadata = {
title: {
@@ -45,7 +48,7 @@ export default function RootLayout({
}>) {
return (
<html lang="de">
<body className={inter.className}>
<body className={`${inter.variable} ${playfair.variable} font-sans`}>
<I18nProvider>
<SessionProvider>
<AuthListener />

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import CameraCapture from "@/components/CameraCapture";
import BottleGrid from "@/components/BottleGrid";
import AuthForm from "@/components/AuthForm";
import BuddyList from "@/components/BuddyList";
@@ -13,7 +12,9 @@ import LanguageSwitcher from "@/components/LanguageSwitcher";
import OfflineIndicator from "@/components/OfflineIndicator";
import { useI18n } from "@/i18n/I18nContext";
import { useSession } from "@/context/SessionContext";
import { Sparkles } from "lucide-react";
import { Sparkles, Camera } from "lucide-react";
import FloatingScannerButton from '@/components/FloatingScannerButton';
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
export default function Home() {
const supabase = createClient();
@@ -23,6 +24,13 @@ export default function Home() {
const [fetchError, setFetchError] = useState<string | null>(null);
const { t } = useI18n();
const { activeSession } = useSession();
const [isFlowOpen, setIsFlowOpen] = useState(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const handleImageSelected = (base64: string) => {
setCapturedImage(base64);
setIsFlowOpen(true);
};
useEffect(() => {
// Check session
@@ -200,7 +208,6 @@ export default function Home() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-5xl">
<div className="flex flex-col gap-8">
<CameraCapture onSaveComplete={fetchCollection} />
<SessionList />
</div>
<div>
@@ -232,10 +239,17 @@ export default function Home() {
</button>
</div>
) : (
<BottleGrid bottles={bottles} />
bottles.length > 0 && <BottleGrid bottles={bottles} />
)}
</div>
</div>
<FloatingScannerButton onImageSelected={handleImageSelected} />
<ScanAndTasteFlow
isOpen={isFlowOpen}
onClose={() => setIsFlowOpen(false)}
base64Image={capturedImage}
/>
</main>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import React from 'react';
import { Camera } from 'lucide-react';
import { motion } from 'framer-motion';
interface FloatingScannerButtonProps {
onImageSelected: (base64Image: string) => void;
}
export default function FloatingScannerButton({ onImageSelected }: FloatingScannerButtonProps) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
onImageSelected(base64String);
};
reader.readAsDataURL(file);
};
return (
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
capture="environment"
className="hidden"
/>
<motion.button
onClick={() => fileInputRef.current?.click()}
whileHover={{ scale: 1.1, translateY: -4 }}
whileTap={{ scale: 0.9 }}
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="relative group p-6 rounded-full bg-[#C89D46] text-black shadow-[0_0_30px_rgba(200,157,70,0.4)] hover:shadow-[0_0_40px_rgba(200,157,70,0.6)] transition-all overflow-hidden"
>
{/* Shine Animation */}
<motion.div
animate={{
x: ['-100%', '100%'],
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "easeInOut",
repeatDelay: 3
}}
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 -z-0"
/>
<Camera size={32} strokeWidth={2.5} className="relative z-10" />
{/* Pulse ring */}
<span className="absolute inset-0 rounded-full border-4 border-[#C89D46] animate-ping opacity-20" />
</motion.button>
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import React from 'react';
import { motion } from 'framer-motion';
import { Share2, Sparkles, Award } from 'lucide-react';
import { Radar, RadarChart, PolarGrid, PolarAngleAxis, ResponsiveContainer } from 'recharts';
interface ResultCardProps {
data: {
nose: number;
taste: number;
finish: number;
rating: number;
complexity?: number;
balance?: number;
};
bottleName: string;
image: string | null;
onShare: () => void;
}
export default function ResultCard({ data, bottleName, image, onShare }: ResultCardProps) {
const chartData = [
{ subject: 'Nose', A: data.nose, fullMark: 100 },
{ subject: 'Taste', A: data.taste, fullMark: 100 },
{ subject: 'Finish', A: data.finish, fullMark: 100 },
{ subject: 'Balance', A: data.balance || 85, fullMark: 100 },
{ subject: 'Complexity', A: data.complexity || 75, fullMark: 100 },
];
const displayScore = data.rating;
return (
<motion.div
initial={{ scale: 0.9, opacity: 0, rotateY: 30 }}
animate={{ scale: 1, opacity: 1, rotateY: 0 }}
transition={{ type: 'spring', damping: 15 }}
className="flex flex-col items-center gap-8 w-full max-w-sm"
>
{/* The Trading Card */}
<div className="relative w-full aspect-[9/16] rounded-[32px] overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.8)] border border-white/20 bg-gradient-to-b from-[#1A1B20] to-black group">
{/* Bottle Image with Vignette */}
<div className="absolute inset-0">
{image ? (
<img src={image} alt={bottleName} className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 bg-white/5 flex items-center justify-center opacity-20 text-[20px] font-black uppercase tracking-[1em] rotate-90">No Image</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/20 to-transparent opacity-80" />
</div>
{/* Content Overlay */}
<div className="absolute inset-x-0 bottom-0 p-8 space-y-6">
<div className="space-y-1">
<p className="text-[10px] font-black uppercase tracking-[0.3em] text-amber-500">Tasting Record</p>
<h2 className="text-4xl font-black text-white truncate uppercase tracking-tight leading-none">{bottleName}</h2>
</div>
{/* Radar Chart Area */}
<div className="h-64 w-full glass-dark rounded-3xl p-4 flex flex-col items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={chartData}>
<PolarGrid stroke="rgba(255,255,255,0.1)" />
<PolarAngleAxis
dataKey="subject"
tick={{ fill: 'rgba(255,255,255,0.4)', fontSize: 10, fontWeight: 900 }}
/>
<Radar
name="Whisky Profile"
dataKey="A"
stroke="#D97706"
fill="#D97706"
fillOpacity={0.5}
/>
</RadarChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-amber-500" />
<span className="text-xs font-black uppercase tracking-widest text-white/40">Verified Report</span>
</div>
</div>
</div>
{/* Score Badge */}
<div className="absolute top-8 right-8 w-20 h-20 glass rounded-2xl flex flex-col items-center justify-center border-amber-500/40 shadow-xl">
<span className="text-2xl font-black text-amber-500">{displayScore}</span>
<span className="text-[8px] font-black uppercase tracking-widest text-white/40">Score</span>
</div>
{/* Decorative Elements */}
<div className="absolute top-8 left-8 p-2 rounded-xl bg-amber-600 text-white shadow-lg shadow-amber-600/40">
<Award size={20} />
</div>
</div>
{/* Share Button */}
<button
onClick={onShare}
className="w-full py-5 bg-zinc-900 hover:bg-zinc-800 border border-white/10 text-white rounded-3xl font-black uppercase tracking-widest text-xs flex items-center justify-center gap-3 transition-all"
>
<Share2 size={18} />
Share Report
</button>
</motion.div>
);
}

View File

@@ -0,0 +1,255 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Loader2, Sparkles, AlertCircle } from 'lucide-react';
import TastingEditor from './TastingEditor';
import SessionBottomSheet from './SessionBottomSheet';
import ResultCard from './ResultCard';
import { useSession } from '@/context/SessionContext';
import { magicScan } from '@/services/magic-scan';
import { saveBottle } from '@/services/save-bottle';
import { saveTasting } from '@/services/save-tasting';
import { BottleMetadata } from '@/types/whisky';
import { useI18n } from '@/i18n/I18nContext';
import { createClient } from '@/lib/supabase/client';
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
interface ScanAndTasteFlowProps {
isOpen: boolean;
onClose: () => void;
base64Image: string | null;
}
export default function ScanAndTasteFlow({ isOpen, onClose, base64Image }: ScanAndTasteFlowProps) {
const [state, setState] = useState<FlowState>('IDLE');
const [isSessionsOpen, setIsSessionsOpen] = useState(false);
const { activeSession } = useSession();
const [tastingData, setTastingData] = useState<any>(null);
const [bottleMetadata, setBottleMetadata] = useState<BottleMetadata | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const { locale } = useI18n();
const supabase = createClient();
useEffect(() => {
if (isOpen && base64Image) {
handleScan(base64Image);
} else if (!isOpen) {
setState('IDLE');
setTastingData(null);
setBottleMetadata(null);
setError(null);
setIsSaving(false);
}
}, [isOpen, base64Image]);
const handleScan = async (image: string) => {
setState('SCANNING');
setError(null);
try {
const cleanBase64 = image.split(',')[1] || image;
const result = await magicScan(cleanBase64, 'gemini', locale);
if (result.success && result.data) {
setBottleMetadata(result.data);
setState('EDITOR');
} else {
throw new Error(result.error || 'Flasche konnte nicht erkannt werden.');
}
} catch (err: any) {
setError(err.message);
setState('ERROR');
}
};
const handleSaveTasting = async (formData: any) => {
if (!bottleMetadata || !base64Image) return;
setIsSaving(true);
setError(null);
try {
const { data: { user } = {} } = await supabase.auth.getUser();
if (!user) throw new Error('Nicht autorisiert');
// 1. Save Bottle
const bottleResult = await saveBottle(bottleMetadata, base64Image, user.id);
if (!bottleResult.success || !bottleResult.data) {
throw new Error(bottleResult.error || 'Fehler beim Speichern der Flasche');
}
const bottleId = bottleResult.data.id;
// 2. Save Tasting
const tastingNote = {
...formData,
bottle_id: bottleId,
};
const tastingResult = await saveTasting(tastingNote);
if (!tastingResult.success) {
throw new Error(tastingResult.error || 'Fehler beim Speichern des Tastings');
}
setTastingData(tastingNote);
setState('RESULT');
} catch (err: any) {
setError(err.message);
setState('ERROR');
} finally {
setIsSaving(false);
}
};
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: `My Tasting: ${bottleMetadata?.name || 'Whisky'}`,
text: `Check out my tasting results for ${bottleMetadata?.distillery} ${bottleMetadata?.name}!`,
url: window.location.href,
});
} catch (err) {
console.error('Share failed:', err);
}
} else {
alert('Sharing is not supported on this browser.');
}
};
if (!isOpen) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[60] bg-[#0F1014] flex flex-col h-[100dvh] w-screen overflow-hidden overscroll-none"
>
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-6 right-6 z-[70] p-2 rounded-full bg-white/5 border border-white/10 text-white/60 hover:text-white transition-colors"
>
<X size={24} />
</button>
<div className="flex-1 w-full h-full flex flex-col relative min-h-0">
{state === 'SCANNING' && (
<div className="flex-1 flex flex-col items-center justify-center">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center gap-6"
>
<div className="relative">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
className="w-32 h-32 rounded-full border-2 border-dashed border-amber-500/30"
/>
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 size={48} className="animate-spin text-amber-500" />
</div>
</div>
<div className="text-center space-y-2">
<h2 className="text-2xl font-black text-white uppercase tracking-tight">Analysiere Etikett...</h2>
<p className="text-amber-500 font-black uppercase tracking-widest text-[10px] flex items-center justify-center gap-2">
<Sparkles size={12} /> KI-gestütztes Scanning
</p>
</div>
</motion.div>
</div>
)}
{state === 'ERROR' && (
<div className="flex-1 flex flex-col items-center justify-center">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex flex-col items-center gap-6 p-8 text-center"
>
<div className="w-20 h-20 rounded-full bg-red-500/10 flex items-center justify-center text-red-500">
<AlertCircle size={40} />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-black text-white uppercase tracking-tight">Ups! Da lief was schief.</h2>
<p className="text-white/60 text-sm max-w-xs mx-auto">{error || 'Wir konnten die Flasche leider nicht erkennen. Bitte versuch es mit einem anderen Foto.'}</p>
</div>
<button
onClick={onClose}
className="px-8 py-4 bg-white/5 border border-white/10 rounded-2xl text-white font-black uppercase tracking-widest text-[10px] hover:bg-white/10 transition-all"
>
Schließen
</button>
</motion.div>
</div>
)}
{state === 'EDITOR' && bottleMetadata && (
<motion.div
key="editor"
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -50, opacity: 0 }}
className="flex-1 w-full h-full flex flex-col min-h-0"
>
<TastingEditor
bottleMetadata={bottleMetadata}
image={base64Image}
onSave={handleSaveTasting}
onOpenSessions={() => setIsSessionsOpen(true)}
activeSessionName={activeSession?.name}
activeSessionId={activeSession?.id}
/>
</motion.div>
)}
{(isSaving) && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute inset-0 z-[80] bg-[#0F1014]/80 backdrop-blur-sm flex flex-col items-center justify-center gap-6"
>
<Loader2 size={48} className="animate-spin text-amber-500" />
<h2 className="text-xl font-black text-white uppercase tracking-tight">Speichere Tasting...</h2>
</motion.div>
)}
{state === 'RESULT' && tastingData && bottleMetadata && (
<div className="flex-1 overflow-y-auto">
<div className="min-h-full flex flex-col items-center justify-center py-20 px-6">
<motion.div
key="result"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-full max-w-sm"
>
<ResultCard
data={{
...tastingData,
complexity: tastingData.complexity || 75,
balance: tastingData.balance || 85,
}}
bottleName={bottleMetadata.name || 'Unknown Whisky'}
image={base64Image}
onShare={handleShare}
/>
</motion.div>
</div>
</div>
)}
</div>
<SessionBottomSheet
isOpen={isSessionsOpen}
onClose={() => setIsSessionsOpen(false)}
/>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, Check, ChevronRight, Loader2 } from 'lucide-react';
import { useSession } from '@/context/SessionContext';
import { createClient } from '@/lib/supabase/client';
interface Session {
id: string;
name: string;
}
interface SessionBottomSheetProps {
isOpen: boolean;
onClose: () => void;
}
export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomSheetProps) {
const { activeSession, setActiveSession } = useSession();
const [sessions, setSessions] = useState<Session[]>([]);
const [newSessionName, setNewSessionName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const supabase = createClient();
useEffect(() => {
if (isOpen) {
fetchSessions();
}
}, [isOpen]);
const fetchSessions = async () => {
setIsLoading(true);
const { data, error } = await supabase
.from('tasting_sessions')
.select('id, name')
.order('scheduled_at', { ascending: false })
.limit(10);
if (!error && data) {
setSessions(data);
}
setIsLoading(false);
};
const handleCreateSession = async () => {
if (!newSessionName.trim()) return;
setIsCreating(true);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data, error } = await supabase
.from('tasting_sessions')
.insert([{ name: newSessionName.trim(), user_id: user.id }])
.select()
.single();
if (!error && data) {
setSessions(prev => [data, ...prev]);
setNewSessionName('');
setActiveSession({ id: data.id, name: data.name });
onClose();
}
setIsCreating(false);
};
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[80]"
/>
{/* Sheet */}
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed bottom-0 left-0 right-0 bg-[#1A1B20] border-t border-white/10 rounded-t-[32px] z-[90] p-8 pb-12 max-h-[80vh] overflow-y-auto shadow-[0_-10px_40px_rgba(0,0,0,0.5)]"
>
{/* Drag Handle */}
<div className="w-12 h-1.5 bg-white/10 rounded-full mx-auto mb-8" />
<h2 className="text-2xl font-bold mb-6 font-display text-white">Tasting Session</h2>
{/* New Session Input */}
<div className="relative mb-8">
<input
type="text"
value={newSessionName}
onChange={(e) => setNewSessionName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateSession()}
placeholder="Neue Session erstellen..."
className="w-full bg-white/5 border border-white/10 rounded-2xl py-4 px-6 text-white focus:outline-none focus:border-[#C89D46] transition-colors"
/>
<button
onClick={handleCreateSession}
disabled={isCreating || !newSessionName.trim()}
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 bg-[#C89D46] text-black rounded-xl disabled:opacity-50"
>
{isCreating ? <Loader2 size={20} className="animate-spin" /> : <Plus size={20} />}
</button>
</div>
{/* Session List */}
<div className="space-y-4">
<p className="text-xs font-black uppercase tracking-widest text-white/40 mb-2">Aktuelle Sessions</p>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 size={24} className="animate-spin text-white/20" />
</div>
) : sessions.length > 0 ? (
sessions.map((s) => (
<button
key={s.id}
onClick={() => {
setActiveSession({ id: s.id, name: s.name });
onClose();
}}
className={`w-full flex items-center justify-between p-4 rounded-2xl border transition-all ${activeSession?.id === s.id ? 'bg-[#C89D46]/10 border-[#C89D46] text-[#C89D46]' : 'bg-white/5 border-white/5 hover:border-white/20 text-white'}`}
>
<span className="font-bold">{s.name}</span>
{activeSession?.id === s.id ? <Check size={20} /> : <ChevronRight size={20} className="text-white/20" />}
</button>
))
) : (
<div className="text-center py-8 text-white/30 italic">Keine aktiven Sessions gefunden</div>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,421 @@
'use client';
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { ChevronDown, Wind, Utensils, Droplets, Sparkles, Send, Users, Star, AlertTriangle, Check, Zap } from 'lucide-react';
import { BottleMetadata } from '@/types/whisky';
import TagSelector from './TagSelector';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';
import { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext';
interface TastingEditorProps {
bottleMetadata: BottleMetadata;
image: string | null;
onSave: (data: any) => void;
onOpenSessions: () => void;
activeSessionName?: string;
activeSessionId?: string;
}
export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId }: TastingEditorProps) {
const { t } = useI18n();
const supabase = createClient();
const [rating, setRating] = useState(85);
const [noseNotes, setNoseNotes] = useState('');
const [palateNotes, setPalateNotes] = useState('');
const [finishNotes, setFinishNotes] = useState('');
const [isSample, setIsSample] = useState(false);
// Sliders for evaluation
const [noseScore, setNoseScore] = useState(50);
const [tasteScore, setTasteScore] = useState(50);
const [finishScore, setFinishScore] = useState(50);
const [complexityScore, setComplexityScore] = useState(75);
const [balanceScore, setBalanceScore] = useState(85);
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
const [finishTagIds, setFinishTagIds] = useState<string[]>([]);
const [textureTagIds, setTextureTagIds] = useState<string[]>([]);
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
const buddies = useLiveQuery(() => db.cache_buddies.toArray(), [], [] as any[]);
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
const suggestedTags = bottleMetadata.suggested_tags || [];
const suggestedCustomTags = bottleMetadata.suggested_custom_tags || [];
// Session-based pre-fill and Palette Checker
useEffect(() => {
const fetchSessionData = async () => {
if (activeSessionId) {
const { data: participants } = await supabase
.from('session_participants')
.select('buddy_id')
.eq('session_id', activeSessionId);
if (participants) {
setSelectedBuddyIds(participants.map(p => p.buddy_id));
}
const { data: lastTastings } = await supabase
.from('tastings')
.select(`
id,
tasted_at,
bottles(name, category),
tasting_tags(tags(name))
`)
.eq('session_id', activeSessionId)
.order('tasted_at', { ascending: false })
.limit(1);
if (lastTastings && lastTastings.length > 0) {
const last = lastTastings[0];
const tags = (last as any).tasting_tags?.map((t: any) => t.tags.name) || [];
const category = (last as any).bottles?.category || '';
const text = (tags.join(' ') + ' ' + category).toLowerCase();
const smokyKeywords = ['rauch', 'torf', 'smoke', 'peat', 'islay', 'ash', 'lagerfeuer'];
const isSmoky = smokyKeywords.some(kw => text.includes(kw));
setLastDramInSession({
name: (last as any).bottles?.name || 'Unbekannt',
isSmoky,
timestamp: new Date(last.tasted_at).getTime()
});
}
}
};
fetchSessionData();
}, [activeSessionId, supabase]);
useEffect(() => {
if (lastDramInSession?.isSmoky) {
const now = Date.now();
const diffMin = (now - lastDramInSession.timestamp) / (1000 * 60);
if (diffMin < 20) setShowPaletteWarning(true);
}
}, [lastDramInSession]);
const toggleBuddy = (id: string) => {
setSelectedBuddyIds(prev => prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]);
};
const handleInternalSave = () => {
onSave({
rating,
nose_notes: noseNotes,
palate_notes: palateNotes,
finish_notes: finishNotes,
is_sample: isSample,
buddy_ids: selectedBuddyIds,
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds, ...textureTagIds],
// Visual data for ResultCard
nose: noseScore,
taste: tasteScore,
finish: finishScore,
complexity: complexityScore,
balance: balanceScore
});
};
return (
<div className="flex-1 flex flex-col w-full bg-[#0F1014] h-full overflow-hidden">
{/* Top Context Bar - Flex Child 1 */}
<button
onClick={onOpenSessions}
className="w-full p-6 bg-black/40 backdrop-blur-md border-b border-white/10 flex items-center justify-between group shrink-0"
>
<div className="text-left">
<p className="text-[10px] font-black uppercase tracking-widest text-amber-500">Kontext</p>
<p className="font-bold text-white leading-none mt-1">{activeSessionName || 'Trinkst du in Gesellschaft?'}</p>
</div>
<ChevronDown size={20} className="text-amber-500 group-hover:translate-y-1 transition-transform" />
</button>
{/* Main Scrollable Content - Flex Child 2 */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-8 space-y-12">
{/* Palette Warning */}
{showPaletteWarning && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 bg-amber-500/10 border border-amber-500/20 rounded-3xl flex items-start gap-3"
>
<AlertTriangle size={24} className="text-amber-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-[10px] font-black uppercase tracking-wider text-amber-500">Palette-Checker</p>
<p className="text-xs font-bold text-white leading-relaxed">
Dein letzter Dram "{lastDramInSession?.name}" war torfig. Trink etwas Wasser!
</p>
<button onClick={() => setShowPaletteWarning(false)} className="text-[10px] font-black uppercase text-amber-500 underline mt-2 block">Verstanden</button>
</div>
</motion.div>
)}
{/* Hero Section */}
<div className="flex items-center gap-6">
<div className="w-24 h-32 bg-white/5 rounded-2xl border border-white/10 flex items-center justify-center overflow-hidden shrink-0 shadow-2xl relative">
{image ? (
<img src={image} alt="Bottle Preview" className="w-full h-full object-cover" />
) : (
<div className="text-[10px] text-white/20 uppercase font-black rotate-[-15deg]">No Photo</div>
)}
</div>
<div className="flex-1 min-w-0">
<h1 className="text-3xl font-black text-amber-600 mb-1 truncate leading-none uppercase tracking-tight">
{bottleMetadata.distillery || 'Destillerie'}
</h1>
<p className="text-white text-xl font-bold truncate mb-2">{bottleMetadata.name || 'Unbekannter Malt'}</p>
<p className="text-white/40 text-[10px] font-black uppercase tracking-widest leading-none">
{bottleMetadata.category || 'Whisky'} {bottleMetadata.abv ? `${bottleMetadata.abv}%` : ''} {bottleMetadata.age ? `${bottleMetadata.age}y` : ''}
</p>
</div>
</div>
{/* Rating Slider */}
<div className="space-y-6 bg-white/5 p-8 rounded-[40px] border border-white/10 shadow-inner relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
<Zap size={120} className="text-amber-500" />
</div>
<div className="flex items-center justify-between relative z-10">
<label className="text-xs font-black text-white/40 uppercase tracking-[0.2em] flex items-center gap-2">
<Star size={14} className="text-amber-500 fill-amber-500" />
{t('tasting.rating')}
</label>
<span className="text-4xl font-black text-amber-600 tracking-tighter">{rating}<span className="text-white/20 text-sm ml-1">/100</span></span>
</div>
<input
type="range"
min="0"
max="100"
value={rating}
onChange={(e) => setRating(parseInt(e.target.value))}
className="w-full h-2 bg-zinc-800 rounded-full appearance-none cursor-pointer accent-amber-600 transition-all"
/>
<div className="flex justify-between text-[10px] text-zinc-500 font-black uppercase tracking-widest px-1 relative z-10">
<span>Swill</span>
<span>Dram</span>
<span>Legendary</span>
</div>
<div className="flex gap-3 pt-2 relative z-10">
{['Bottle', 'Sample'].map(type => (
<button
key={type}
onClick={() => setIsSample(type === 'Sample')}
className={`flex-1 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest border transition-all ${(type === 'Sample' ? isSample : !isSample)
? 'bg-zinc-100 border-zinc-100 text-zinc-900 shadow-lg'
: 'bg-transparent border-white/10 text-white/40 hover:border-white/30'
}`}
>
{type}
</button>
))}
</div>
</div>
{/* Evaluation Sliders Area */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<CustomSlider label="Complexity" value={complexityScore} onChange={setComplexityScore} icon={<Sparkles size={18} />} />
<CustomSlider label="Balance" value={balanceScore} onChange={setBalanceScore} icon={<Check size={18} />} />
</div>
{/* Sections */}
<div className="space-y-12 pb-12">
{/* Nose Section */}
<div className="space-y-8 bg-zinc-900/50 p-8 rounded-[40px] border border-white/5">
<div className="flex items-center gap-5">
<div className="w-14 h-14 rounded-3xl bg-amber-500/10 flex items-center justify-center text-amber-500 shrink-0 border border-amber-500/20 shadow-xl">
<Wind size={28} />
</div>
<div>
<h3 className="text-xl font-black text-white uppercase tracking-widest leading-none">{t('tasting.nose')}</h3>
<p className="text-[10px] text-white/30 font-black uppercase tracking-widest mt-2 px-0.5">Aroma & Bouquet</p>
</div>
</div>
<div className="space-y-6">
<CustomSlider label="Nose Intensity" value={noseScore} onChange={setNoseScore} icon={<Sparkles size={16} />} />
<div className="space-y-3">
<p className="text-[10px] font-black text-white/20 uppercase tracking-widest px-1">Tags</p>
<TagSelector
category="nose"
selectedTagIds={noseTagIds}
onToggleTag={(id) => setNoseTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
/>
</div>
<div className="space-y-3">
<p className="text-[10px] font-black text-white/20 uppercase tracking-widest px-1">Eigene Notizen</p>
<textarea
value={noseNotes}
onChange={(e) => setNoseNotes(e.target.value)}
placeholder={t('tasting.notesPlaceholder') || "Wie riecht er?..."}
className="w-full p-6 bg-zinc-900 border-none rounded-3xl text-sm text-zinc-200 focus:ring-2 focus:ring-amber-500 outline-none min-h-[120px] resize-none transition-all placeholder:text-zinc-600 shadow-inner"
/>
</div>
</div>
</div>
{/* Palate Section */}
<div className="space-y-8 bg-zinc-900/50 p-8 rounded-[40px] border border-white/5">
<div className="flex items-center gap-5">
<div className="w-14 h-14 rounded-3xl bg-amber-500/10 flex items-center justify-center text-amber-500 shrink-0 border border-amber-500/20 shadow-xl">
<Utensils size={28} />
</div>
<div>
<h3 className="text-xl font-black text-white uppercase tracking-widest leading-none">{t('tasting.palate')}</h3>
<p className="text-[10px] text-white/30 font-black uppercase tracking-widest mt-2 px-0.5">Geschmack & Textur</p>
</div>
</div>
<div className="space-y-6">
<CustomSlider label="Taste Impact" value={tasteScore} onChange={setTasteScore} icon={<Sparkles size={16} />} />
<div className="space-y-3">
<p className="text-[10px] font-black text-white/20 uppercase tracking-widest px-1">Tags</p>
<TagSelector
category="taste"
selectedTagIds={palateTagIds}
onToggleTag={(id) => setPalateTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
/>
</div>
<div className="space-y-3">
<p className="text-[10px] font-black text-white/20 uppercase tracking-widest px-1">Eigene Notizen</p>
<textarea
value={palateNotes}
onChange={(e) => setPalateNotes(e.target.value)}
placeholder={t('tasting.notesPlaceholder') || "Wie schmeckt er?..."}
className="w-full p-6 bg-zinc-900 border-none rounded-3xl text-sm text-zinc-200 focus:ring-2 focus:ring-amber-500 outline-none min-h-[120px] resize-none transition-all placeholder:text-zinc-600 shadow-inner"
/>
</div>
</div>
</div>
{/* Finish Section */}
<div className="space-y-8 bg-zinc-900/50 p-8 rounded-[40px] border border-white/5">
<div className="flex items-center gap-5">
<div className="w-14 h-14 rounded-3xl bg-amber-500/10 flex items-center justify-center text-amber-500 shrink-0 border border-amber-500/20 shadow-xl">
<Droplets size={28} />
</div>
<div>
<h3 className="text-xl font-black text-white uppercase tracking-widest leading-none">{t('tasting.finish')}</h3>
<p className="text-[10px] text-white/30 font-black uppercase tracking-widest mt-2 px-0.5">Abgang & Nachklang</p>
</div>
</div>
<div className="space-y-6">
<CustomSlider label="Finish Duration" value={finishScore} onChange={setFinishScore} icon={<Sparkles size={16} />} />
<div className="space-y-6 pt-4 border-t border-white/5">
<div className="space-y-3">
<p className="text-[10px] font-black text-white/20 uppercase tracking-widest px-1">Aroma Tags</p>
<TagSelector
category="finish"
selectedTagIds={finishTagIds}
onToggleTag={(id) => setFinishTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
/>
</div>
<div className="space-y-3">
<p className="text-[10px] font-black text-white/20 uppercase tracking-widest px-1">Gefühl & Textur</p>
<TagSelector
category="texture"
selectedTagIds={textureTagIds}
onToggleTag={(id) => setTextureTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
/>
</div>
</div>
<div className="space-y-3">
<p className="text-[10px] font-black text-white/20 uppercase tracking-widest px-1">Eigene Notizen</p>
<textarea
value={finishNotes}
onChange={(e) => setFinishNotes(e.target.value)}
placeholder={t('tasting.notesPlaceholder') || "Der bleibende Eindruck..."}
className="w-full p-6 bg-zinc-900 border-none rounded-3xl text-sm text-zinc-200 focus:ring-2 focus:ring-amber-500 outline-none min-h-[120px] resize-none transition-all placeholder:text-zinc-600 shadow-inner"
/>
</div>
</div>
</div>
{/* Buddy Selection */}
{buddies && buddies.length > 0 && (
<div className="space-y-8 bg-zinc-900/50 p-8 rounded-[40px] border border-white/5">
<div className="flex items-center gap-5">
<div className="w-14 h-14 rounded-3xl bg-amber-500/10 flex items-center justify-center text-amber-500 shrink-0 border border-amber-500/20 shadow-xl">
<Users size={28} />
</div>
<div>
<h3 className="text-xl font-black text-white uppercase tracking-widest leading-none">Mit wem trinkst du?</h3>
<p className="text-[10px] text-white/30 font-black uppercase tracking-widest mt-2 px-0.5">Gesellschaft & Buddies</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{buddies.map(buddy => (
<button
key={buddy.id}
onClick={() => toggleBuddy(buddy.id)}
className={`px-5 py-3 rounded-2xl text-[10px] font-black uppercase transition-all border flex items-center gap-2 ${selectedBuddyIds.includes(buddy.id)
? 'bg-amber-600 border-amber-600 text-white shadow-lg'
: 'bg-transparent border-white/10 text-white/40 hover:border-white/30'
}`}
>
{selectedBuddyIds.includes(buddy.id) && <Check size={14} />}
{buddy.name}
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Sticky Footer - Flex Child 3 */}
<div className="w-full p-8 bg-black/60 backdrop-blur-xl border-t border-white/10 shrink-0">
<button
onClick={handleInternalSave}
className="w-full py-5 bg-amber-600 text-white rounded-3xl font-black uppercase tracking-widest text-xs flex items-center justify-center gap-4 shadow-xl active:scale-[0.98] transition-all"
>
<Send size={20} />
{t('tasting.saveTasting')}
<div className="ml-auto bg-black/20 px-3 py-1 rounded-full text-[10px] font-black text-amber-200">{rating}</div>
</button>
</div>
</div>
);
}
function CustomSlider({ label, value, onChange, icon }: any) {
return (
<div className="space-y-4 bg-zinc-900/50 p-6 rounded-3xl border border-white/5 shadow-inner">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-white/40">
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-500">
{icon}
</div>
<span className="text-[10px] font-black uppercase tracking-[0.2em]">{label}</span>
</div>
<span className="text-2xl font-black text-amber-600 tracking-tighter">{value}</span>
</div>
<input
type="range"
min="0"
max="100"
value={value}
onChange={(e) => onChange(parseInt(e.target.value))}
className="w-full h-1.5 bg-zinc-800 rounded-full appearance-none cursor-pointer accent-amber-600 transition-all"
/>
</div>
);
}