From b57f5dc2adaa21b3ef97e8bf34e9773b999d386e Mon Sep 17 00:00:00 2001 From: robin Date: Sun, 21 Dec 2025 22:29:16 +0100 Subject: [PATCH] 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). --- .aiideas | 179 +++++----- package.json | 2 + pnpm-lock.yaml | 346 +++++++++++++++++++ public/sw.js | 15 +- src/app/globals.css | 37 +- src/app/layout.tsx | 7 +- src/app/page.tsx | 22 +- src/components/FloatingScannerButton.tsx | 65 ++++ src/components/ResultCard.tsx | 109 ++++++ src/components/ScanAndTasteFlow.tsx | 255 ++++++++++++++ src/components/SessionBottomSheet.tsx | 144 ++++++++ src/components/TastingEditor.tsx | 421 +++++++++++++++++++++++ 12 files changed, 1482 insertions(+), 120 deletions(-) create mode 100644 src/components/FloatingScannerButton.tsx create mode 100644 src/components/ResultCard.tsx create mode 100644 src/components/ScanAndTasteFlow.tsx create mode 100644 src/components/SessionBottomSheet.tsx create mode 100644 src/components/TastingEditor.tsx diff --git a/.aiideas b/.aiideas index c83eb17..228bb4d 100644 --- a/.aiideas +++ b/.aiideas @@ -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. - - 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 - }); +Theme: Strict Dark Mode. - const rawContent = chatResponse.choices?.[0].message.content; - - if (!rawContent) throw new Error("Keine Antwort von Pixtral"); + Background: #0F1014(Deep Anthracite / Black) - // JSON parsen - return JSON.parse(rawContent as string); +Surface / Cards: #1A1B20(Lighter Anthracite) - } catch (error) { - console.error("Pixtral Error:", error); - return null; // Fallback auslösen - } -} + Primary Accent: #C89D46(Whisky Gold / Amber) -3. Der "A/B-Switcher" (So nutzt du beides) +Text: Sans - Serif(Inter) für UI, Serif(Playfair Display) für Überschriften / Namen. -In deiner Haupt-Logik (app/actions/scan.ts) kannst du jetzt einfach umschalten oder Pixtral als Fallback nutzen, wenn Gemini zickt (oder andersrum). -TypeScript + Stil: "Premium & Warm".Runde Ecken(rounded - 2xl), Glassmorphism für Overlays(backdrop - blur - md, bg - white / 5), feine Borders(border - white / 10). -'use server' -import { scanWithGemini } from './scan-gemini'; // Deine bestehende Funktion -import { scanWithPixtral } from './scan-mistral'; +2. Der Flow(Schritt für Schritt Implementierung) -export async function scanBottle(formData: FormData) { - // ... Bild zu Base64 konvertieren ... - const base64 = "..."; - const mime = "image/jpeg"; +Bitte implementiere folgende Views / Components als zusammenhängenden Flow: +A.Der Entry Point(Floating Action Button) - // STRATEGIE A: Der "Qualitäts-Check" - // Wir nutzen standardmäßig Gemini, aber Pixtral als EU-Option - - let result; - const useEuModel = process.env.USE_EU_MODEL === 'true'; // Schalter in .env + Erstelle einen prominenten, schwebenden Button(unten mittig, fixed), der über dem Dashboard liegt. - 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); - } + Icon: Kamera - Symbol. - // 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); - } + 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. - // ... weiter mit Supabase Caching & Brave Search ... - return result; -} + B.Der Tasting Editor(Main Component) -Pixtral vs. Gemini 3 Flash (Dein Check) +Dies ist der wichtigste Screen.Layout - Struktur: -Achte beim Testen auf diese Feinheiten: + Top Bar(Sticky): - Helle Schrift auf dunklem Grund: Hier ist Gemini oft aggressiver und liest besser. Pixtral ist manchmal vorsichtiger. + Zeige einen "Context Indicator". - Schreibschrift (Signatory Vintage Abfüllungen): Pixtral Large ist hier erstaunlich gut, fast besser als Gemini, da es Handschrift extrem gut kann. + Logik: Zeige Text "Trinkst du in Gesellschaft? + Session wählen". - JSON-Struktur: Dank responseFormat: { type: 'json_object' } sollten beide Modelle sehr sauberen Code liefern. + Interaction: Klick öffnet ein Bottom Sheet(siehe C).Wenn eine Session gewählt wurde, zeige: "Session: [Name]". + + Hero Section: + + Zeige das(gemockte) Foto der Flasche links. + + 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"). \ No newline at end of file diff --git a/package.json b/package.json index faa687a..9f33ed9 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85ba918..ec78466 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/public/sw.js b/public/sw.js index 86505f8..d0c4438 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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( diff --git a/src/app/globals.css b/src/app/globals.css index 50baef2..3910112 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,28 +2,37 @@ @tailwind components; @tailwind utilities; -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { +@layer base { :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; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7be7b66..5efb9d5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - + diff --git a/src/app/page.tsx b/src/app/page.tsx index 4201b27..5675dab 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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(null); const { t } = useI18n(); const { activeSession } = useSession(); + const [isFlowOpen, setIsFlowOpen] = useState(false); + const [capturedImage, setCapturedImage] = useState(null); + + const handleImageSelected = (base64: string) => { + setCapturedImage(base64); + setIsFlowOpen(true); + }; useEffect(() => { // Check session @@ -200,7 +208,6 @@ export default function Home() {
-
@@ -232,10 +239,17 @@ export default function Home() {
) : ( - + bottles.length > 0 && )}
+ + + setIsFlowOpen(false)} + base64Image={capturedImage} + /> ); } diff --git a/src/components/FloatingScannerButton.tsx b/src/components/FloatingScannerButton.tsx new file mode 100644 index 0000000..34c215e --- /dev/null +++ b/src/components/FloatingScannerButton.tsx @@ -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(null); + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( +
+ + 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 */} + + + + + {/* Pulse ring */} + + +
+ ); +} diff --git a/src/components/ResultCard.tsx b/src/components/ResultCard.tsx new file mode 100644 index 0000000..44443f3 --- /dev/null +++ b/src/components/ResultCard.tsx @@ -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 ( + + {/* The Trading Card */} +
+ {/* Bottle Image with Vignette */} +
+ {image ? ( + {bottleName} + ) : ( +
No Image
+ )} +
+
+ + {/* Content Overlay */} +
+
+

Tasting Record

+

{bottleName}

+
+ + {/* Radar Chart Area */} +
+ + + + + + + +
+ +
+
+ + Verified Report +
+
+
+ + {/* Score Badge */} +
+ {displayScore} + Score +
+ + {/* Decorative Elements */} +
+ +
+
+ + {/* Share Button */} + + + ); +} diff --git a/src/components/ScanAndTasteFlow.tsx b/src/components/ScanAndTasteFlow.tsx new file mode 100644 index 0000000..65cddda --- /dev/null +++ b/src/components/ScanAndTasteFlow.tsx @@ -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('IDLE'); + const [isSessionsOpen, setIsSessionsOpen] = useState(false); + const { activeSession } = useSession(); + const [tastingData, setTastingData] = useState(null); + const [bottleMetadata, setBottleMetadata] = useState(null); + const [error, setError] = useState(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 ( + + + {/* Close Button */} + + +
+ {state === 'SCANNING' && ( +
+ +
+ +
+ +
+
+
+

Analysiere Etikett...

+

+ KI-gestütztes Scanning +

+
+
+
+ )} + + {state === 'ERROR' && ( +
+ +
+ +
+
+

Ups! Da lief was schief.

+

{error || 'Wir konnten die Flasche leider nicht erkennen. Bitte versuch es mit einem anderen Foto.'}

+
+ +
+
+ )} + + {state === 'EDITOR' && bottleMetadata && ( + + setIsSessionsOpen(true)} + activeSessionName={activeSession?.name} + activeSessionId={activeSession?.id} + /> + + )} + + {(isSaving) && ( + + +

Speichere Tasting...

+
+ )} + + {state === 'RESULT' && tastingData && bottleMetadata && ( +
+
+ + + +
+
+ )} +
+ + setIsSessionsOpen(false)} + /> +
+
+ ); +} diff --git a/src/components/SessionBottomSheet.tsx b/src/components/SessionBottomSheet.tsx new file mode 100644 index 0000000..7020e56 --- /dev/null +++ b/src/components/SessionBottomSheet.tsx @@ -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([]); + 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 ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Sheet */} + + {/* Drag Handle */} +
+ +

Tasting Session

+ + {/* New Session Input */} +
+ 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" + /> + +
+ + {/* Session List */} +
+

Aktuelle Sessions

+ {isLoading ? ( +
+ +
+ ) : sessions.length > 0 ? ( + sessions.map((s) => ( + + )) + ) : ( +
Keine aktiven Sessions gefunden
+ )} +
+ + + )} + + ); +} diff --git a/src/components/TastingEditor.tsx b/src/components/TastingEditor.tsx new file mode 100644 index 0000000..7ad7da8 --- /dev/null +++ b/src/components/TastingEditor.tsx @@ -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([]); + const [palateTagIds, setPalateTagIds] = useState([]); + const [finishTagIds, setFinishTagIds] = useState([]); + const [textureTagIds, setTextureTagIds] = useState([]); + const [selectedBuddyIds, setSelectedBuddyIds] = useState([]); + + 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 ( +
+ {/* Top Context Bar - Flex Child 1 */} + + + {/* Main Scrollable Content - Flex Child 2 */} +
+ {/* Palette Warning */} + {showPaletteWarning && ( + + +
+

Palette-Checker

+

+ Dein letzter Dram "{lastDramInSession?.name}" war torfig. Trink etwas Wasser! +

+ +
+
+ )} + + {/* Hero Section */} +
+
+ {image ? ( + Bottle Preview + ) : ( +
No Photo
+ )} +
+
+

+ {bottleMetadata.distillery || 'Destillerie'} +

+

{bottleMetadata.name || 'Unbekannter Malt'}

+

+ {bottleMetadata.category || 'Whisky'} {bottleMetadata.abv ? `• ${bottleMetadata.abv}%` : ''} {bottleMetadata.age ? `• ${bottleMetadata.age}y` : ''} +

+
+
+ + {/* Rating Slider */} +
+
+ +
+
+ + {rating}/100 +
+ setRating(parseInt(e.target.value))} + className="w-full h-2 bg-zinc-800 rounded-full appearance-none cursor-pointer accent-amber-600 transition-all" + /> +
+ Swill + Dram + Legendary +
+ +
+ {['Bottle', 'Sample'].map(type => ( + + ))} +
+
+ + {/* Evaluation Sliders Area */} +
+ } /> + } /> +
+ + {/* Sections */} +
+ {/* Nose Section */} +
+
+
+ +
+
+

{t('tasting.nose')}

+

Aroma & Bouquet

+
+
+ +
+ } /> + +
+

Tags

+ setNoseTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])} + suggestedTagNames={suggestedTags} + suggestedCustomTagNames={suggestedCustomTags} + /> +
+
+

Eigene Notizen

+