Compare commits

..

27 Commits

Author SHA1 Message Date
467bd88f95 fix: SSR error - check for browser before accessing navigator
- Add typeof window check in I18nContext useEffect
- Safely access navigator.language with optional chaining
2026-01-19 23:41:45 +01:00
d75a30f459 fix: Two-row header layout for mobile
- Row 1: Logo + Logout (always fits)
- Row 2: Session info (left) + Status badges (right)
- No more horizontal scrolling on mobile
2026-01-19 23:31:49 +01:00
06fa208dd8 feat: Auto browser language detection, remove LanguageSwitcher
- Update I18nContext to auto-detect browser language
- Default to English, switch to German if browser is German
- Remove LanguageSwitcher from guest and authenticated views
- Remove DramOfTheDay from header
- Cleaner, mobile-friendly header layout
2026-01-19 23:26:55 +01:00
883f76e488 fix: Restore logout button, hide DramOfTheDay on mobile
- Keep logout button in header (user feedback)
- Hide DramOfTheDay on mobile to save space (hidden sm:block)
- Keep responsive flex-wrap and reduced gaps
2026-01-19 23:23:28 +01:00
d8a9e9fd0a fix: Make header responsive on mobile
- Add flex-wrap to header right section
- Hide LanguageSwitcher on small screens (hidden sm:block)
- Replace logout text with Settings icon button
- Add shrink-0 to prevent logo compression
- Reduce gaps on mobile (gap-1 sm:gap-2)
2026-01-19 23:21:32 +01:00
5c00be59f1 feat: Add UX optimizations - skeletons and optimistic hooks
- Add Skeletons.tsx with TastingListSkeleton, ChartSkeleton, etc.
- Add useOptimistic.ts hooks for React 19 optimistic updates
- Update stats page to use skeleton loading instead of spinner
- Remove force-dynamic exports (12 files) for SSG compatibility
- Note: PPR (cacheComponents) tested but reverted - requires RSC-first refactor
2026-01-19 23:01:00 +01:00
004698b604 feat: Enable React Compiler for automatic memoization
- Install babel-plugin-react-compiler@1.0.0
- Add reactCompiler: true to next.config.mjs
- React 19 compiler will auto-optimize useMemo/useCallback
2026-01-19 22:47:01 +01:00
096daffb3e feat: Upgrade to Tailwind CSS v4.1.18
- Migrate from tailwindcss v3.3 to v4.1.18
- Replace @tailwind directives with @import 'tailwindcss'
- Move custom colors to @theme block in globals.css
- Convert custom utilities to @utility syntax
- Update PostCSS config to use @tailwindcss/postcss
- Remove autoprefixer (now built-in)
2026-01-19 22:26:21 +01:00
b179a88d4c chore: Prepare for Tailwind v4 migration 2026-01-19 22:17:48 +01:00
2bf0ac0f3e chore: Implement route protection and security enhancements
- Add src/middleware.ts for global route proection
- Whitelist public routes (/, /auth/*, /splits/[slug])
- Add redirect logic to Home page for returning users
- Fix minor lint issues in Home page
2026-01-19 12:36:35 +01:00
bb9a78f755 feat: Revamp Analytics Dashboard
- Replace StatsDashboard with new AnalyticsDashboard component
- Add Recharts charts: Category Pie, ABV Area, Age Bar, Top Distilleries Bar, Price vs Quality Scatter
- Update fetching logic to include tasting ratings for analysis
- Enhance UI with new KPI cards and dark mode styling
2026-01-19 12:06:47 +01:00
45f562e2ce feat: Add admin page for tasting sessions
- Create /admin/sessions page showing all sessions from all users
- Stats: total sessions, active, hosts, participants, tastings
- Filter by host, status (active/ended)
- Show session duration, participant count, tasting count
- Add 'All Sessions' link to admin dashboard
2026-01-19 11:54:42 +01:00
5914ef2ac8 fix: Use correct column names for tastings table
- Change nose/palate/finish/notes to nose_notes/palate_notes/finish_notes
- Update query, interface, and all references in admin tastings page
2026-01-19 11:49:49 +01:00
948c70c7f2 fix: Use native img tags for admin pages
- Replace next/image with native img tags in admin bottles, splits, tastings
- Remove hardcoded Supabase hostname from next.config.mjs
- Native img works with any hostname without config changes on deploy
2026-01-19 11:46:26 +01:00
3c02d33531 fix: Add Supabase storage to Next.js images config
Allow next/image to optimize images from supaapi.cloud.fluffigewolke.de storage bucket.
2026-01-19 11:42:59 +01:00
6320cb14e5 fix: Service Worker always returns valid Response
- Fixed fetch handler that could return undefined instead of Response
- Changed from stale-while-revalidate to network-first with cache fallback
- Always return proper 503 Response when offline and no cache available
- Bump cache version to v21 to force SW update
2026-01-19 11:39:40 +01:00
f9192f2228 feat: Add admin pages for splits and tastings
- Create /admin/splits page showing all bottle splits
  - Stats: total splits, active, hosts, participants, volume
  - Filter by host, status (active/closed)
  - Progress bars showing reservation status

- Create /admin/tastings page showing all tasting notes
  - Stats: total tastings, users, avg rating, with notes, today
  - Filter by user, rating
  - Notes preview with star ratings

- Add navigation links to admin dashboard
2026-01-19 11:37:00 +01:00
ef64c89e9b feat: Add admin page to view all bottles from all users
- Create /admin/bottles page with comprehensive bottle overview
- Show stats: total bottles, total users, avg rating, top distilleries
- AdminBottlesList with search, filter by user/category, sorting
- Display bottle images, ratings, user info, and dates
- Add 'All Bottles' link to admin dashboard
2026-01-19 11:31:29 +01:00
c047966b43 feat: Add Admin UI for banner management
- Create /admin/banners page with full CRUD operations
- Add BannerManager.tsx client component for interactive management
- Add banner-actions.ts server actions (create, update, toggle, delete)
- Add 'Manage Banners' link to admin dashboard
- Features: image preview, activate/deactivate toggle, edit inline
2026-01-19 11:23:46 +01:00
169fa0ad63 style: Increase contrast for better readability
Override Tailwind zinc scale with brighter values for improved text contrast on dark backgrounds. Targets older users who may have difficulty reading gray-on-black text.

- zinc-500: #71717a → #8a8a95 (+20% brightness)
- zinc-600: #52525b → #6b6b75 (+25% brightness)
- zinc-400/700 also adjusted proportionally
2026-01-19 11:15:00 +01:00
886e5c121f feat: Complete GlitchTip error monitoring integration
- Add sentry.client.config.ts, sentry.server.config.ts, sentry.edge.config.ts
- Create /api/glitchtip-tunnel route for bypassing ad blockers
- Add SentryInit component for client-side initialization
- Add instrumentation.ts for server/edge initialization
- Integrate Sentry.captureException in error handlers
- Remove test button after verifying integration works

Env vars: NEXT_PUBLIC_GLITCHTIP_DSN, GLITCHTIP_DSN
2026-01-19 09:18:58 +01:00
ef2b9dfabf feat: Add GlitchTip error monitoring with Sentry SDK
- Install @sentry/nextjs
- Add sentry.client.config.ts, sentry.server.config.ts, sentry.edge.config.ts
- Conditional initialization based on GLITCHTIP_DSN env variable
- Add /api/glitchtip-tunnel route to bypass ad blockers
- Update next.config.mjs with withSentryConfig wrapper
- Integrate Sentry.captureException in error.tsx and global-error.tsx
- Support env vars: GLITCHTIP_DSN, NEXT_PUBLIC_GLITCHTIP_DSN, GLITCHTIP_URL, etc.
2026-01-18 21:33:59 +01:00
489b975911 feat: Improve Sessions and Buddies pages with modern UI
- Rename 'Events' to 'Tastings' in nav
- Sessions page: Create, list, delete sessions with sticky header
- Buddies page: Add, search, delete buddies with linked/unlinked sections
- Both pages match new home view design language
2026-01-18 21:24:53 +01:00
1d02079df3 fix: Fix navigation links and restore LanguageSwitcher
- Add missing nav keys to types.ts, de.ts, en.ts (sessions, buddies, stats, wishlist)
- Add LanguageSwitcher back to authenticated header
- Create /sessions page with SessionList
- Create /buddies page with BuddyList
- Create /stats page with StatsDashboard
- Create /wishlist placeholder page
2026-01-18 21:18:25 +01:00
d109dfad0e fix: Log empty OCR results to help debug TextDetector availability
- Cascade OCR now saves a log entry even when no text is detected
- Logs ocrMethod as 'text_detector' or 'not_supported' for debugging
- Helps identify when browsers block the TextDetector API
2026-01-18 20:57:41 +01:00
9ba0825bcd feat: Add Spotify-style backdrop, Cascade OCR, Smart Scan Flow & OCR Dashboard
- BottleGrid: Implement blurred backdrop effect for bottle cards
- Cascade OCR: TextDetector → RegEx → Fuzzy Match → window.ai pipeline
- Smart Scan: Native OCR for Android, Live Text fallback for iOS
- OCR Dashboard: Admin page at /admin/ocr-logs with stats and scan history
- Features: Add feature flags in src/config/features.ts
- SQL: Add ocr_logs table migration
- Services: Update analyze-bottle to use OpenRouter, add save-ocr-log
2026-01-18 20:38:48 +01:00
83e852e5fb Fix onboarding tutorial visibility and apply security remediation tasks (ABV sanitization, i18n hardening, regex escaping) 2026-01-06 13:19:05 +01:00
127 changed files with 10501 additions and 1455 deletions

2
.semgrepignore Normal file
View File

@@ -0,0 +1,2 @@
# Ignore console.log formatting warnings
javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring

1
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

6
Logs/cpu.log Normal file
View File

@@ -0,0 +1,6 @@
1/6 22:21:38.843 vendor: AuthenticAMD
1/6 22:21:38.843 branding: AMD Ryzen 7 5700X3D 8-Core Processor
1/6 22:21:38.843 features: lahf64 cmpxchg16b sse sse2 sse3 ssse3 sse41 sse42 avx avx2 aesni clmul sha rdrand
1/6 22:21:38.843 sockets: 1
1/6 22:21:38.843 cores: 8
1/6 22:21:38.843 threads: 16

View File

@@ -0,0 +1,5 @@
-- Add flavor_profile column to tastings table
ALTER TABLE public.tastings
ADD COLUMN IF NOT EXISTS flavor_profile JSONB;
COMMENT ON COLUMN public.tastings.flavor_profile IS 'Stores radar chart scores for smoky, fruity, spicy, sweet, and floral (0-100).';

View File

@@ -160,7 +160,7 @@
"oily", "oily",
"medium-full body", "medium-full body",
"silky", "silky",
"rounded", "rounded-sm",
"balanced", "balanced",
"smooth", "smooth",
"slightly warming", "slightly warming",
@@ -774,7 +774,7 @@
], ],
"texture": [ "texture": [
"oily and waxy", "oily and waxy",
"creamy and rounded", "creamy and rounded-sm",
"medium weight", "medium weight",
"silky with a gentle grip", "silky with a gentle grip",
"well-structured balancing sweetness and dryness", "well-structured balancing sweetness and dryness",
@@ -880,7 +880,7 @@
"oily", "oily",
"viscous", "viscous",
"silky", "silky",
"rounded", "rounded-sm",
"balanced", "balanced",
"medium-bodied", "medium-bodied",
"slightly-dry", "slightly-dry",
@@ -1038,7 +1038,7 @@
"polished", "polished",
"well-integrated", "well-integrated",
"slightly oily", "slightly oily",
"rounded", "rounded-sm",
"smooth", "smooth",
"lively" "lively"
] ]
@@ -1235,7 +1235,7 @@
"softly effervescent", "softly effervescent",
"polished oak feel", "polished oak feel",
"refreshingly bright", "refreshingly bright",
"lean yet rounded" "lean yet rounded-sm"
] ]
}, },
"Aultmore": { "Aultmore": {
@@ -1527,7 +1527,7 @@
"creamy", "creamy",
"silky", "silky",
"oily", "oily",
"rounded", "rounded-sm",
"polished", "polished",
"mellow", "mellow",
"gentle", "gentle",
@@ -1588,7 +1588,7 @@
"Lingering sea-salt", "Lingering sea-salt",
"Sweet lemon", "Sweet lemon",
"Lime zest", "Lime zest",
"Dried apple ring", "Dried apple ring-3",
"Vanilla oak", "Vanilla oak",
"Gentle oak tannin", "Gentle oak tannin",
"Black pepper", "Black pepper",
@@ -1883,7 +1883,7 @@
"polished", "polished",
"slightly resinous", "slightly resinous",
"lively oak spice", "lively oak spice",
"rounded", "rounded-sm",
"structured", "structured",
"sappy (young oak feel)" "sappy (young oak feel)"
] ]
@@ -2001,7 +2001,7 @@
"oily", "oily",
"creamy", "creamy",
"silky", "silky",
"rounded", "rounded-sm",
"medium-bodied", "medium-bodied",
"polished", "polished",
"gently spirity", "gently spirity",
@@ -2156,7 +2156,7 @@
"oily", "oily",
"creamy", "creamy",
"medium to full-bodied", "medium to full-bodied",
"rounded", "rounded-sm",
"silky", "silky",
"slightly waxy", "slightly waxy",
"balanced", "balanced",
@@ -2415,7 +2415,7 @@
"grippy", "grippy",
"structured tannins", "structured tannins",
"cask-driven", "cask-driven",
"rounded", "rounded-sm",
"balanced", "balanced",
"robust", "robust",
"powerful", "powerful",
@@ -2544,7 +2544,7 @@
"waxy", "waxy",
"silky", "silky",
"rich", "rich",
"rounded", "rounded-sm",
"medium-to-full bodied", "medium-to-full bodied",
"coating", "coating",
"smooth", "smooth",
@@ -2781,7 +2781,7 @@
"light-to-medium body", "light-to-medium body",
"soft", "soft",
"polished", "polished",
"rounded", "rounded-sm",
"well-structured", "well-structured",
"approachable", "approachable",
"gentle", "gentle",
@@ -2868,7 +2868,7 @@
"creamy toffee and caramelized sugar", "creamy toffee and caramelized sugar",
"vanilla oak spicing (cinnamon, white pepper)", "vanilla oak spicing (cinnamon, white pepper)",
"nutty undertones (almond, hazelnut)", "nutty undertones (almond, hazelnut)",
"soft rounded bitterness (cocoa nibs, orange marmalade)", "soft rounded-sm bitterness (cocoa nibs, orange marmalade)",
"mild stone fruit (apricot) and dried sultana", "mild stone fruit (apricot) and dried sultana",
"waxy orchard skin texture and gentle oiliness", "waxy orchard skin texture and gentle oiliness",
"cereal maltiness with a slight biscuit edge", "cereal maltiness with a slight biscuit edge",
@@ -2888,7 +2888,7 @@
], ],
"texture": [ "texture": [
"waxy mouthfeel that softens with age", "waxy mouthfeel that softens with age",
"creamy and rounded", "creamy and rounded-sm",
"oily yet clean, not heavy", "oily yet clean, not heavy",
"slightly coating but not syrupy", "slightly coating but not syrupy",
"polished oak structure underneath", "polished oak structure underneath",
@@ -2992,7 +2992,7 @@
"polished oak", "polished oak",
"soft tannins", "soft tannins",
"gentle heat", "gentle heat",
"rounded", "rounded-sm",
"clean", "clean",
"crisp", "crisp",
"gliding", "gliding",
@@ -3591,7 +3591,7 @@
"silky", "silky",
"medium-bodied", "medium-bodied",
"slightly oily", "slightly oily",
"rounded", "rounded-sm",
"well-integrated alcohol", "well-integrated alcohol",
"polished tannins", "polished tannins",
"sprightly", "sprightly",
@@ -3820,7 +3820,7 @@
"viscous", "viscous",
"creamy", "creamy",
"velvety", "velvety",
"rounded mouthfeel", "rounded-sm mouthfeel",
"slightly chewy", "slightly chewy",
"resinous", "resinous",
"warming", "warming",
@@ -4776,7 +4776,7 @@
"silky", "silky",
"oily", "oily",
"creamy", "creamy",
"rounded", "rounded-sm",
"balanced", "balanced",
"medium-bodied", "medium-bodied",
"spry", "spry",
@@ -5066,7 +5066,7 @@
"well-balanced", "well-balanced",
"medium-to-full body", "medium-to-full body",
"coating", "coating",
"rounded", "rounded-sm",
"polished", "polished",
"lively", "lively",
"gentle", "gentle",
@@ -5424,7 +5424,7 @@
"smooth", "smooth",
"creamy", "creamy",
"soft", "soft",
"rounded", "rounded-sm",
"medium-bodied", "medium-bodied",
"polished", "polished",
"well-balanced", "well-balanced",
@@ -5602,7 +5602,7 @@
"quiet floral echo (heather)" "quiet floral echo (heather)"
], ],
"texture": [ "texture": [
"creamy and rounded", "creamy and rounded-sm",
"silky with gentle oiliness", "silky with gentle oiliness",
"medium body, never heavy", "medium body, never heavy",
"polished oak grip (fine-grained tannins)", "polished oak grip (fine-grained tannins)",
@@ -5861,7 +5861,7 @@
"oily", "oily",
"creamy", "creamy",
"velvety", "velvety",
"rounded", "rounded-sm",
"medium-bodied", "medium-bodied",
"approachable", "approachable",
"slightly waxy", "slightly waxy",
@@ -6002,7 +6002,7 @@
"smooth", "smooth",
"silky", "silky",
"slightly waxy", "slightly waxy",
"rounded", "rounded-sm",
"gentle", "gentle",
"well-balanced", "well-balanced",
"not overly viscous", "not overly viscous",
@@ -6070,7 +6070,7 @@
"silky", "silky",
"oily (light)", "oily (light)",
"soft", "soft",
"rounded", "rounded-sm",
"clean", "clean",
"crisp", "crisp",
"approachable", "approachable",
@@ -6230,7 +6230,7 @@
"waxy", "waxy",
"silky", "silky",
"medium-weight", "medium-weight",
"rounded", "rounded-sm",
"well-structured", "well-structured",
"balanced", "balanced",
"clean", "clean",
@@ -6427,7 +6427,7 @@
"waxy-coated", "waxy-coated",
"chalky/mineral grip", "chalky/mineral grip",
"well-structured", "well-structured",
"rounded and balanced", "rounded-sm and balanced",
"smooth entry", "smooth entry",
"zesty lift", "zesty lift",
"slightly drying oak" "slightly drying oak"
@@ -6507,7 +6507,7 @@
"waxy", "waxy",
"silky", "silky",
"medium-bodied", "medium-bodied",
"rounded", "rounded-sm",
"slightly resinous", "slightly resinous",
"prickly spice", "prickly spice",
"chewy", "chewy",
@@ -7021,7 +7021,7 @@
"oily", "oily",
"waxy", "waxy",
"coastal mouthfeel", "coastal mouthfeel",
"rounded", "rounded-sm",
"balanced", "balanced",
"creamy", "creamy",
"silky", "silky",
@@ -7265,7 +7265,7 @@
"smooth and approachable", "smooth and approachable",
"medium-bodied", "medium-bodied",
"slightly oily", "slightly oily",
"soft and rounded", "soft and rounded-sm",
"creamy", "creamy",
"well-integrated alcohol", "well-integrated alcohol",
"gentle spice", "gentle spice",
@@ -7648,7 +7648,7 @@
"Slightly prickly", "Slightly prickly",
"Weighty yet agile", "Weighty yet agile",
"Chewy", "Chewy",
"Soft and rounded", "Soft and rounded-sm",
"Peppery heat" "Peppery heat"
] ]
}, },
@@ -7949,7 +7949,7 @@
"waxy / coating", "waxy / coating",
"oily", "oily",
"medium-bodied", "medium-bodied",
"softly rounded", "softly rounded-sm",
"creamy", "creamy",
"silky", "silky",
"slightly drying", "slightly drying",
@@ -8003,7 +8003,7 @@
"oily and resinous", "oily and resinous",
"creamy and luscious", "creamy and luscious",
"viscous mouthfeel", "viscous mouthfeel",
"rounded and polished", "rounded-sm and polished",
"silky with grip", "silky with grip",
"balanced warmth", "balanced warmth",
"velvety oak", "velvety oak",
@@ -8145,7 +8145,7 @@
"silky", "silky",
"creamy", "creamy",
"oily", "oily",
"rounded", "rounded-sm",
"plush", "plush",
"well-integrated", "well-integrated",
"velvety", "velvety",
@@ -8258,12 +8258,12 @@
"dry, gently cereal/biscuity tail", "dry, gently cereal/biscuity tail",
"clean, crisp acidity (a touch of citrus)", "clean, crisp acidity (a touch of citrus)",
"overall dryness in later stages", "overall dryness in later stages",
"no sulphur, very smooth and rounded" "no sulphur, very smooth and rounded-sm"
], ],
"texture": [ "texture": [
"smooth and approachable", "smooth and approachable",
"medium-bodied", "medium-bodied",
"creamy and rounded", "creamy and rounded-sm",
"silky and polished", "silky and polished",
"slightly oily in the glass but light on the palate", "slightly oily in the glass but light on the palate",
"well-balanced", "well-balanced",
@@ -8319,7 +8319,7 @@
"smooth with a gentle prickle", "smooth with a gentle prickle",
"soft and approachable", "soft and approachable",
"salty tactile impression", "salty tactile impression",
"rounded oak structure without heaviness", "rounded-sm oak structure without heaviness",
"clean, brisk progression across the palate" "clean, brisk progression across the palate"
] ]
}, },
@@ -8758,7 +8758,7 @@
"silky", "silky",
"slightly chewy", "slightly chewy",
"polished", "polished",
"rounded", "rounded-sm",
"balanced", "balanced",
"approachable", "approachable",
"maritime grip", "maritime grip",
@@ -9322,7 +9322,7 @@
"creamy", "creamy",
"oily", "oily",
"waxy", "waxy",
"rounded", "rounded-sm",
"smooth", "smooth",
"soft", "soft",
"mouth-coating", "mouth-coating",
@@ -9687,7 +9687,7 @@
"polished", "polished",
"slightly oily", "slightly oily",
"creamy", "creamy",
"rounded", "rounded-sm",
"well-balanced", "well-balanced",
"slightly drying", "slightly drying",
"crisp", "crisp",
@@ -9850,7 +9850,7 @@
"silky", "silky",
"creamy", "creamy",
"velvety", "velvety",
"rounded", "rounded-sm",
"medium-bodied", "medium-bodied",
"oily", "oily",
"polished", "polished",
@@ -9919,7 +9919,7 @@
"Clean and medium length", "Clean and medium length",
"Subtle coconut", "Subtle coconut",
"A touch of herbal freshness", "A touch of herbal freshness",
"Smooth and rounded close", "Smooth and rounded-sm close",
"Fading floral note" "Fading floral note"
], ],
"texture": [ "texture": [
@@ -9929,7 +9929,7 @@
"Soft and approachable", "Soft and approachable",
"Slightly oily", "Slightly oily",
"Polished", "Polished",
"Even and rounded", "Even and rounded-sm",
"Mellow", "Mellow",
"Clean and fresh", "Clean and fresh",
"Non-aggressive" "Non-aggressive"
@@ -10140,7 +10140,7 @@
"creamy", "creamy",
"well-integrated alcohol", "well-integrated alcohol",
"polished oak influence", "polished oak influence",
"soft and rounded", "soft and rounded-sm",
"gently warming", "gently warming",
"bright and lively", "bright and lively",
"clean and crisp" "clean and crisp"
@@ -10267,7 +10267,7 @@
"prickly pepper", "prickly pepper",
"well-integrated heat", "well-integrated heat",
"chewy", "chewy",
"rounded", "rounded-sm",
"silky", "silky",
"moderately weighted" "moderately weighted"
] ]
@@ -10409,7 +10409,7 @@
"medium-bodied", "medium-bodied",
"well-integrated alcohol", "well-integrated alcohol",
"polished oak", "polished oak",
"rounded", "rounded-sm",
"luscious", "luscious",
"slightly viscous", "slightly viscous",
"smooth" "smooth"
@@ -10749,7 +10749,7 @@
"thick", "thick",
"viscous", "viscous",
"mouth-coating", "mouth-coating",
"rounded", "rounded-sm",
"well-integrated", "well-integrated",
"polished", "polished",
"slightly drying", "slightly drying",
@@ -10845,7 +10845,7 @@
"smooth", "smooth",
"fresh", "fresh",
"sprightly", "sprightly",
"rounded" "rounded-sm"
] ]
}, },
"Jameson": { "Jameson": {
@@ -10920,7 +10920,7 @@
"creamy", "creamy",
"velvety", "velvety",
"oily", "oily",
"rounded", "rounded-sm",
"mellow", "mellow",
"balanced", "balanced",
"soft", "soft",
@@ -11185,7 +11185,7 @@
"waxy, candle-wax polish tone in older bottlings" "waxy, candle-wax polish tone in older bottlings"
], ],
"texture": [ "texture": [
"creamy, silky and rounded mouthfeel", "creamy, silky and rounded-sm mouthfeel",
"viscous and coating (especially single pot still proofs)", "viscous and coating (especially single pot still proofs)",
"slightly oily and waxy", "slightly oily and waxy",
"buttery and smooth (vanilla custard texture)", "buttery and smooth (vanilla custard texture)",
@@ -11489,7 +11489,7 @@
], ],
"texture": [ "texture": [
"Creamy", "Creamy",
"Silky and rounded", "Silky and rounded-sm",
"Medium viscosity", "Medium viscosity",
"Oiliness that coats the palate", "Oiliness that coats the palate",
"Soft and approachable", "Soft and approachable",
@@ -11868,7 +11868,7 @@
"well-balanced", "well-balanced",
"lightly oily yet clean", "lightly oily yet clean",
"medium-bodied", "medium-bodied",
"rounded and cohesive", "rounded-sm and cohesive",
"soft-spiced", "soft-spiced",
"refreshing acidity", "refreshing acidity",
"velvety oak impression", "velvety oak impression",
@@ -12021,7 +12021,7 @@
"elegant and restrained", "elegant and restrained",
"refined and precise", "refined and precise",
"high-definition clarity", "high-definition clarity",
"smooth, rounded edges", "smooth, rounded-sm edges",
"spry acidity (citrus lift)", "spry acidity (citrus lift)",
"tight-grained oak feel", "tight-grained oak feel",
"lifted and airy", "lifted and airy",
@@ -12044,7 +12044,7 @@
"hint of coconut and banana from Mizunara/inactive oak" "hint of coconut and banana from Mizunara/inactive oak"
], ],
"taste": [ "taste": [
"soft, rounded mouthfeel", "soft, rounded-sm mouthfeel",
"orchard fruit sweetness (pear, apple)", "orchard fruit sweetness (pear, apple)",
"peach and apricot preserve", "peach and apricot preserve",
"light floral notes (lilac, jasmine)", "light floral notes (lilac, jasmine)",
@@ -12072,7 +12072,7 @@
"texture": [ "texture": [
"silky and smooth", "silky and smooth",
"light to medium body", "light to medium body",
"crisp yet rounded", "crisp yet rounded-sm",
"polished and clean", "polished and clean",
"well-integrated alcohol", "well-integrated alcohol",
"slightly oily with a fresh core", "slightly oily with a fresh core",
@@ -12226,7 +12226,7 @@
"Subtle smoke/char - faint ex-bourbon and Mizunara influence" "Subtle smoke/char - faint ex-bourbon and Mizunara influence"
], ],
"taste": [ "taste": [
"Velvety malt - soft, rounded mid-palate", "Velvety malt - soft, rounded-sm mid-palate",
"Pear and Nashi fruit - clean orchard sweetness", "Pear and Nashi fruit - clean orchard sweetness",
"White peach and apricot - gentle stone fruit", "White peach and apricot - gentle stone fruit",
"Citrus zest - lemon and yuzu acidity for balance", "Citrus zest - lemon and yuzu acidity for balance",
@@ -12615,7 +12615,7 @@
"creamy", "creamy",
"silky", "silky",
"smooth", "smooth",
"rounded", "rounded-sm",
"balanced", "balanced",
"firm", "firm",
"robust", "robust",
@@ -12764,7 +12764,7 @@
"silky", "silky",
"full-bodied", "full-bodied",
"rich", "rich",
"rounded", "rounded-sm",
"chewy", "chewy",
"polished", "polished",
"luscious", "luscious",
@@ -12848,7 +12848,7 @@
"rich", "rich",
"viscous", "viscous",
"structured", "structured",
"rounded", "rounded-sm",
"bold", "bold",
"smooth", "smooth",
"dense", "dense",
@@ -12985,7 +12985,7 @@
"chewy", "chewy",
"well-structured", "well-structured",
"balanced", "balanced",
"rounded", "rounded-sm",
"warming", "warming",
"spicy prickle", "spicy prickle",
"smooth", "smooth",
@@ -13067,7 +13067,7 @@
"hot and vibrant", "hot and vibrant",
"slightly prickly", "slightly prickly",
"buttery", "buttery",
"rounded and integrated", "rounded-sm and integrated",
"unctuous", "unctuous",
"mouth-filling", "mouth-filling",
"thick pour" "thick pour"
@@ -13176,7 +13176,7 @@
"dense", "dense",
"full-bodied", "full-bodied",
"rich", "rich",
"rounded", "rounded-sm",
"well-integrated", "well-integrated",
"balanced", "balanced",
"layered", "layered",
@@ -13273,7 +13273,7 @@
"creamy", "creamy",
"oily", "oily",
"medium-bodied", "medium-bodied",
"rounded", "rounded-sm",
"slightly spicy", "slightly spicy",
"warm", "warm",
"velvety", "velvety",
@@ -13338,7 +13338,7 @@
"luscious", "luscious",
"chewy", "chewy",
"rich", "rich",
"rounded" "rounded-sm"
] ]
}, },
"Elijah Craig": { "Elijah Craig": {
@@ -13719,7 +13719,7 @@
"chewy", "chewy",
"syrupy", "syrupy",
"rich", "rich",
"rounded", "rounded-sm",
"coating", "coating",
"smooth", "smooth",
"balanced", "balanced",
@@ -13821,7 +13821,7 @@
"velvety", "velvety",
"silky", "silky",
"chewy", "chewy",
"rounded", "rounded-sm",
"well-integrated", "well-integrated",
"balanced", "balanced",
"smooth", "smooth",
@@ -13966,7 +13966,7 @@
"smooth", "smooth",
"creamy", "creamy",
"oily", "oily",
"rounded", "rounded-sm",
"balanced", "balanced",
"mellow", "mellow",
"slightly viscous", "slightly viscous",
@@ -14118,7 +14118,7 @@
"creamy", "creamy",
"velvety", "velvety",
"oily", "oily",
"rounded", "rounded-sm",
"syrupy", "syrupy",
"chewy", "chewy",
"medium-bodied", "medium-bodied",
@@ -14224,7 +14224,7 @@
"spirited", "spirited",
"well-integrated", "well-integrated",
"structured", "structured",
"rounded", "rounded-sm",
"luscious", "luscious",
"layered", "layered",
"viscous", "viscous",
@@ -14365,7 +14365,7 @@
"Creamy mouthfeel", "Creamy mouthfeel",
"Dense and rich", "Dense and rich",
"Syrupy sweetness balanced by oak", "Syrupy sweetness balanced by oak",
"Soft and rounded", "Soft and rounded-sm",
"Velvety tannins", "Velvety tannins",
"Warming spice prickle", "Warming spice prickle",
"Chewy and substantial", "Chewy and substantial",
@@ -14506,7 +14506,7 @@
"full-bodied", "full-bodied",
"rich", "rich",
"coating", "coating",
"rounded", "rounded-sm",
"dense", "dense",
"bold", "bold",
"thick", "thick",
@@ -14592,7 +14592,7 @@
"chewy", "chewy",
"rich", "rich",
"well-integrated", "well-integrated",
"rounded", "rounded-sm",
"luscious", "luscious",
"dense", "dense",
"polished", "polished",
@@ -14818,7 +14818,7 @@
"Luscious and succulent", "Luscious and succulent",
"Polished and well-integrated", "Polished and well-integrated",
"Silky with a subtle grip", "Silky with a subtle grip",
"Buttery and rounded", "Buttery and rounded-sm",
"Warm and enveloping", "Warm and enveloping",
"Concentrated and intense" "Concentrated and intense"
] ]
@@ -14870,7 +14870,7 @@
"Oiliness from pot still character", "Oiliness from pot still character",
"Creamy mouthfeel with soft oak grip", "Creamy mouthfeel with soft oak grip",
"Slightly viscous with tropical weight", "Slightly viscous with tropical weight",
"Polished and rounded tannins", "Polished and rounded-sm tannins",
"Bright but not sharp, approachable", "Bright but not sharp, approachable",
"Warming spice prickle", "Warming spice prickle",
"Creamy vanilla custard texture", "Creamy vanilla custard texture",
@@ -14931,7 +14931,7 @@
"polished oak texture", "polished oak texture",
"brine-tinged grip", "brine-tinged grip",
"cocoa-dusted smoothness", "cocoa-dusted smoothness",
"rounded yet angular spice", "rounded-sm yet angular spice",
"medium-to-full bodied", "medium-to-full bodied",
"lively pepper-prickly sensation" "lively pepper-prickly sensation"
] ]
@@ -15027,7 +15027,7 @@
"syrupy and viscous", "syrupy and viscous",
"silky and velvety", "silky and velvety",
"coating and mouth-coating", "coating and mouth-coating",
"rounded and plush", "rounded-sm and plush",
"spicy-prickly (white pepper)", "spicy-prickly (white pepper)",
"warming yet refreshing", "warming yet refreshing",
"balanced heat from virgin oak", "balanced heat from virgin oak",
@@ -15152,7 +15152,7 @@
"oily (coat the palate)", "oily (coat the palate)",
"slightly syrupy", "slightly syrupy",
"well-integrated alcohol", "well-integrated alcohol",
"rounded oak texture", "rounded-sm oak texture",
"polished tannin", "polished tannin",
"creamy (from vanilla/caramel)", "creamy (from vanilla/caramel)",
"bright acidity (wine-cask lift)" "bright acidity (wine-cask lift)"
@@ -15706,7 +15706,7 @@
], ],
"texture": [ "texture": [
"Silky and creamy mouthfeel", "Silky and creamy mouthfeel",
"Medium body with a rounded profile", "Medium body with a rounded-sm profile",
"Polished and gently coating", "Polished and gently coating",
"Juicy fruit sensation", "Juicy fruit sensation",
"Slightly waxy on the mid-palate", "Slightly waxy on the mid-palate",
@@ -15955,7 +15955,7 @@
"balanced dryness without harsh astringency" "balanced dryness without harsh astringency"
], ],
"texture": [ "texture": [
"silky and rounded mouthfeel", "silky and rounded-sm mouthfeel",
"medium body, neither oily nor watery", "medium body, neither oily nor watery",
"slightly waxy on the palate", "slightly waxy on the palate",
"creamy texture reminiscent of crème anglaise", "creamy texture reminiscent of crème anglaise",
@@ -16073,7 +16073,7 @@
"well-integrated alcohol", "well-integrated alcohol",
"slightly drying", "slightly drying",
"silky", "silky",
"rounded mouthfeel", "rounded-sm mouthfeel",
"soft and approachable", "soft and approachable",
"polished oak" "polished oak"
] ]
@@ -16133,7 +16133,7 @@
"texture": [ "texture": [
"silky", "silky",
"oily", "oily",
"rounded", "rounded-sm",
"well-balanced", "well-balanced",
"moderately creamy", "moderately creamy",
"polished", "polished",
@@ -16253,7 +16253,7 @@
"smooth and approachable", "smooth and approachable",
"concentrated and dense", "concentrated and dense",
"chalky-dry towards the end", "chalky-dry towards the end",
"rounded but structured", "rounded-sm but structured",
"supple with a citrusy cut", "supple with a citrusy cut",
"polished oak texture" "polished oak texture"
] ]
@@ -16310,11 +16310,11 @@
"full-bodied for low ABV", "full-bodied for low ABV",
"effervescent prickle", "effervescent prickle",
"sprightly zing", "sprightly zing",
"smooth and rounded", "smooth and rounded-sm",
"viscous syrup", "viscous syrup",
"well-integrated heat", "well-integrated heat",
"slightly waxy", "slightly waxy",
"rounded mouthfeel" "rounded-sm mouthfeel"
] ]
}, },
"Berry Bros & Rudd": { "Berry Bros & Rudd": {
@@ -16374,7 +16374,7 @@
"silky/velvety mid-palate", "silky/velvety mid-palate",
"medium-bodied and balanced", "medium-bodied and balanced",
"slightly oily with grip", "slightly oily with grip",
"crisp yet rounded", "crisp yet rounded-sm",
"chalky/mineral edge", "chalky/mineral edge",
"polished oak feel", "polished oak feel",
"creamy without being heavy", "creamy without being heavy",
@@ -16678,7 +16678,7 @@
"medium-bodied", "medium-bodied",
"slightly oily", "slightly oily",
"polished", "polished",
"rounded", "rounded-sm",
"well-integrated alcohol", "well-integrated alcohol",
"soft", "soft",
"crisp", "crisp",
@@ -17228,7 +17228,7 @@
"dense", "dense",
"oily-tear legs", "oily-tear legs",
"weighty", "weighty",
"rounded", "rounded-sm",
"polished", "polished",
"plush", "plush",
"satiny", "satiny",
@@ -17396,7 +17396,7 @@
"clean smoke / ember whisper" "clean smoke / ember whisper"
], ],
"texture": [ "texture": [
"smooth and rounded", "smooth and rounded-sm",
"silky / velvety", "silky / velvety",
"creamy (reminiscent of crème anglaise)", "creamy (reminiscent of crème anglaise)",
"medium-bodied and well-balanced", "medium-bodied and well-balanced",
@@ -17444,7 +17444,7 @@
"gentle peat (whisper)" "gentle peat (whisper)"
], ],
"finish": [ "finish": [
"smooth and rounded", "smooth and rounded-sm",
"lingering honey", "lingering honey",
"soft oak", "soft oak",
"dried fruit sultanas", "dried fruit sultanas",
@@ -17461,7 +17461,7 @@
"creamy", "creamy",
"well-balanced", "well-balanced",
"soft", "soft",
"rounded", "rounded-sm",
"slightly oily", "slightly oily",
"polished", "polished",
"approachable", "approachable",
@@ -17532,7 +17532,7 @@
], ],
"texture": [ "texture": [
"smooth and polished", "smooth and polished",
"creamy and rounded", "creamy and rounded-sm",
"medium-bodied and balanced", "medium-bodied and balanced",
"silky mouthfeel", "silky mouthfeel",
"well-integrated alcohol (no harshness)", "well-integrated alcohol (no harshness)",
@@ -17640,7 +17640,7 @@
"butterscotch" "butterscotch"
], ],
"finish": [ "finish": [
"smooth and rounded", "smooth and rounded-sm",
"medium length", "medium length",
"lingering honey and vanilla", "lingering honey and vanilla",
"sweet oak and gentle spice", "sweet oak and gentle spice",
@@ -17769,7 +17769,7 @@
"balanced sweetness and spice" "balanced sweetness and spice"
], ],
"finish": [ "finish": [
"smooth and rounded", "smooth and rounded-sm",
"lingering smokiness", "lingering smokiness",
"creamy vanilla", "creamy vanilla",
"oak-driven warmth", "oak-driven warmth",
@@ -17790,7 +17790,7 @@
"velvety", "velvety",
"silky", "silky",
"medium-bodied", "medium-bodied",
"rounded", "rounded-sm",
"oily", "oily",
"smooth", "smooth",
"soft", "soft",
@@ -17866,7 +17866,7 @@
"approachable", "approachable",
"smooth", "smooth",
"sprightly", "sprightly",
"rounded mouthfeel", "rounded-sm mouthfeel",
"gentle" "gentle"
] ]
}, },
@@ -17930,7 +17930,7 @@
"texture": [ "texture": [
"creamy", "creamy",
"oily", "oily",
"rounded", "rounded-sm",
"full-bodied", "full-bodied",
"silky", "silky",
"weighted", "weighted",
@@ -17989,7 +17989,7 @@
"creamy and velvety", "creamy and velvety",
"oily and coating", "oily and coating",
"waxy and beeswax-like", "waxy and beeswax-like",
"rounded and mellow", "rounded-sm and mellow",
"medium-bodied and balanced", "medium-bodied and balanced",
"silky and smooth", "silky and smooth",
"slightly drying oak", "slightly drying oak",

View File

@@ -1,12 +1,48 @@
import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').Config} */ /** @type {import('next').Config} */
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
productionBrowserSourceMaps: false, // Enable source maps for Sentry stack traces in production
productionBrowserSourceMaps: !!process.env.GLITCHTIP_DSN,
// React Compiler for automatic memoization (React 19+)
reactCompiler: true,
experimental: { experimental: {
// Note: cacheComponents (PPR) disabled - requires Suspense boundaries for all auth contexts
// Can be enabled later after refactoring to RSC-first architecture
serverActions: { serverActions: {
bodySizeLimit: '10mb', bodySizeLimit: '10mb',
}, },
}, },
}; };
export default nextConfig; // Wrap with Sentry only if DSN is configured
const sentryEnabled = !!process.env.GLITCHTIP_DSN || !!process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
const sentryWebpackPluginOptions = {
// Suppresses source map uploading logs during build
silent: true,
// Organization and project slugs (optional - for source map upload)
org: process.env.GLITCHTIP_ORG,
project: process.env.GLITCHTIP_PROJECT,
// GlitchTip server URL
sentryUrl: process.env.GLITCHTIP_URL,
// Auth token for source map upload
authToken: process.env.GLITCHTIP_AUTH_TOKEN,
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Automatically tree-shake Sentry logger statements
disableLogger: true,
// Prevent bundling of native binaries
widenClientFileUpload: true,
};
export default sentryEnabled
? withSentryConfig(nextConfig, sentryWebpackPluginOptions)
: nextConfig;

View File

@@ -15,9 +15,11 @@
"@ai-sdk/google": "^2.0.51", "@ai-sdk/google": "^2.0.51",
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"@mistralai/mistralai": "^1.11.0", "@mistralai/mistralai": "^1.11.0",
"@sentry/nextjs": "^10.34.0",
"@supabase/ssr": "^0.5.2", "@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.47.10", "@supabase/supabase-js": "^2.47.10",
"@tanstack/react-query": "^5.62.7", "@tanstack/react-query": "^5.62.7",
"@xenova/transformers": "^2.17.2",
"ai": "^5.0.116", "ai": "^5.0.116",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",
@@ -42,6 +44,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@types/node": "^20", "@types/node": "^20",
@@ -49,13 +52,13 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.0.1", "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "16.1.0", "eslint-config-next": "16.1.0",
"eslint-plugin-security": "^2.1.1", "eslint-plugin-security": "^2.1.1",
"jsdom": "^27.3.0", "jsdom": "^27.3.0",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.3.0", "tailwindcss": "^4.1.18",
"typescript": "^5", "typescript": "^5",
"vitest": "^4.0.16" "vitest": "^4.0.16"
}, },

2797
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
autoprefixer: {},
}, },
}; };

View File

@@ -0,0 +1,123 @@
// Background Removal Worker using briaai/RMBG-1.4
// Using @huggingface/transformers v3
import { AutoModel, AutoProcessor, RawImage, env } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1';
console.log('[BG-Processor Worker] Script loaded from /public');
// Configuration
env.allowLocalModels = false;
env.useBrowserCache = true;
// Force WASM backend (more compatible)
env.backends.onnx.wasm.proxy = false;
let model = null;
let processor = null;
/**
* Load the RMBG-1.4 model (WASM only for compatibility)
*/
const loadModel = async () => {
if (!model) {
console.log('[BG-Processor Worker] Loading briaai/RMBG-1.4 model (WASM)...');
model = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
device: 'wasm',
dtype: 'fp32',
});
processor = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
console.log('[BG-Processor Worker] Model loaded successfully.');
}
return { model, processor };
};
/**
* Apply the alpha mask to the original image
*/
const applyMask = async (originalBlob, maskData, width, height) => {
const bitmap = await createImageBitmap(originalBlob);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error("No Canvas context");
// Draw original image
ctx.drawImage(bitmap, 0, 0);
// Get image data
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const data = imageData.data;
// Create mask canvas at model output size
const maskCanvas = new OffscreenCanvas(width, height);
const maskCtx = maskCanvas.getContext('2d');
const maskImageData = maskCtx.createImageData(width, height);
// Convert model output to grayscale image
for (let i = 0; i < maskData.length; i++) {
const val = Math.round(Math.max(0, Math.min(1, maskData[i])) * 255);
maskImageData.data[i * 4] = val;
maskImageData.data[i * 4 + 1] = val;
maskImageData.data[i * 4 + 2] = val;
maskImageData.data[i * 4 + 3] = 255;
}
maskCtx.putImageData(maskImageData, 0, 0);
// Scale mask to original size
const scaledMaskCanvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const scaledMaskCtx = scaledMaskCanvas.getContext('2d');
scaledMaskCtx.drawImage(maskCanvas, 0, 0, bitmap.width, bitmap.height);
const scaledMaskData = scaledMaskCtx.getImageData(0, 0, bitmap.width, bitmap.height);
// Apply mask as alpha
for (let i = 0; i < data.length; i += 4) {
data[i + 3] = scaledMaskData.data[i]; // Use R channel as alpha
}
ctx.putImageData(imageData, 0, 0);
return await canvas.convertToBlob({ type: 'image/png' });
};
self.onmessage = async (e) => {
const { type, id, imageBlob } = e.data;
if (type === 'ping') {
self.postMessage({ type: 'pong' });
return;
}
if (!imageBlob) return;
console.log(`[BG-Processor Worker] Received request for ${id}`);
try {
const { model, processor } = await loadModel();
// Convert blob to RawImage
const url = URL.createObjectURL(imageBlob);
const image = await RawImage.fromURL(url);
URL.revokeObjectURL(url);
console.log('[BG-Processor Worker] Running inference...');
// Process image
const { pixel_values } = await processor(image);
// Run model
const { output } = await model({ input: pixel_values });
// Get mask data - output is a Tensor
const maskData = output.data;
const [batch, channels, height, width] = output.dims;
console.log(`[BG-Processor Worker] Mask dims: ${width}x${height}`);
console.log('[BG-Processor Worker] Applying mask...');
const processedBlob = await applyMask(imageBlob, maskData, width, height);
self.postMessage({ id, status: 'success', blob: processedBlob });
console.log(`[BG-Processor Worker] Successfully processed ${id}`);
} catch (err) {
console.error(`[BG-Processor Worker] Processing Error (${id}):`, err);
self.postMessage({ id, status: 'error', error: err.message });
}
};

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'whisky-vault-v20-offline'; const CACHE_NAME = 'whisky-vault-v21-offline';
// CONFIG: Assets - Only essential files, no heavy OCR (~2MB instead of ~50MB) // CONFIG: Assets - Only essential files, no heavy OCR (~2MB instead of ~50MB)
const STATIC_ASSETS = [ const STATIC_ASSETS = [
@@ -189,22 +189,33 @@ self.addEventListener('fetch', (event) => {
if (isNavigation || isAsset) { if (isNavigation || isAsset) {
event.respondWith( event.respondWith(
caches.match(event.request).then(async (cachedResponse) => { caches.match(event.request).then(async (cachedResponse) => {
const fetchPromise = fetchWithTimeout(event.request, 10000) // Try network first
.then(async (networkResponse) => { try {
const networkResponse = await fetchWithTimeout(event.request, 10000);
if (networkResponse && networkResponse.status === 200) { if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(CACHE_NAME); const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse.clone()); cache.put(event.request, networkResponse.clone());
} }
return networkResponse; return networkResponse;
}).catch(() => { }); } catch (networkError) {
// Network failed, fall back to cache
if (cachedResponse) {
return cachedResponse;
}
// For navigation, try to serve the app shell
if (isNavigation) { if (isNavigation) {
if (cachedResponse) return cachedResponse;
const shell = await caches.match('/'); const shell = await caches.match('/');
if (shell) return shell; if (shell) return shell;
} }
return cachedResponse || fetchPromise || fetch(event.request); // Last resort: return a proper error response
return new Response('Offline - Resource not available', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain' }
});
}
}) })
); );
} }

99
security-report.txt Normal file
View File

@@ -0,0 +1,99 @@
┌──────────────────┐
│ 15 Code Findings │
└──────────────────┘
public/sw.js
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
75┆ console.error(`⚠️ PWA: Pre-cache failed for ${url}:`, error);
⋮┆----------------------------------------
174┆ console.error(`[SW] Failed to fetch ${url.pathname}:`, error);
scripts/scrape-distillery-tags.ts
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
107┆ console.error(`❌ API Error for ${name}: ${response.status}`, data.error || data);
⋮┆----------------------------------------
116┆ console.error(`⚠️ OpenRouter Error for ${name}:`, data.error.message);
⋮┆----------------------------------------
119┆ console.error(`⚠️ No content returned for ${name}. Full response:`, JSON.stringify(data,
null, 2));
⋮┆----------------------------------------
125┆ console.error(`❌ Fetch Exception for ${name}:`, error);
src/context/AuthContext.tsx
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
40┆ console.log(`[AuthContext] event: ${event}`, {
src/hooks/useScanner.ts
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
157┆ console.log(`[useScanner] ${providerUsed} complete:`, cloudResult);
⋮┆----------------------------------------
186┆ console.warn(`[useScanner] ${providerUsed} failed:`, cloudResponse.error);
src/i18n/I18nContext.tsx
❯❱ javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop.prototype-pollution-loop
Possibility of prototype polluting function detected. By adding or modifying attributes of an object
prototype, it is possible to create attributes that exist on every object, or replace critical
attributes with malicious ones. This can be problematic if the software depends on existence or non-
existence of certain attributes, or uses pre-defined attributes of object prototype (such as
hasOwnProperty, toString or valueOf). Possible mitigations might be: freezing the object prototype,
using an object without prototypes (via Object.create(null) ), blocking modifications of attributes
that resolve to object prototype, using Map instead of object.
Details: https://sg.run/w1DB
54┆ current = current[key];
src/lib/distillery-matcher.ts
❯❱ javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
RegExp() called with a `distillery` function argument, this might allow an attacker to cause a
Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main
thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on
user-controlled input, consider performing input validation or use a regex checking/sanitization
library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear
vulnerable to ReDoS.
Details: https://sg.run/gr65
154┆ const regex = new RegExp(`^${escaped}\\s*[-–—:]?\\s*`, 'i');
⋮┆----------------------------------------
161┆ const anywhereRegex = new RegExp(`\\b${escaped}\\b\\s*[-–—:]?\\s*`, 'i');
src/services/bulk-scan.ts
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
211┆ console.error(`Analysis failed for bottle ${bottleId}:`, error);
src/services/tags.ts
❱ javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
Detected string concatenation with a non-literal variable in a util.format / console.log function.
If an attacker injects a format specifier in the string, it will forge the log message. Try to use
constant values for the format string.
Details: https://sg.run/7Y5R
33┆ console.error(`Error fetching tags for ${category}:`, error);
⋮┆----------------------------------------
39┆ console.error(`Exception in getTagsByCategory for ${category}:`, err);

36
sentry.client.config.ts Normal file
View File

@@ -0,0 +1,36 @@
import * as Sentry from "@sentry/nextjs";
const GLITCHTIP_DSN = process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
// Only initialize Sentry if DSN is configured
if (GLITCHTIP_DSN) {
Sentry.init({
dsn: GLITCHTIP_DSN,
// Environment
environment: process.env.NODE_ENV,
// Sample rate for error events (1.0 = 100%)
sampleRate: 1.0,
// Performance monitoring sample rate (0.1 = 10%)
tracesSampleRate: 0.1,
// Use tunnel to bypass ad blockers
tunnel: "/api/glitchtip-tunnel",
// Disable debug in production
debug: process.env.NODE_ENV === "development",
// Ignore common non-actionable errors
ignoreErrors: [
"ResizeObserver loop limit exceeded",
"ResizeObserver loop completed with undelivered notifications",
"Non-Error promise rejection captured",
],
});
console.log("[Sentry] Client initialized with GlitchTip");
} else {
console.log("[Sentry] Client disabled - no DSN configured");
}

21
sentry.edge.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import * as Sentry from "@sentry/nextjs";
const GLITCHTIP_DSN = process.env.GLITCHTIP_DSN || process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
// Only initialize Sentry if DSN is configured
if (GLITCHTIP_DSN) {
Sentry.init({
dsn: GLITCHTIP_DSN,
// Environment
environment: process.env.NODE_ENV,
// Sample rate for error events (1.0 = 100%)
sampleRate: 1.0,
// Performance monitoring sample rate (lower for edge)
tracesSampleRate: 0.05,
});
console.log("[Sentry] Edge initialized with GlitchTip");
}

26
sentry.server.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import * as Sentry from "@sentry/nextjs";
const GLITCHTIP_DSN = process.env.GLITCHTIP_DSN || process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
// Only initialize Sentry if DSN is configured
if (GLITCHTIP_DSN) {
Sentry.init({
dsn: GLITCHTIP_DSN,
// Environment
environment: process.env.NODE_ENV,
// Sample rate for error events (1.0 = 100%)
sampleRate: 1.0,
// Performance monitoring sample rate (0.1 = 10%)
tracesSampleRate: 0.1,
// Disable debug in production
debug: process.env.NODE_ENV === "development",
});
console.log("[Sentry] Server initialized with GlitchTip");
} else {
console.log("[Sentry] Server disabled - no DSN configured");
}

View File

@@ -0,0 +1,49 @@
-- App Banners Table for dynamic hero content on home page
CREATE TABLE IF NOT EXISTS app_banners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
image_url TEXT NOT NULL, -- 16:9 Banner Image
link_target TEXT, -- e.g., '/sessions'
cta_text TEXT DEFAULT 'Open',
is_active BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Only one banner should be active at a time (optional constraint)
CREATE UNIQUE INDEX IF NOT EXISTS idx_app_banners_active
ON app_banners (is_active)
WHERE is_active = true;
-- RLS Policies
ALTER TABLE app_banners ENABLE ROW LEVEL SECURITY;
-- Everyone can view active banners
CREATE POLICY "Anyone can view active banners"
ON app_banners FOR SELECT
USING (is_active = true);
-- Admins can manage all banners
CREATE POLICY "Admins can manage banners"
ON app_banners FOR ALL
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
)
);
-- Trigger for updated_at
CREATE OR REPLACE FUNCTION update_app_banners_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_app_banners_updated_at
BEFORE UPDATE ON app_banners
FOR EACH ROW
EXECUTE FUNCTION update_app_banners_updated_at();

78
sql/create_ocr_logs.sql Normal file
View File

@@ -0,0 +1,78 @@
-- OCR Logs Table for storing cascade OCR results
-- This allows admins to view OCR recognition results from mobile devices
CREATE TABLE IF NOT EXISTS ocr_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
bottle_id UUID REFERENCES bottles(id) ON DELETE SET NULL,
-- Image data
image_url TEXT, -- URL to the scanned image
image_thumbnail TEXT, -- Base64 thumbnail for quick preview
-- Detected fields
raw_text TEXT, -- All detected text joined
detected_texts JSONB, -- Array of individual text detections
-- Extracted data
distillery TEXT,
distillery_source TEXT, -- 'fuzzy', 'ai', 'manual'
bottle_name TEXT,
abv DECIMAL(5,2),
age INTEGER,
vintage TEXT,
volume TEXT,
category TEXT,
-- Meta
confidence INTEGER, -- 0-100
device_info TEXT, -- User agent or device type
ocr_method TEXT, -- 'text_detector', 'fallback', etc.
processing_time_ms INTEGER,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Index for efficient queries
CREATE INDEX IF NOT EXISTS idx_ocr_logs_user_id ON ocr_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_ocr_logs_created_at ON ocr_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ocr_logs_distillery ON ocr_logs(distillery);
-- RLS Policies
ALTER TABLE ocr_logs ENABLE ROW LEVEL SECURITY;
-- Users can view their own logs
CREATE POLICY "Users can view own ocr_logs"
ON ocr_logs FOR SELECT
USING (auth.uid() = user_id);
-- Users can insert their own logs
CREATE POLICY "Users can insert own ocr_logs"
ON ocr_logs FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Admins can view all logs
CREATE POLICY "Admins can view all ocr_logs"
ON ocr_logs FOR SELECT
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
)
);
-- Trigger for updated_at
CREATE OR REPLACE FUNCTION update_ocr_logs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_ocr_logs_updated_at
BEFORE UPDATE ON ocr_logs
FOR EACH ROW
EXECUTE FUNCTION update_ocr_logs_updated_at();

View File

@@ -0,0 +1,36 @@
-- Add Blind Tasting support to Sessions
ALTER TABLE public.tasting_sessions
ADD COLUMN IF NOT EXISTS is_blind BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS is_revealed BOOLEAN DEFAULT false;
-- Add Guessing fields to Tastings
ALTER TABLE public.tastings
ADD COLUMN IF NOT EXISTS blind_label TEXT,
ADD COLUMN IF NOT EXISTS guess_abv DECIMAL,
ADD COLUMN IF NOT EXISTS guess_age INTEGER,
ADD COLUMN IF NOT EXISTS guess_region TEXT,
ADD COLUMN IF NOT EXISTS guess_points INTEGER;
-- Update RLS Policies for blind sessions
-- Guests should only see bottle details if NOT blind OR revealed
-- This is a complex policy update, we'll refine the existing tastings_select_policy
DROP POLICY IF EXISTS "tastings_select_policy" ON public.tastings;
CREATE POLICY "tastings_select_policy" ON public.tastings FOR SELECT USING (
-- You can see your own tastings
auth.uid() = user_id
OR
-- You can see tastings in a session you participate in
EXISTS (
SELECT 1
FROM public.session_participants sp
JOIN public.buddies b ON b.id = sp.buddy_id
WHERE sp.session_id = public.tastings.session_id
AND b.buddy_profile_id = auth.uid()
)
);
-- Note: The logic for hiding bottle details will be handled in the UI/API layer
-- as the RLS here still needs to allow access to the tasting record itself.
-- Hiding 'bottle_id' content for blind tastings will be done in the frontend
-- based on session.is_blind and session.is_revealed.

View File

@@ -1,6 +1,5 @@
'use server'; 'use server';
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { trackApiUsage } from '@/services/track-api-usage'; import { trackApiUsage } from '@/services/track-api-usage';
import { deductCredits } from '@/services/credit-service'; import { deductCredits } from '@/services/credit-service';
@@ -8,32 +7,6 @@ import { getAllSystemTags } from '@/services/tags';
import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter'; import { getAIProvider, getOpenRouterClient, OPENROUTER_PROVIDER_PREFERENCES } from '@/lib/openrouter';
import { getEnrichmentCache, saveEnrichmentCache, incrementCacheHit } from '@/services/cache-enrichment'; import { getEnrichmentCache, saveEnrichmentCache, incrementCacheHit } from '@/services/cache-enrichment';
// Native Schema Definition for Enrichment Data
const enrichmentSchema = {
description: "Sensory profile and search metadata for whisky",
type: SchemaType.OBJECT as const,
properties: {
suggested_tags: {
type: SchemaType.ARRAY,
description: "Array of suggested aroma/taste tags from the available system tags",
items: { type: SchemaType.STRING },
nullable: true
},
suggested_custom_tags: {
type: SchemaType.ARRAY,
description: "Array of custom dominant notes not in the system tags",
items: { type: SchemaType.STRING },
nullable: true
},
search_string: {
type: SchemaType.STRING,
description: "Optimized search query for Whiskybase discovery",
nullable: true
}
},
required: [],
};
const ENRICHMENT_MODEL = 'google/gemma-3-27b-it'; const ENRICHMENT_MODEL = 'google/gemma-3-27b-it';
/** /**
@@ -107,46 +80,11 @@ async function enrichWithOpenRouter(instruction: string): Promise<{ data: any; a
throw lastError || new Error('OpenRouter enrichment failed after retries'); throw lastError || new Error('OpenRouter enrichment failed after retries');
} }
/**
* Enrich with Gemini
*/
async function enrichWithGemini(instruction: string): Promise<{ data: any; apiTime: number; responseText: string }> {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({
model: 'gemini-2.5-flash',
generationConfig: {
responseMimeType: "application/json",
responseSchema: enrichmentSchema as any,
temperature: 0.3,
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
] as any,
});
const startApi = performance.now();
const result = await model.generateContent(instruction);
const endApi = performance.now();
const responseText = result.response.text();
return {
data: JSON.parse(responseText),
apiTime: endApi - startApi,
responseText: responseText
};
}
export async function enrichData(name: string, distillery: string, availableTags?: string, language: string = 'de') { export async function enrichData(name: string, distillery: string, availableTags?: string, language: string = 'de') {
const provider = getAIProvider(); const provider = getAIProvider();
// Check API key based on provider // Check API key
if (provider === 'gemini' && !process.env.GEMINI_API_KEY) { if (!process.env.OPENROUTER_API_KEY) {
return { success: false, error: 'GEMINI_API_KEY is not configured.' };
}
if (provider === 'openrouter' && !process.env.OPENROUTER_API_KEY) {
return { success: false, error: 'OPENROUTER_API_KEY is not configured.' }; return { success: false, error: 'OPENROUTER_API_KEY is not configured.' };
} }
@@ -203,13 +141,8 @@ Instructions:
3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`; 3. Provide a clean "search_string" for Whiskybase (e.g., "Distillery Name Age").`;
console.log(`[EnrichData] Using provider: ${provider}`); console.log(`[EnrichData] Using provider: ${provider}`);
let result: { data: any; apiTime: number; responseText: string };
if (provider === 'openrouter') { const result = await enrichWithOpenRouter(instruction);
result = await enrichWithOpenRouter(instruction);
} else {
result = await enrichWithGemini(instruction);
}
console.log('[EnrichData] Response:', result.data); console.log('[EnrichData] Response:', result.data);
@@ -229,7 +162,7 @@ Instructions:
endpoint: `enrichData_${provider}`, endpoint: `enrichData_${provider}`,
success: true, success: true,
provider: provider, provider: provider,
model: provider === 'openrouter' ? ENRICHMENT_MODEL : 'gemini-2.5-flash', model: ENRICHMENT_MODEL,
responseText: result.responseText responseText: result.responseText
}); });

View File

@@ -1,6 +1,5 @@
'use server'; 'use server';
import { GoogleGenerativeAI, SchemaType, HarmCategory, HarmBlockThreshold } from '@google/generative-ai';
import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky'; import { BottleMetadataSchema, BottleMetadata } from '@/types/whisky';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { trackApiUsage } from '@/services/track-api-usage'; import { trackApiUsage } from '@/services/track-api-usage';
@@ -9,30 +8,6 @@ import { getAIProvider, getOpenRouterClient, OPENROUTER_VISION_MODEL, OPENROUTER
import { normalizeWhiskyData } from '@/lib/distillery-matcher'; import { normalizeWhiskyData } from '@/lib/distillery-matcher';
import { formatWhiskyName } from '@/utils/formatWhiskyName'; import { formatWhiskyName } from '@/utils/formatWhiskyName';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import sharp from 'sharp';
// Schema for AI extraction
const visionSchema = {
description: "Whisky bottle label metadata extracted from image",
type: SchemaType.OBJECT as const,
properties: {
name: { type: SchemaType.STRING, description: "Full whisky name (constructed)", nullable: false },
distillery: { type: SchemaType.STRING, description: "Distillery name", nullable: true },
bottler: { type: SchemaType.STRING, description: "Independent bottler if applicable", nullable: true },
series: { type: SchemaType.STRING, description: "Whisky series or collection (e.g. Cadenhead's Natural Strength)", nullable: true },
category: { type: SchemaType.STRING, description: "Whisky category (Single Malt, Blended, Bourbon, etc.)", nullable: true },
abv: { type: SchemaType.NUMBER, description: "Alcohol by volume percentage", nullable: true },
age: { type: SchemaType.NUMBER, description: "Age statement in years", nullable: true },
vintage: { type: SchemaType.STRING, description: "Vintage/distillation year", nullable: true },
cask_type: { type: SchemaType.STRING, description: "Cask type (Sherry, Bourbon, Port, etc.)", nullable: true },
distilled_at: { type: SchemaType.STRING, description: "Distillation date", nullable: true },
bottled_at: { type: SchemaType.STRING, description: "Bottling date", nullable: true },
batch_info: { type: SchemaType.STRING, description: "Batch or cask number", nullable: true },
is_whisky: { type: SchemaType.BOOLEAN, description: "Whether this is a whisky product", nullable: false },
confidence: { type: SchemaType.NUMBER, description: "Confidence score 0-1", nullable: false },
},
required: ["name", "is_whisky", "confidence"],
};
const VISION_PROMPT = `ROLE: Senior Whisky Database Curator. const VISION_PROMPT = `ROLE: Senior Whisky Database Curator.
@@ -68,13 +43,11 @@ OUTPUT SCHEMA (Strict JSON):
"confidence": number "confidence": number
}`; }`;
const GEMINI_MODEL = 'gemini-2.5-flash';
export interface ScannerResult { export interface ScannerResult {
success: boolean; success: boolean;
data?: BottleMetadata; data?: BottleMetadata;
error?: string; error?: string;
provider?: 'gemini' | 'openrouter'; provider?: 'openrouter';
perf?: { perf?: {
imagePrep?: number; imagePrep?: number;
apiCall: number; apiCall: number;
@@ -183,7 +156,6 @@ export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerRe
console.log(`[Scanner] Using provider: ${provider}`); console.log(`[Scanner] Using provider: ${provider}`);
let aiResult: { data: any; apiTime: number; responseText: string }; let aiResult: { data: any; apiTime: number; responseText: string };
if (provider === 'openrouter') {
const client = getOpenRouterClient(); const client = getOpenRouterClient();
const startApi = performance.now(); const startApi = performance.now();
const maxRetries = 3; const maxRetries = 3;
@@ -234,34 +206,6 @@ export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerRe
apiTime: performance.now() - startApi, apiTime: performance.now() - startApi,
responseText: content responseText: content
}; };
} else {
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);
const model = genAI.getGenerativeModel({
model: GEMINI_MODEL,
generationConfig: {
responseMimeType: "application/json",
responseSchema: visionSchema as any,
temperature: 0.1,
},
safetySettings: [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_NONE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_NONE },
] as any,
});
const startApi = performance.now();
const result = await model.generateContent([
{ inlineData: { data: base64Data, mimeType } },
{ text: VISION_PROMPT },
]);
const responseText = result.response.text();
aiResult = {
data: JSON.parse(responseText),
apiTime: performance.now() - startApi,
responseText: responseText
};
}
// 6. Name Composition & Normalization // 6. Name Composition & Normalization
// Use standardized helper to construct the perfect name // Use standardized helper to construct the perfect name
@@ -301,7 +245,7 @@ export async function analyzeBottleLabel(imageBase64: string): Promise<ScannerRe
endpoint: `analyzeBottleLabel_${provider}`, endpoint: `analyzeBottleLabel_${provider}`,
success: true, success: true,
provider, provider,
model: provider === 'openrouter' ? OPENROUTER_VISION_MODEL : GEMINI_MODEL, model: OPENROUTER_VISION_MODEL,
responseText: aiResult.responseText responseText: aiResult.responseText
}); });
await deductCredits(user.id, 'gemini_ai', `Scanner analysis (${provider})`); await deductCredits(user.id, 'gemini_ai', `Scanner analysis (${provider})`);

View File

@@ -0,0 +1,396 @@
'use client';
import { useState } from 'react';
import { Image, ExternalLink, ToggleLeft, ToggleRight, Trash2, Plus, Edit2, Save, X, Loader2, Check } from 'lucide-react';
import { Banner, createBanner, updateBanner, toggleBannerActive, deleteBanner } from '@/services/banner-actions';
interface BannerManagerProps {
initialBanners: Banner[];
}
export default function BannerManager({ initialBanners }: BannerManagerProps) {
const [banners, setBanners] = useState<Banner[]>(initialBanners);
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Form states
const [formData, setFormData] = useState({
title: '',
image_url: '',
link_target: '',
cta_text: 'Open',
});
const resetForm = () => {
setFormData({ title: '', image_url: '', link_target: '', cta_text: 'Open' });
setShowCreateForm(false);
setEditingId(null);
};
const showMessage = (type: 'success' | 'error', text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage(null), 3000);
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading('create');
const form = new FormData();
form.append('title', formData.title);
form.append('image_url', formData.image_url);
form.append('link_target', formData.link_target);
form.append('cta_text', formData.cta_text);
const result = await createBanner(form);
if (result.success) {
showMessage('success', 'Banner created successfully');
// Refresh - in real app would revalidate
window.location.reload();
} else {
showMessage('error', result.error || 'Failed to create banner');
}
setIsLoading(null);
resetForm();
};
const handleUpdate = async (id: string) => {
setIsLoading(id);
const form = new FormData();
form.append('title', formData.title);
form.append('image_url', formData.image_url);
form.append('link_target', formData.link_target);
form.append('cta_text', formData.cta_text);
const result = await updateBanner(id, form);
if (result.success) {
showMessage('success', 'Banner updated successfully');
setBanners(banners.map(b =>
b.id === id
? { ...b, ...formData }
: b
));
} else {
showMessage('error', result.error || 'Failed to update banner');
}
setIsLoading(null);
resetForm();
};
const handleToggleActive = async (id: string, currentStatus: boolean) => {
setIsLoading(id);
const result = await toggleBannerActive(id, !currentStatus);
if (result.success) {
showMessage('success', !currentStatus ? 'Banner activated' : 'Banner deactivated');
// If activating, deactivate all others
setBanners(banners.map(b => ({
...b,
is_active: b.id === id ? !currentStatus : (!currentStatus ? false : b.is_active)
})));
} else {
showMessage('error', result.error || 'Failed to toggle banner');
}
setIsLoading(null);
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this banner?')) return;
setIsLoading(id);
const result = await deleteBanner(id);
if (result.success) {
showMessage('success', 'Banner deleted');
setBanners(banners.filter(b => b.id !== id));
} else {
showMessage('error', result.error || 'Failed to delete banner');
}
setIsLoading(null);
};
const startEditing = (banner: Banner) => {
setEditingId(banner.id);
setFormData({
title: banner.title,
image_url: banner.image_url,
link_target: banner.link_target || '',
cta_text: banner.cta_text || 'Open',
});
};
return (
<div className="space-y-6">
{/* Message Toast */}
{message && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl shadow-lg animate-in slide-in-from-right ${message.type === 'success'
? 'bg-green-500/20 border border-green-500/50 text-green-400'
: 'bg-red-500/20 border border-red-500/50 text-red-400'
}`}>
{message.text}
</div>
)}
{/* Create Button / Form */}
{!showCreateForm ? (
<button
onClick={() => setShowCreateForm(true)}
className="w-full py-4 bg-zinc-900 hover:bg-zinc-800 border border-dashed border-zinc-700 hover:border-orange-600/50 rounded-2xl text-zinc-400 hover:text-orange-500 transition-all flex items-center justify-center gap-2"
>
<Plus size={20} />
<span className="font-bold">Add New Banner</span>
</button>
) : (
<form onSubmit={handleCreate} className="p-6 bg-zinc-900 rounded-2xl border border-zinc-800 space-y-4">
<h3 className="text-lg font-bold text-white mb-4">Create New Banner</h3>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-1">Title *</label>
<input
type="text"
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
placeholder="Banner title"
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-1">Image URL * (16:9 recommended)</label>
<input
type="url"
value={formData.image_url}
onChange={e => setFormData({ ...formData, image_url: e.target.value })}
placeholder="https://example.com/banner.jpg"
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-zinc-400 mb-1">Link Target</label>
<input
type="text"
value={formData.link_target}
onChange={e => setFormData({ ...formData, link_target: e.target.value })}
placeholder="/sessions"
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/>
</div>
<div>
<label className="block text-xs font-bold text-zinc-400 mb-1">CTA Text</label>
<input
type="text"
value={formData.cta_text}
onChange={e => setFormData({ ...formData, cta_text: e.target.value })}
placeholder="Open"
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/>
</div>
</div>
{/* Preview */}
{formData.image_url && (
<div className="mt-4">
<label className="block text-xs font-bold text-zinc-400 mb-2">Preview</label>
<div className="aspect-video rounded-xl overflow-hidden bg-zinc-800">
<img
src={formData.image_url}
alt="Preview"
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).src = '/placeholder.png'; }}
/>
</div>
</div>
)}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={resetForm}
className="flex-1 py-3 bg-zinc-800 text-zinc-400 rounded-xl font-bold hover:bg-zinc-700 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading === 'create'}
className="flex-1 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-bold disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
>
{isLoading === 'create' ? (
<Loader2 size={18} className="animate-spin" />
) : (
<>
<Plus size={18} />
Create Banner
</>
)}
</button>
</div>
</form>
)}
{/* Banners List */}
<div className="space-y-4">
{banners.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
<Image size={32} className="text-zinc-600" />
</div>
<p className="text-lg font-bold text-white mb-2">No Banners Yet</p>
<p className="text-sm text-zinc-500">Create your first banner to display on the home page.</p>
</div>
) : (
banners.map(banner => (
<div
key={banner.id}
className={`p-4 bg-zinc-900 rounded-2xl border transition-all ${banner.is_active
? 'border-green-600/50 ring-1 ring-green-600/20'
: 'border-zinc-800'
}`}
>
{editingId === banner.id ? (
/* Edit Form */
<div className="space-y-4">
<input
type="text"
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white"
/>
<input
type="url"
value={formData.image_url}
onChange={e => setFormData({ ...formData, image_url: e.target.value })}
className="w-full px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white"
/>
<div className="grid grid-cols-2 gap-3">
<input
type="text"
value={formData.link_target}
onChange={e => setFormData({ ...formData, link_target: e.target.value })}
placeholder="Link target"
className="px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white"
/>
<input
type="text"
value={formData.cta_text}
onChange={e => setFormData({ ...formData, cta_text: e.target.value })}
placeholder="CTA text"
className="px-3 py-2 bg-zinc-950 border border-zinc-700 rounded-lg text-white"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => resetForm()}
className="px-4 py-2 bg-zinc-800 text-zinc-400 rounded-lg"
>
<X size={16} />
</button>
<button
onClick={() => handleUpdate(banner.id)}
disabled={isLoading === banner.id}
className="flex-1 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-bold flex items-center justify-center gap-2"
>
{isLoading === banner.id ? (
<Loader2 size={16} className="animate-spin" />
) : (
<>
<Save size={16} />
Save
</>
)}
</button>
</div>
</div>
) : (
/* Display Mode */
<div className="flex gap-4">
{/* Thumbnail */}
<div className="w-32 h-20 rounded-lg overflow-hidden bg-zinc-800 shrink-0">
<img
src={banner.image_url}
alt={banner.title}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-bold text-white truncate">{banner.title}</h3>
{banner.is_active && (
<span className="px-2 py-0.5 bg-green-600/20 text-green-400 text-[10px] font-bold uppercase rounded-full">
Active
</span>
)}
</div>
{banner.link_target && (
<p className="text-xs text-zinc-500 flex items-center gap-1">
<ExternalLink size={12} />
{banner.link_target}
</p>
)}
<p className="text-xs text-zinc-600 mt-1">
CTA: {banner.cta_text}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => handleToggleActive(banner.id, banner.is_active)}
disabled={isLoading === banner.id}
className={`p-2 rounded-lg transition-colors ${banner.is_active
? 'bg-green-600/20 text-green-400 hover:bg-green-600/30'
: 'bg-zinc-800 text-zinc-500 hover:text-white'
}`}
title={banner.is_active ? 'Deactivate' : 'Activate'}
>
{isLoading === banner.id ? (
<Loader2 size={18} className="animate-spin" />
) : banner.is_active ? (
<ToggleRight size={18} />
) : (
<ToggleLeft size={18} />
)}
</button>
<button
onClick={() => startEditing(banner)}
className="p-2 bg-zinc-800 text-zinc-400 hover:text-white rounded-lg transition-colors"
title="Edit"
>
<Edit2 size={18} />
</button>
<button
onClick={() => handleDelete(banner.id)}
disabled={isLoading === banner.id}
className="p-2 bg-zinc-800 text-zinc-400 hover:text-red-500 rounded-lg transition-colors"
title="Delete"
>
<Trash2 size={18} />
</button>
</div>
</div>
)}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
import { getBanners } from '@/services/banner-actions';
import Link from 'next/link';
import { ArrowLeft, Image, ExternalLink, ToggleLeft, ToggleRight, Trash2, Plus, Edit2 } from 'lucide-react';
import BannerManager from './BannerManager';
export default async function AdminBannersPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/');
}
const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) {
redirect('/');
}
const { banners, error } = await getBanners();
return (
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/admin"
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold text-white">Banner Management</h1>
<p className="text-sm text-zinc-500">
Manage hero banners for the home page
</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
Error loading banners: {error}
</div>
)}
{/* Client Component for Interactive Banner Management */}
<BannerManager initialBanners={banners} />
</div>
</main>
);
}

View File

@@ -0,0 +1,275 @@
'use client';
import { useState, useMemo } from 'react';
import { Search, User, Wine, Star, X } from 'lucide-react';
interface Bottle {
id: string;
name: string;
distillery: string | null;
image_url: string | null;
abv: number | null;
age: number | null;
category: string | null;
status: string | null;
created_at: string;
user_id: string;
tastings: { id: string; rating: number }[];
user: { username: string; display_name: string | null };
}
interface AdminBottlesListProps {
bottles: Bottle[];
}
export default function AdminBottlesList({ bottles }: AdminBottlesListProps) {
const [search, setSearch] = useState('');
const [filterUser, setFilterUser] = useState<string | null>(null);
const [filterCategory, setFilterCategory] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'created_at' | 'name' | 'distillery' | 'rating'>('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Get unique users and categories for filters
const users = useMemo(() => {
const userMap = new Map<string, string>();
bottles.forEach(b => {
userMap.set(b.user_id, b.user.display_name || b.user.username);
});
return Array.from(userMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [bottles]);
const categories = useMemo(() => {
const cats = new Set<string>();
bottles.forEach(b => {
if (b.category) cats.add(b.category);
});
return Array.from(cats).sort();
}, [bottles]);
// Filter and sort bottles
const filteredBottles = useMemo(() => {
let result = bottles;
// Search filter
if (search) {
const searchLower = search.toLowerCase();
result = result.filter(b =>
b.name?.toLowerCase().includes(searchLower) ||
b.distillery?.toLowerCase().includes(searchLower) ||
b.user.username.toLowerCase().includes(searchLower) ||
b.user.display_name?.toLowerCase().includes(searchLower)
);
}
// User filter
if (filterUser) {
result = result.filter(b => b.user_id === filterUser);
}
// Category filter
if (filterCategory) {
result = result.filter(b => b.category === filterCategory);
}
// Sort
result = [...result].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = (a.name || '').localeCompare(b.name || '');
break;
case 'distillery':
comparison = (a.distillery || '').localeCompare(b.distillery || '');
break;
case 'rating':
const avgA = a.tastings?.length > 0
? a.tastings.reduce((sum, t) => sum + t.rating, 0) / a.tastings.length
: 0;
const avgB = b.tastings?.length > 0
? b.tastings.reduce((sum, t) => sum + t.rating, 0) / b.tastings.length
: 0;
comparison = avgA - avgB;
break;
case 'created_at':
default:
comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
break;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
return result;
}, [bottles, search, filterUser, filterCategory, sortBy, sortOrder]);
const clearFilters = () => {
setSearch('');
setFilterUser(null);
setFilterCategory(null);
};
const hasFilters = search || filterUser || filterCategory;
return (
<div className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search bottles, distilleries, or users..."
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/>
</div>
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{/* User Filter */}
<select
value={filterUser || ''}
onChange={e => setFilterUser(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden focus:border-orange-600 appearance-none cursor-pointer"
>
<option value="">All Users</option>
{users.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
{/* Category Filter */}
<select
value={filterCategory || ''}
onChange={e => setFilterCategory(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden focus:border-orange-600 appearance-none cursor-pointer"
>
<option value="">All Categories</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{/* Sort */}
<select
value={`${sortBy}-${sortOrder}`}
onChange={e => {
const [by, order] = e.target.value.split('-') as [typeof sortBy, typeof sortOrder];
setSortBy(by);
setSortOrder(order);
}}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden focus:border-orange-600 appearance-none cursor-pointer"
>
<option value="created_at-desc">Newest First</option>
<option value="created_at-asc">Oldest First</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
<option value="distillery-asc">Distillery A-Z</option>
<option value="rating-desc">Highest Rating</option>
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={clearFilters}
className="px-4 py-3 bg-zinc-800 text-zinc-400 hover:text-white rounded-xl transition-colors flex items-center gap-2"
>
<X size={16} />
Clear
</button>
)}
</div>
</div>
{/* Results Count */}
<div className="text-sm text-zinc-500">
Showing {filteredBottles.length} of {bottles.length} bottles
</div>
{/* Bottles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredBottles.map(bottle => {
const avgRating = bottle.tastings?.length > 0
? bottle.tastings.reduce((sum, t) => sum + t.rating, 0) / bottle.tastings.length
: 0;
return (
<div
key={bottle.id}
className="bg-zinc-900 rounded-2xl border border-zinc-800 overflow-hidden hover:border-zinc-700 transition-colors"
>
{/* Image */}
<div className="aspect-4/3 relative bg-zinc-800">
{bottle.image_url ? (
<img
src={bottle.image_url}
alt={bottle.name || 'Bottle'}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<Wine size={48} className="text-zinc-700" />
</div>
)}
{/* Category Badge */}
{bottle.category && (
<span className="absolute top-2 left-2 px-2 py-1 bg-black/60 backdrop-blur-xs text-[10px] font-bold text-white rounded-lg uppercase">
{bottle.category}
</span>
)}
{/* Rating Badge */}
{avgRating > 0 && (
<span className="absolute top-2 right-2 px-2 py-1 bg-orange-600/90 backdrop-blur-xs text-xs font-bold text-white rounded-lg flex items-center gap-1">
<Star size={12} fill="currentColor" />
{avgRating.toFixed(1)}
</span>
)}
</div>
{/* Info */}
<div className="p-4">
<h3 className="font-bold text-white truncate mb-1">
{bottle.name || 'Unknown'}
</h3>
<p className="text-sm text-zinc-500 truncate mb-3">
{bottle.distillery || 'Unknown Distillery'}
{bottle.age && `${bottle.age}y`}
{bottle.abv && `${bottle.abv}%`}
</p>
{/* User & Date */}
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1 text-zinc-400">
<User size={12} />
{bottle.user.display_name || bottle.user.username}
</span>
<span className="text-zinc-600">
{new Date(bottle.created_at).toLocaleDateString('de-DE')}
</span>
</div>
</div>
</div>
);
})}
</div>
{/* Empty State */}
{filteredBottles.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
<Wine size={32} className="text-zinc-600" />
</div>
<p className="text-lg font-bold text-white mb-2">No Bottles Found</p>
<p className="text-sm text-zinc-500">
{hasFilters ? 'Try adjusting your filters.' : 'No bottles have been scanned yet.'}
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
import Link from 'next/link';
import { ArrowLeft, Wine, User, Calendar, Star, Search, Filter } from 'lucide-react';
import AdminBottlesList from './AdminBottlesList';
export default async function AdminBottlesPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/');
}
const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) {
redirect('/');
}
// Fetch all bottles from all users with user info
const { data: bottlesRaw, error } = await supabase
.from('bottles')
.select(`
id,
name,
distillery,
image_url,
abv,
age,
category,
status,
created_at,
user_id,
tastings (
id,
rating
)
`)
.order('created_at', { ascending: false })
.limit(500);
// Get unique user IDs
const userIds = Array.from(new Set(bottlesRaw?.map(b => b.user_id) || []));
// Fetch profiles for these users
const { data: profiles } = userIds.length > 0
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
: { data: [] };
// Combine bottles with user info
const bottles = bottlesRaw?.map(bottle => ({
...bottle,
user: profiles?.find(p => p.id === bottle.user_id) || { username: 'Unknown', display_name: null }
})) || [];
// Calculate stats
const stats = {
totalBottles: bottles.length,
totalUsers: userIds.length,
avgRating: bottles.reduce((sum, b) => {
const ratings = b.tastings?.map((t: any) => t.rating).filter((r: number) => r > 0) || [];
const avg = ratings.length > 0 ? ratings.reduce((a: number, b: number) => a + b, 0) / ratings.length : 0;
return sum + avg;
}, 0) / bottles.filter(b => b.tastings && b.tastings.length > 0).length || 0,
topDistilleries: Object.entries(
bottles.reduce((acc: Record<string, number>, b) => {
const d = b.distillery || 'Unknown';
acc[d] = (acc[d] || 0) + 1;
return acc;
}, {})
).sort((a, b) => b[1] - a[1]).slice(0, 5),
};
return (
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/admin"
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold text-white">All Bottles</h1>
<p className="text-sm text-zinc-500">
View all scanned bottles from all users
</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
Error loading bottles: {error.message}
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Wine size={18} className="text-orange-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Total Bottles</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalBottles}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<User size={18} className="text-blue-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Total Users</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalUsers}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Star size={18} className="text-yellow-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Avg Rating</span>
</div>
<div className="text-2xl font-black text-white">
{stats.avgRating > 0 ? stats.avgRating.toFixed(1) : 'N/A'}
</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Calendar size={18} className="text-green-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Top Distillery</span>
</div>
<div className="text-lg font-black text-white truncate">
{stats.topDistilleries[0]?.[0] || 'N/A'}
</div>
{stats.topDistilleries[0] && (
<div className="text-xs text-zinc-500">{stats.topDistilleries[0][1]} bottles</div>
)}
</div>
</div>
{/* Top Distilleries */}
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800 mb-8">
<h3 className="text-sm font-bold text-zinc-400 uppercase mb-3">Top 5 Distilleries</h3>
<div className="flex flex-wrap gap-2">
{stats.topDistilleries.map(([name, count]) => (
<span
key={name}
className="px-3 py-1.5 bg-zinc-800 rounded-lg text-sm text-zinc-300"
>
{name} <span className="text-orange-500 font-bold">({count})</span>
</span>
))}
</div>
</div>
{/* Bottles List - Client Component for search/filter */}
<AdminBottlesList bottles={bottles} />
</div>
</main>
);
}

View File

@@ -0,0 +1,254 @@
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
import { getOcrLogs, getOcrStats } from '@/services/save-ocr-log';
import { Eye, Camera, TrendingUp, CheckCircle, AlertCircle, Calendar, Clock, Percent } from 'lucide-react';
import Link from 'next/link';
import Image from 'next/image';
export default async function OcrLogsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/');
}
const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) {
redirect('/');
}
// Fetch OCR data
const [logsResult, stats] = await Promise.all([
getOcrLogs(100),
getOcrStats(),
]);
const logs = logsResult.data || [];
return (
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12">
<div className="max-w-7xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">OCR Dashboard</h1>
<p className="text-zinc-500 mt-1">Mobile OCR Scan Results</p>
</div>
<div className="flex gap-3">
<Link
href="/admin"
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
>
Back to Admin
</Link>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Camera size={20} className="text-blue-600 dark:text-blue-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Total Scans</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.totalScans}</div>
<div className="text-xs text-zinc-500 mt-1">All time</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Calendar size={20} className="text-green-600 dark:text-green-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Today</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.todayScans}</div>
<div className="text-xs text-zinc-500 mt-1">Scans today</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<Percent size={20} className="text-amber-600 dark:text-amber-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Avg Confidence</span>
</div>
<div className="text-3xl font-black text-zinc-900 dark:text-white">{stats.avgConfidence}%</div>
<div className="text-xs text-zinc-500 mt-1">Recognition quality</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<TrendingUp size={20} className="text-purple-600 dark:text-purple-400" />
</div>
<span className="text-xs font-black uppercase text-zinc-400">Top Distillery</span>
</div>
<div className="text-xl font-black text-zinc-900 dark:text-white truncate">
{stats.topDistilleries[0]?.name || '-'}
</div>
<div className="text-xs text-zinc-500 mt-1">
{stats.topDistilleries[0] ? `${stats.topDistilleries[0].count} scans` : 'No data'}
</div>
</div>
</div>
{/* Top Distilleries */}
{stats.topDistilleries.length > 0 && (
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Most Scanned Distilleries</h2>
<div className="flex flex-wrap gap-2">
{stats.topDistilleries.map((d, i) => (
<span
key={d.name}
className={`px-3 py-1.5 rounded-full text-sm font-bold ${i === 0
? 'bg-orange-600 text-white'
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300'
}`}
>
{d.name} ({d.count})
</span>
))}
</div>
</div>
)}
{/* OCR Logs Grid */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent OCR Scans</h2>
{logs.length === 0 ? (
<div className="text-center py-12 text-zinc-500">
<Camera className="mx-auto mb-3" size={48} />
<p>No OCR scans recorded yet</p>
<p className="text-sm mt-1">Scans from mobile devices will appear here</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{logs.map((log: any) => (
<div
key={log.id}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 border border-zinc-200 dark:border-zinc-700 hover:border-orange-500/50 transition-colors"
>
{/* Image Preview */}
<div className="relative aspect-4/3 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 mb-3">
{log.image_thumbnail ? (
<img
src={log.image_thumbnail}
alt="Scan"
className="w-full h-full object-cover"
/>
) : log.image_url ? (
<img
src={log.image_url}
alt="Scan"
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center h-full text-zinc-400">
<Camera size={32} />
</div>
)}
{/* Confidence Badge */}
<div className={`absolute top-2 right-2 px-2 py-1 rounded-full text-[10px] font-black ${log.confidence >= 70
? 'bg-green-500 text-white'
: log.confidence >= 40
? 'bg-amber-500 text-white'
: 'bg-red-500 text-white'
}`}>
{log.confidence}%
</div>
</div>
{/* Detected Fields */}
<div className="space-y-2">
{log.distillery && (
<div className="flex items-center gap-2">
<CheckCircle size={14} className="text-green-500" />
<span className="text-sm font-bold text-zinc-900 dark:text-white">
{log.distillery}
</span>
{log.distillery_source && (
<span className="text-[10px] px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-sm text-zinc-500">
{log.distillery_source}
</span>
)}
</div>
)}
{log.bottle_name && (
<div className="text-sm text-zinc-600 dark:text-zinc-400 truncate">
{log.bottle_name}
</div>
)}
<div className="flex flex-wrap gap-1.5">
{log.abv && (
<span className="px-2 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded-sm text-[10px] font-bold">
{log.abv}%
</span>
)}
{log.age && (
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-sm text-[10px] font-bold">
{log.age}y
</span>
)}
{log.vintage && (
<span className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded-sm text-[10px] font-bold">
{log.vintage}
</span>
)}
{log.volume && (
<span className="px-2 py-0.5 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded-sm text-[10px] font-bold">
{log.volume}
</span>
)}
</div>
</div>
{/* Raw Text (Collapsible) */}
{log.raw_text && (
<details className="mt-3">
<summary className="text-[10px] font-bold text-zinc-400 cursor-pointer hover:text-orange-500 uppercase">
Raw Text
</summary>
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-900 rounded-sm text-[9px] text-zinc-500 overflow-x-auto max-h-20 whitespace-pre-wrap">
{log.raw_text}
</pre>
</details>
)}
{/* Meta */}
<div className="flex items-center justify-between mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-700">
<div className="flex items-center gap-1 text-[10px] text-zinc-400">
<Clock size={12} />
{new Date(log.created_at).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</div>
<div className="text-[10px] text-zinc-400">
{log.profiles?.username || 'Unknown'}
</div>
{log.processing_time_ms && (
<div className="text-[10px] text-zinc-400">
{log.processing_time_ms}ms
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</main>
);
}

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage'; import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';
@@ -93,6 +92,12 @@ export default async function AdminPage() {
<p className="text-zinc-500 mt-1">API Usage Monitoring & Statistics</p> <p className="text-zinc-500 mt-1">API Usage Monitoring & Statistics</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Link
href="/admin/ocr-logs"
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white rounded-xl font-bold transition-colors"
>
OCR Logs
</Link>
<Link <Link
href="/admin/plans" href="/admin/plans"
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors" className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors"
@@ -111,6 +116,36 @@ export default async function AdminPage() {
> >
Manage Users Manage Users
</Link> </Link>
<Link
href="/admin/banners"
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white rounded-xl font-bold transition-colors"
>
Manage Banners
</Link>
<Link
href="/admin/bottles"
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-bold transition-colors"
>
All Bottles
</Link>
<Link
href="/admin/splits"
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors"
>
All Splits
</Link>
<Link
href="/admin/tastings"
className="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white rounded-xl font-bold transition-colors"
>
All Tastings
</Link>
<Link
href="/admin/sessions"
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-xl font-bold transition-colors"
>
All Sessions
</Link>
<Link <Link
href="/" href="/"
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors" className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
@@ -122,7 +157,7 @@ export default async function AdminPage() {
{/* Global Stats Cards */} {/* Global Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg"> <div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<BarChart3 size={20} className="text-blue-600 dark:text-blue-400" /> <BarChart3 size={20} className="text-blue-600 dark:text-blue-400" />
@@ -133,7 +168,7 @@ export default async function AdminPage() {
<div className="text-xs text-zinc-500 mt-1">All time</div> <div className="text-xs text-zinc-500 mt-1">All time</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg"> <div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Calendar size={20} className="text-green-600 dark:text-green-400" /> <Calendar size={20} className="text-green-600 dark:text-green-400" />
@@ -152,7 +187,7 @@ export default async function AdminPage() {
</div> </div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg"> <div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" /> <TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
@@ -163,7 +198,7 @@ export default async function AdminPage() {
<div className="text-xs text-zinc-500 mt-1">Whiskybase searches</div> <div className="text-xs text-zinc-500 mt-1">Whiskybase searches</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg"> <div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<Users size={20} className="text-purple-600 dark:text-purple-400" /> <Users size={20} className="text-purple-600 dark:text-purple-400" />
@@ -176,7 +211,7 @@ export default async function AdminPage() {
</div> </div>
{/* Top Users */} {/* Top Users */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Top Users by API Usage</h2> <h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Top Users by API Usage</h2>
<div className="space-y-3"> <div className="space-y-3">
{topUsersWithStats.map((user, index) => ( {topUsersWithStats.map((user, index) => (
@@ -199,7 +234,7 @@ export default async function AdminPage() {
</div> </div>
{/* Recent API Calls */} {/* Recent API Calls */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent API Calls</h2> <h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent API Calls</h2>
<div className="text-sm text-zinc-500 mb-4"> <div className="text-sm text-zinc-500 mb-4">
Total calls logged: {recentUsage?.length || 0} Total calls logged: {recentUsage?.length || 0}
@@ -260,7 +295,7 @@ export default async function AdminPage() {
{call.response_text && ( {call.response_text && (
<details className="text-[10px]"> <details className="text-[10px]">
<summary className="cursor-pointer text-orange-600 hover:text-orange-700 font-bold uppercase transition-colors">Response</summary> <summary className="cursor-pointer text-orange-600 hover:text-orange-700 font-bold uppercase transition-colors">Response</summary>
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-950 rounded border border-zinc-200 dark:border-zinc-800 overflow-x-auto max-w-sm whitespace-pre-wrap font-mono text-[9px] text-zinc-400"> <pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-950 rounded-sm border border-zinc-200 dark:border-zinc-800 overflow-x-auto max-w-sm whitespace-pre-wrap font-mono text-[9px] text-zinc-400">
{call.response_text} {call.response_text}
</pre> </pre>
</details> </details>
@@ -274,7 +309,7 @@ export default async function AdminPage() {
<div className="group relative"> <div className="group relative">
<span className="text-red-600 dark:text-red-400 font-black text-xs cursor-help">ERR</span> <span className="text-red-600 dark:text-red-400 font-black text-xs cursor-help">ERR</span>
{call.error_message && ( {call.error_message && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2 bg-red-600 text-white text-[9px] rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50"> <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2 bg-red-600 text-white text-[9px] rounded-sm shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
{call.error_message} {call.error_message}
</div> </div>
)} )}

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage'; import { checkIsAdmin } from '@/services/track-api-usage';

View File

@@ -0,0 +1,220 @@
'use client';
import { useState, useMemo } from 'react';
import { Search, User, Calendar, GlassWater, Users, Check, X, Clock, ExternalLink } from 'lucide-react';
import Link from 'next/link';
interface Session {
id: string;
name: string;
user_id: string;
scheduled_at: string;
ended_at: string | null;
created_at: string;
user: { username: string; display_name: string | null };
participantCount: number;
tastingCount: number;
}
interface AdminSessionsListProps {
sessions: Session[];
}
export default function AdminSessionsList({ sessions }: AdminSessionsListProps) {
const [search, setSearch] = useState('');
const [filterHost, setFilterHost] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'ended'>('all');
// Get unique hosts for filter
const hosts = useMemo(() => {
const hostMap = new Map<string, string>();
sessions.forEach(s => {
hostMap.set(s.user_id, s.user.display_name || s.user.username);
});
return Array.from(hostMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [sessions]);
// Filter sessions
const filteredSessions = useMemo(() => {
let result = sessions;
if (search) {
const searchLower = search.toLowerCase();
result = result.filter(s =>
s.name?.toLowerCase().includes(searchLower) ||
s.user.username.toLowerCase().includes(searchLower) ||
s.user.display_name?.toLowerCase().includes(searchLower)
);
}
if (filterHost) {
result = result.filter(s => s.user_id === filterHost);
}
if (filterStatus === 'active') {
result = result.filter(s => !s.ended_at);
} else if (filterStatus === 'ended') {
result = result.filter(s => s.ended_at);
}
return result;
}, [sessions, search, filterHost, filterStatus]);
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getSessionDuration = (start: string, end: string | null) => {
const startDate = new Date(start);
const endDate = end ? new Date(end) : new Date();
const diffMs = endDate.getTime() - startDate.getTime();
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
};
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search sessions or hosts..."
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/>
</div>
<div className="flex gap-2">
<select
value={filterHost || ''}
onChange={e => setFilterHost(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
>
<option value="">All Hosts</option>
{hosts.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value as any)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="ended">Ended</option>
</select>
</div>
</div>
{/* Results */}
<div className="text-sm text-zinc-500">
Showing {filteredSessions.length} of {sessions.length} sessions
</div>
{/* Sessions List */}
<div className="space-y-3">
{filteredSessions.map(session => (
<div
key={session.id}
className={`bg-zinc-900 rounded-2xl border p-4 transition-colors ${!session.ended_at
? 'border-orange-600/30 hover:border-orange-600/50'
: 'border-zinc-800 hover:border-zinc-700'
}`}
>
<div className="flex items-center gap-4">
{/* Icon */}
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${!session.ended_at
? 'bg-orange-600/20'
: 'bg-zinc-800'
}`}>
<GlassWater size={24} className={
!session.ended_at ? 'text-orange-500' : 'text-zinc-500'
} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-bold text-white truncate">{session.name}</h3>
{!session.ended_at ? (
<span className="px-2 py-0.5 bg-orange-600/20 text-orange-500 text-[10px] font-bold uppercase rounded-full flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse" />
Live
</span>
) : (
<span className="px-2 py-0.5 bg-zinc-800 text-zinc-500 text-[10px] font-bold uppercase rounded-full flex items-center gap-1">
<Check size={10} />
Ended
</span>
)}
</div>
<div className="flex items-center gap-4 text-xs text-zinc-500">
<span className="flex items-center gap-1">
<User size={12} />
{session.user.display_name || session.user.username}
</span>
<span className="flex items-center gap-1">
<Calendar size={12} />
{formatDate(session.scheduled_at)}
</span>
<span className="flex items-center gap-1">
<Clock size={12} />
{getSessionDuration(session.scheduled_at, session.ended_at)}
</span>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-lg font-bold text-white">{session.participantCount}</div>
<div className="text-[10px] text-zinc-600 uppercase">Buddies</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-orange-500">{session.tastingCount}</div>
<div className="text-[10px] text-zinc-600 uppercase">Tastings</div>
</div>
</div>
{/* Link */}
<Link
href={`/sessions/${session.id}`}
target="_blank"
className="p-2 text-zinc-500 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
>
<ExternalLink size={18} />
</Link>
</div>
</div>
))}
</div>
{/* Empty State */}
{filteredSessions.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
<Calendar size={32} className="text-zinc-600" />
</div>
<p className="text-lg font-bold text-white mb-2">No Sessions Found</p>
<p className="text-sm text-zinc-500">No tasting sessions match your filters.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
import Link from 'next/link';
import { ArrowLeft, Calendar, User, Users, GlassWater, Clock, CheckCircle } from 'lucide-react';
import AdminSessionsList from './AdminSessionsList';
export default async function AdminSessionsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/');
}
const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) {
redirect('/');
}
// Fetch all sessions from all users
const { data: sessionsRaw, error } = await supabase
.from('tasting_sessions')
.select(`
id,
name,
user_id,
scheduled_at,
ended_at,
created_at,
session_participants (id),
tastings (id)
`)
.order('created_at', { ascending: false })
.limit(500);
// Get unique user IDs
const userIds = Array.from(new Set(sessionsRaw?.map(s => s.user_id) || []));
// Fetch profiles for users
const { data: profiles } = userIds.length > 0
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
: { data: [] };
// Combine sessions with user info
const sessions = sessionsRaw?.map(session => ({
...session,
user: profiles?.find(p => p.id === session.user_id) || { username: 'Unknown', display_name: null },
participantCount: (session.session_participants as any[])?.length || 0,
tastingCount: (session.tastings as any[])?.length || 0,
})) || [];
// Calculate stats
const stats = {
totalSessions: sessions.length,
activeSessions: sessions.filter(s => !s.ended_at).length,
totalHosts: userIds.length,
totalParticipants: sessions.reduce((sum, s) => sum + s.participantCount, 0),
totalTastings: sessions.reduce((sum, s) => sum + s.tastingCount, 0),
};
return (
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/admin"
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold text-white">All Tasting Sessions</h1>
<p className="text-sm text-zinc-500">
View all tasting sessions from all users
</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
Error loading sessions: {error.message}
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Calendar size={18} className="text-purple-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Total Sessions</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalSessions}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Clock size={18} className="text-green-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Active</span>
</div>
<div className="text-2xl font-black text-white">{stats.activeSessions}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<User size={18} className="text-blue-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Hosts</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalHosts}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Users size={18} className="text-orange-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Participants</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalParticipants}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<GlassWater size={18} className="text-yellow-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Tastings</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalTastings}</div>
</div>
</div>
{/* Sessions List */}
<AdminSessionsList sessions={sessions} />
</div>
</main>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { useState, useMemo } from 'react';
import { Search, User, Share2, Users, Check, X, ExternalLink } from 'lucide-react';
import Link from 'next/link';
interface Split {
id: string;
public_slug: string;
host_id: string;
total_volume: number;
host_share: number;
price_bottle: number;
is_active: boolean;
created_at: string;
host: { username: string; display_name: string | null };
bottle: { id: string; name: string; distillery: string | null; image_url: string | null } | null;
participantCount: number;
totalReserved: number;
}
interface AdminSplitsListProps {
splits: Split[];
}
export default function AdminSplitsList({ splits }: AdminSplitsListProps) {
const [search, setSearch] = useState('');
const [filterHost, setFilterHost] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'closed'>('all');
// Get unique hosts for filter
const hosts = useMemo(() => {
const hostMap = new Map<string, string>();
splits.forEach(s => {
hostMap.set(s.host_id, s.host.display_name || s.host.username);
});
return Array.from(hostMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [splits]);
// Filter splits
const filteredSplits = useMemo(() => {
let result = splits;
if (search) {
const searchLower = search.toLowerCase();
result = result.filter(s =>
s.bottle?.name?.toLowerCase().includes(searchLower) ||
s.bottle?.distillery?.toLowerCase().includes(searchLower) ||
s.host.username.toLowerCase().includes(searchLower) ||
s.public_slug.toLowerCase().includes(searchLower)
);
}
if (filterHost) {
result = result.filter(s => s.host_id === filterHost);
}
if (filterStatus === 'active') {
result = result.filter(s => s.is_active);
} else if (filterStatus === 'closed') {
result = result.filter(s => !s.is_active);
}
return result;
}, [splits, search, filterHost, filterStatus]);
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search bottles, hosts, or slugs..."
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/>
</div>
<div className="flex gap-2">
<select
value={filterHost || ''}
onChange={e => setFilterHost(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
>
<option value="">All Hosts</option>
{hosts.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value as any)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="closed">Closed</option>
</select>
</div>
</div>
{/* Results */}
<div className="text-sm text-zinc-500">
Showing {filteredSplits.length} of {splits.length} splits
</div>
{/* Splits Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSplits.map(split => {
const available = split.total_volume - split.host_share;
const remaining = available - split.totalReserved;
const fillPercent = Math.min(100, (split.totalReserved / available) * 100);
return (
<div
key={split.id}
className={`bg-zinc-900 rounded-2xl border overflow-hidden transition-colors ${split.is_active
? 'border-zinc-800 hover:border-zinc-700'
: 'border-zinc-800/50 opacity-60'
}`}
>
{/* Image */}
<div className="aspect-video relative bg-zinc-800">
{split.bottle?.image_url ? (
<img
src={split.bottle.image_url}
alt={split.bottle.name || 'Bottle'}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<Share2 size={48} className="text-zinc-700" />
</div>
)}
{/* Status Badge */}
<span className={`absolute top-2 left-2 px-2 py-1 text-[10px] font-bold rounded-lg flex items-center gap-1 ${split.is_active
? 'bg-green-600/90 text-white'
: 'bg-zinc-700/90 text-zinc-300'
}`}>
{split.is_active ? <Check size={10} /> : <X size={10} />}
{split.is_active ? 'Active' : 'Closed'}
</span>
{/* Participants Badge */}
<span className="absolute top-2 right-2 px-2 py-1 bg-black/60 backdrop-blur-xs text-xs font-bold text-white rounded-lg flex items-center gap-1">
<Users size={12} />
{split.participantCount}
</span>
</div>
{/* Info */}
<div className="p-4">
<h3 className="font-bold text-white truncate mb-1">
{split.bottle?.name || 'Unknown Bottle'}
</h3>
<p className="text-sm text-zinc-500 truncate mb-2">
{split.bottle?.distillery || 'Unknown Distillery'}
</p>
{/* Progress Bar */}
<div className="mb-3">
<div className="flex justify-between text-xs text-zinc-500 mb-1">
<span>{split.totalReserved}cl reserved</span>
<span>{remaining}cl left</span>
</div>
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-linear-to-r from-orange-500 to-orange-600 transition-all"
style={{ width: `${fillPercent}%` }}
/>
</div>
</div>
{/* Details */}
<div className="flex items-center justify-between text-xs mb-3">
<span className="flex items-center gap-1 text-zinc-400">
<User size={12} />
{split.host.display_name || split.host.username}
</span>
<span className="text-zinc-600">
{new Date(split.created_at).toLocaleDateString('de-DE')}
</span>
</div>
{/* Price & Link */}
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-orange-500">
{split.price_bottle.toFixed(2)}
</span>
<Link
href={`/splits/${split.public_slug}`}
target="_blank"
className="flex items-center gap-1 text-xs text-zinc-400 hover:text-white transition-colors"
>
<ExternalLink size={12} />
{split.public_slug}
</Link>
</div>
</div>
</div>
);
})}
</div>
{/* Empty State */}
{filteredSplits.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
<Share2 size={32} className="text-zinc-600" />
</div>
<p className="text-lg font-bold text-white mb-2">No Splits Found</p>
<p className="text-sm text-zinc-500">No bottle splits match your filters.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
import Link from 'next/link';
import { ArrowLeft, Share2, User, Calendar, Users, DollarSign, Package } from 'lucide-react';
import AdminSplitsList from './AdminSplitsList';
export default async function AdminSplitsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/');
}
const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) {
redirect('/');
}
// Fetch all splits from all users
const { data: splitsRaw, error } = await supabase
.from('bottle_splits')
.select(`
id,
public_slug,
bottle_id,
host_id,
total_volume,
host_share,
price_bottle,
is_active,
created_at,
bottles (id, name, distillery, image_url),
split_participants (id, amount_cl, status, user_id)
`)
.order('created_at', { ascending: false })
.limit(500);
// Get unique host IDs
const hostIds = Array.from(new Set(splitsRaw?.map(s => s.host_id) || []));
// Fetch profiles for hosts
const { data: profiles } = hostIds.length > 0
? await supabase.from('profiles').select('id, username, display_name').in('id', hostIds)
: { data: [] };
// Combine splits with host info
const splits = splitsRaw?.map(split => ({
...split,
host: profiles?.find(p => p.id === split.host_id) || { username: 'Unknown', display_name: null },
bottle: split.bottles as any,
participantCount: (split.split_participants as any[])?.length || 0,
totalReserved: (split.split_participants as any[])?.reduce((sum: number, p: any) =>
['APPROVED', 'PAID', 'SHIPPED', 'PENDING'].includes(p.status) ? sum + p.amount_cl : sum, 0
) || 0,
})) || [];
// Calculate stats
const stats = {
totalSplits: splits.length,
activeSplits: splits.filter(s => s.is_active).length,
totalHosts: hostIds.length,
totalParticipants: splits.reduce((sum, s) => sum + s.participantCount, 0),
totalVolume: splits.reduce((sum, s) => sum + s.total_volume, 0),
};
return (
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/admin"
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold text-white">All Bottle Splits</h1>
<p className="text-sm text-zinc-500">
View all bottle splits from all users
</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
Error loading splits: {error.message}
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Share2 size={18} className="text-purple-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Total Splits</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalSplits}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Package size={18} className="text-green-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Active</span>
</div>
<div className="text-2xl font-black text-white">{stats.activeSplits}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<User size={18} className="text-blue-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Hosts</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalHosts}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Users size={18} className="text-orange-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Participants</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalParticipants}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<DollarSign size={18} className="text-yellow-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Total Volume</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalVolume}cl</div>
</div>
</div>
{/* Splits List */}
<AdminSplitsList splits={splits} />
</div>
</main>
);
}

View File

@@ -88,7 +88,7 @@ export default function AdminTagsPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Tags suchen..." placeholder="Tags suchen..."
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all dark:text-zinc-200" className="w-full pl-10 pr-4 py-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-hidden transition-all dark:text-zinc-200"
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -96,7 +96,7 @@ export default function AdminTagsPage() {
<select <select
value={categoryFilter} value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as any)} onChange={(e) => setCategoryFilter(e.target.value as any)}
className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-sm font-bold uppercase tracking-tight outline-none focus:ring-2 focus:ring-amber-500 dark:text-zinc-200" className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-sm font-bold uppercase tracking-tight outline-hidden focus:ring-2 focus:ring-amber-500 dark:text-zinc-200"
> >
<option value="all">Alle Kategorien</option> <option value="all">Alle Kategorien</option>
<option value="nose">Nose</option> <option value="nose">Nose</option>
@@ -154,7 +154,7 @@ export default function AdminTagsPage() {
key={score} key={score}
onClick={() => updatePopularity(tag.id, score)} onClick={() => updatePopularity(tag.id, score)}
className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black transition-all ${tag.popularity_score === score className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black transition-all ${tag.popularity_score === score
? 'bg-amber-600 text-white shadow-sm' ? 'bg-amber-600 text-white shadow-xs'
: 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700' : 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700'
}`} }`}
> >

View File

@@ -0,0 +1,226 @@
'use client';
import { useState, useMemo } from 'react';
import { Search, User, Star, Wine, MessageSquare } from 'lucide-react';
interface Tasting {
id: string;
bottle_id: string;
user_id: string;
rating: number;
nose_notes: string | null;
palate_notes: string | null;
finish_notes: string | null;
created_at: string;
user: { username: string; display_name: string | null };
bottle: { id: string; name: string; distillery: string | null; image_url: string | null } | null;
}
interface AdminTastingsListProps {
tastings: Tasting[];
}
export default function AdminTastingsList({ tastings }: AdminTastingsListProps) {
const [search, setSearch] = useState('');
const [filterUser, setFilterUser] = useState<string | null>(null);
const [filterRating, setFilterRating] = useState<number | null>(null);
// Get unique users for filter
const users = useMemo(() => {
const userMap = new Map<string, string>();
tastings.forEach(t => {
userMap.set(t.user_id, t.user.display_name || t.user.username);
});
return Array.from(userMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [tastings]);
// Filter tastings
const filteredTastings = useMemo(() => {
let result = tastings;
if (search) {
const searchLower = search.toLowerCase();
result = result.filter(t =>
t.bottle?.name?.toLowerCase().includes(searchLower) ||
t.bottle?.distillery?.toLowerCase().includes(searchLower) ||
t.user.username.toLowerCase().includes(searchLower) ||
t.nose_notes?.toLowerCase().includes(searchLower) ||
t.palate_notes?.toLowerCase().includes(searchLower) ||
t.finish_notes?.toLowerCase().includes(searchLower)
);
}
if (filterUser) {
result = result.filter(t => t.user_id === filterUser);
}
if (filterRating !== null) {
result = result.filter(t => Math.floor(t.rating) === filterRating);
}
return result;
}, [tastings, search, filterUser, filterRating]);
const renderStars = (rating: number) => {
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map(star => (
<Star
key={star}
size={14}
className={star <= rating ? 'text-orange-500 fill-orange-500' : 'text-zinc-700'}
/>
))}
</div>
);
};
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search bottles, users, or notes..."
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/>
</div>
<div className="flex gap-2">
<select
value={filterUser || ''}
onChange={e => setFilterUser(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
>
<option value="">All Users</option>
{users.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
<select
value={filterRating ?? ''}
onChange={e => setFilterRating(e.target.value ? parseInt(e.target.value) : null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
>
<option value="">All Ratings</option>
<option value="5">5 Stars</option>
<option value="4">4 Stars</option>
<option value="3">3 Stars</option>
<option value="2">2 Stars</option>
<option value="1">1 Star</option>
</select>
</div>
</div>
{/* Results */}
<div className="text-sm text-zinc-500">
Showing {filteredTastings.length} of {tastings.length} tastings
</div>
{/* Tastings List */}
<div className="space-y-3">
{filteredTastings.map(tasting => {
const hasNotes = tasting.nose_notes || tasting.palate_notes || tasting.finish_notes;
return (
<div
key={tasting.id}
className="bg-zinc-900 rounded-2xl border border-zinc-800 p-4 hover:border-zinc-700 transition-colors"
>
<div className="flex gap-4">
{/* Bottle Image */}
<div className="w-16 h-16 rounded-xl overflow-hidden bg-zinc-800 shrink-0">
{tasting.bottle?.image_url ? (
<img
src={tasting.bottle.image_url}
alt={tasting.bottle.name || 'Bottle'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Wine size={24} className="text-zinc-700" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<div>
<h3 className="font-bold text-white truncate">
{tasting.bottle?.name || 'Unknown Bottle'}
</h3>
<p className="text-sm text-zinc-500 truncate">
{tasting.bottle?.distillery || 'Unknown Distillery'}
</p>
</div>
<div className="shrink-0">
{tasting.rating > 0 ? renderStars(tasting.rating) : (
<span className="text-xs text-zinc-600">No rating</span>
)}
</div>
</div>
{/* Notes Preview */}
{hasNotes && (
<div className="mt-2 space-y-1">
{tasting.nose_notes && (
<p className="text-xs text-zinc-400">
<span className="text-zinc-600">Nose:</span> {tasting.nose_notes.slice(0, 80)}...
</p>
)}
{tasting.palate_notes && (
<p className="text-xs text-zinc-400">
<span className="text-zinc-600">Palate:</span> {tasting.palate_notes.slice(0, 80)}...
</p>
)}
</div>
)}
{/* Meta */}
<div className="flex items-center gap-4 mt-2 text-xs text-zinc-600">
<span className="flex items-center gap-1">
<User size={12} />
{tasting.user.display_name || tasting.user.username}
</span>
<span>
{new Date(tasting.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
{hasNotes && (
<span className="flex items-center gap-1 text-green-500">
<MessageSquare size={12} />
Has notes
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Empty State */}
{filteredTastings.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
<Star size={32} className="text-zinc-600" />
</div>
<p className="text-lg font-bold text-white mb-2">No Tastings Found</p>
<p className="text-sm text-zinc-500">No tastings match your filters.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
import Link from 'next/link';
import { ArrowLeft, Wine, User, Calendar, Star, MessageSquare, Sparkles } from 'lucide-react';
import AdminTastingsList from './AdminTastingsList';
export default async function AdminTastingsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/');
}
const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) {
redirect('/');
}
// Fetch all tastings from all users
const { data: tastingsRaw, error } = await supabase
.from('tastings')
.select(`
id,
bottle_id,
user_id,
rating,
nose_notes,
palate_notes,
finish_notes,
created_at,
bottles (id, name, distillery, image_url)
`)
.order('created_at', { ascending: false })
.limit(500);
// Get unique user IDs
const userIds = Array.from(new Set(tastingsRaw?.map(t => t.user_id) || []));
// Fetch profiles for users
const { data: profiles } = userIds.length > 0
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
: { data: [] };
// Combine tastings with user info
const tastings = tastingsRaw?.map(tasting => ({
...tasting,
user: profiles?.find(p => p.id === tasting.user_id) || { username: 'Unknown', display_name: null },
bottle: tasting.bottles as any,
})) || [];
// Calculate stats
const stats = {
totalTastings: tastings.length,
totalUsers: userIds.length,
avgRating: tastings.length > 0
? tastings.reduce((sum, t) => sum + (t.rating || 0), 0) / tastings.filter(t => t.rating > 0).length
: 0,
withNotes: tastings.filter(t => t.nose_notes || t.palate_notes || t.finish_notes).length,
todayCount: tastings.filter(t => {
const today = new Date();
const tastingDate = new Date(t.created_at);
return tastingDate.toDateString() === today.toDateString();
}).length,
};
return (
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/admin"
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold text-white">All Tastings</h1>
<p className="text-sm text-zinc-500">
View all tasting notes from all users
</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
Error loading tastings: {error.message}
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Sparkles size={18} className="text-purple-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Total Tastings</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalTastings}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<User size={18} className="text-blue-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Users</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalUsers}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Star size={18} className="text-yellow-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Avg Rating</span>
</div>
<div className="text-2xl font-black text-white">
{stats.avgRating > 0 ? stats.avgRating.toFixed(1) : 'N/A'}
</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<MessageSquare size={18} className="text-green-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">With Notes</span>
</div>
<div className="text-2xl font-black text-white">{stats.withNotes}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Calendar size={18} className="text-orange-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Today</span>
</div>
<div className="text-2xl font-black text-white">{stats.todayCount}</div>
</div>
</div>
{/* Tastings List */}
<AdminTastingsList tastings={tastings} />
</div>
</main>
);
}

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage'; import { checkIsAdmin } from '@/services/track-api-usage';
@@ -53,7 +52,7 @@ export default async function AdminUsersPage() {
{/* Statistics Cards */} {/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg"> <div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Users size={20} className="text-blue-600 dark:text-blue-400" /> <Users size={20} className="text-blue-600 dark:text-blue-400" />
@@ -63,7 +62,7 @@ export default async function AdminUsersPage() {
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalUsers}</div> <div className="text-3xl font-black text-zinc-900 dark:text-white">{totalUsers}</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg"> <div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Coins size={20} className="text-green-600 dark:text-green-400" /> <Coins size={20} className="text-green-600 dark:text-green-400" />
@@ -73,7 +72,7 @@ export default async function AdminUsersPage() {
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsInCirculation.toLocaleString()}</div> <div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsInCirculation.toLocaleString()}</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg"> <div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" /> <TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
@@ -83,7 +82,7 @@ export default async function AdminUsersPage() {
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsPurchased.toLocaleString()}</div> <div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsPurchased.toLocaleString()}</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg"> <div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<TrendingDown size={20} className="text-purple-600 dark:text-purple-400" /> <TrendingDown size={20} className="text-purple-600 dark:text-purple-400" />

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* GlitchTip/Sentry Tunnel API Route
*
* This tunnels error reports from the client through our own API,
* bypassing ad blockers that might block direct Sentry/GlitchTip requests.
*/
export async function POST(request: NextRequest) {
const dsn = process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
console.log('[GlitchTip Tunnel] Received request');
console.log('[GlitchTip Tunnel] DSN:', dsn ? dsn.substring(0, 40) + '...' : 'NOT SET');
if (!dsn) {
console.error('[GlitchTip Tunnel] No DSN configured');
return NextResponse.json(
{ status: 'error', message: 'GlitchTip not configured' },
{ status: 503 }
);
}
try {
const body = await request.text();
console.log('[GlitchTip Tunnel] Body length:', body.length);
console.log('[GlitchTip Tunnel] Body preview:', body.substring(0, 200));
// Parse the envelope header to get the DSN from the actual request
// Sentry SDK sends: {"dsn":"...","sent_at":"..."}
const envelopeHeader = body.split('\n')[0];
let targetDsn = dsn;
try {
const headerData = JSON.parse(envelopeHeader);
if (headerData.dsn) {
targetDsn = headerData.dsn;
console.log('[GlitchTip Tunnel] Using DSN from envelope:', targetDsn.substring(0, 40) + '...');
}
} catch {
console.log('[GlitchTip Tunnel] Could not parse envelope header, using env DSN');
}
// Parse the DSN to extract components
// DSN format: https://<key>@<host>/<project_id>
const dsnUrl = new URL(targetDsn);
const key = dsnUrl.username;
const host = dsnUrl.host;
const projectId = dsnUrl.pathname.replace('/', '');
// GlitchTip uses the same API as Sentry
const glitchtipUrl = `https://${host}/api/${projectId}/envelope/`;
console.log('[GlitchTip Tunnel] Forwarding to:', glitchtipUrl);
const response = await fetch(glitchtipUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-sentry-envelope',
'X-Sentry-Auth': `Sentry sentry_version=7, sentry_client=sentry.javascript.nextjs, sentry_key=${key}`,
},
body: body,
});
const responseText = await response.text();
console.log('[GlitchTip Tunnel] Response status:', response.status);
console.log('[GlitchTip Tunnel] Response body:', responseText.substring(0, 200));
if (!response.ok) {
console.error('[GlitchTip Tunnel] Error response:', response.status, responseText);
return NextResponse.json(
{ status: 'error', message: 'Failed to forward to GlitchTip', details: responseText },
{ status: response.status }
);
}
console.log('[GlitchTip Tunnel] ✅ Successfully forwarded to GlitchTip');
return NextResponse.json({ status: 'ok' });
} catch (error: any) {
console.error('[GlitchTip Tunnel] Exception:', error);
return NextResponse.json(
{ status: 'error', message: error.message },
{ status: 500 }
);
}
}

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import sharp from 'sharp'; import sharp from 'sharp';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';

259
src/app/buddies/page.tsx Normal file
View File

@@ -0,0 +1,259 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Users, UserPlus, Loader2, Trash2, Link2, Search } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/context/AuthContext';
import { addBuddy, deleteBuddy } from '@/services/buddy';
import BuddyHandshake from '@/components/BuddyHandshake';
interface Buddy {
id: string;
name: string;
buddy_profile_id: string | null;
}
export default function BuddiesPage() {
const router = useRouter();
const { t } = useI18n();
const supabase = createClient();
const { user, isLoading: isAuthLoading } = useAuth();
const [buddies, setBuddies] = useState<Buddy[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isAdding, setIsAdding] = useState(false);
const [newName, setNewName] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isHandshakeOpen, setIsHandshakeOpen] = useState(false);
useEffect(() => {
if (!isAuthLoading && user) {
fetchBuddies();
}
}, [user, isAuthLoading]);
const fetchBuddies = async () => {
setIsLoading(true);
const { data, error } = await supabase
.from('buddies')
.select('*')
.order('name');
if (!error) {
setBuddies(data || []);
}
setIsLoading(false);
};
const handleAddBuddy = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
setIsAdding(true);
const result = await addBuddy({ name: newName.trim() });
if (result.success && result.data) {
setBuddies(prev => [...[result.data], ...prev].sort((a, b) => a.name.localeCompare(b.name)));
setNewName('');
}
setIsAdding(false);
};
const handleDeleteBuddy = async (id: string) => {
const result = await deleteBuddy(id);
if (result.success) {
setBuddies(prev => prev.filter(b => b.id !== id));
}
};
const filteredBuddies = buddies.filter(b =>
b.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const linkedBuddies = filteredBuddies.filter(b => b.buddy_profile_id);
const unlinkedBuddies = filteredBuddies.filter(b => !b.buddy_profile_id);
return (
<main className="min-h-screen bg-zinc-950 pb-24">
{/* Header */}
<div className="sticky top-0 z-20 bg-zinc-950/95 backdrop-blur-md border-b border-zinc-900">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<button
onClick={() => router.back()}
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-white">
{t('buddy.title')}
</h1>
<p className="text-xs text-zinc-500">
{buddies.length} Buddies
</p>
</div>
<button
onClick={() => setIsHandshakeOpen(true)}
className="p-2.5 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 hover:border-orange-600/50 rounded-xl text-orange-500 transition-colors"
>
<Link2 size={20} />
</button>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 pt-6">
{/* Add Buddy Form */}
<form onSubmit={handleAddBuddy} className="flex gap-2 mb-6">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('buddy.placeholder')}
className="flex-1 bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600 transition-colors"
/>
<button
type="submit"
disabled={isAdding || !newName.trim()}
className="px-4 bg-orange-600 hover:bg-orange-700 text-white rounded-xl transition-all disabled:opacity-50 flex items-center gap-2"
>
{isAdding ? (
<Loader2 size={20} className="animate-spin" />
) : (
<>
<UserPlus size={20} />
<span className="hidden sm:inline text-sm font-bold">Add</span>
</>
)}
</button>
</form>
{/* Search */}
{buddies.length > 5 && (
<div className="relative mb-6">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
placeholder="Search buddies..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600/50"
/>
</div>
)}
{/* Buddies List */}
{isLoading ? (
<div className="flex justify-center py-20">
<Loader2 size={32} className="animate-spin text-orange-600" />
</div>
) : buddies.length === 0 ? (
<div className="text-center py-20">
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
<Users size={32} className="text-zinc-600" />
</div>
<p className="text-lg font-bold text-white mb-2">{t('buddy.noBuddies')}</p>
<p className="text-sm text-zinc-500 max-w-xs mx-auto">
Add your tasting friends to share sessions and compare notes.
</p>
</div>
) : (
<div className="space-y-6">
{/* Linked Buddies */}
{linkedBuddies.length > 0 && (
<div>
<h3 className="text-[10px] font-bold uppercase tracking-widest text-orange-500/80 mb-3 flex items-center gap-2">
<Link2 size={12} />
Linked Accounts
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{linkedBuddies.map(buddy => (
<BuddyCard
key={buddy.id}
buddy={buddy}
onDelete={handleDeleteBuddy}
/>
))}
</div>
</div>
)}
{/* Unlinked Buddies */}
{unlinkedBuddies.length > 0 && (
<div>
{linkedBuddies.length > 0 && (
<h3 className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-3">
Other Buddies
</h3>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{unlinkedBuddies.map(buddy => (
<BuddyCard
key={buddy.id}
buddy={buddy}
onDelete={handleDeleteBuddy}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Buddy Handshake Dialog */}
<BuddyHandshake
isOpen={isHandshakeOpen}
onClose={() => setIsHandshakeOpen(false)}
onSuccess={() => {
setIsHandshakeOpen(false);
fetchBuddies();
}}
/>
</main>
);
}
function BuddyCard({ buddy, onDelete }: { buddy: Buddy; onDelete: (id: string) => void }) {
const { t } = useI18n();
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
setIsDeleting(true);
await onDelete(buddy.id);
setIsDeleting(false);
};
return (
<div className="flex items-center justify-between p-4 bg-zinc-900 rounded-2xl border border-zinc-800 group hover:border-zinc-700 transition-all">
<div className="flex items-center gap-3">
<div className={`w-11 h-11 rounded-xl flex items-center justify-center font-bold text-lg ${buddy.buddy_profile_id
? 'bg-orange-600/20 text-orange-500 border border-orange-600/30'
: 'bg-zinc-800 text-zinc-400 border border-zinc-700'
}`}>
{buddy.name[0].toUpperCase()}
</div>
<div>
<p className="font-bold text-white">{buddy.name}</p>
{buddy.buddy_profile_id && (
<p className="text-[9px] font-bold uppercase tracking-widest text-orange-500/80">
{t('common.link')}
</p>
)}
</div>
</div>
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-2 text-zinc-600 hover:text-red-500 hover:bg-zinc-800 rounded-xl opacity-0 group-hover:opacity-100 transition-all"
>
{isDeleting ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Trash2 size={16} />
)}
</button>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { AlertTriangle, RefreshCcw } from 'lucide-react'; import { AlertTriangle, RefreshCcw } from 'lucide-react';
import * as Sentry from "@sentry/nextjs";
export default function Error({ export default function Error({
error, error,
@@ -12,8 +13,11 @@ export default function Error({
}) { }) {
useEffect(() => { useEffect(() => {
console.error('App Crash Error:', error); console.error('App Crash Error:', error);
// Report error to Sentry/GlitchTip
Sentry.captureException(error);
}, [error]); }, [error]);
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-50 dark:bg-black text-center"> <div className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-50 dark:bg-black text-center">
<div className="bg-white dark:bg-zinc-900 p-8 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xl max-w-md w-full space-y-6"> <div className="bg-white dark:bg-zinc-900 p-8 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xl max-w-md w-full space-y-6">

View File

@@ -1,6 +1,8 @@
'use client'; 'use client';
import { RefreshCcw } from 'lucide-react'; import { RefreshCcw } from 'lucide-react';
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({ export default function GlobalError({
error, error,
@@ -9,6 +11,11 @@ export default function GlobalError({
error: Error & { digest?: string }; error: Error & { digest?: string };
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => {
// Report error to Sentry/GlitchTip
Sentry.captureException(error);
}, [error]);
return ( return (
<html lang="de"> <html lang="de">
<body> <body>

View File

@@ -1,6 +1,58 @@
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities; @theme {
--color-zinc-50: #fafafa;
--color-zinc-100: #f4f4f5;
--color-zinc-200: #e4e4e7;
--color-zinc-300: #d4d4d8;
--color-zinc-400: #a8a8b3;
--color-zinc-500: #8a8a95;
--color-zinc-600: #6b6b75;
--color-zinc-700: #4a4a52;
--color-zinc-800: #2a2a2e;
--color-zinc-900: #1a1a1e;
--color-zinc-950: #0d0d0f;
--background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
--background-image-gradient-conic: conic-gradient(
from 180deg at 50% 50%,
var(--tw-gradient-stops)
);
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility glass {
@apply backdrop-blur-md bg-zinc-900/50 border border-zinc-800/50;
}
@utility glass-dark {
@apply backdrop-blur-md bg-zinc-950/80 border border-zinc-900/50;
}
@utility scrollbar-hide {
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
@layer base { @layer base {
:root { :root {
@@ -20,47 +72,30 @@
} }
} }
body { @layer utilities {
body {
@apply bg-[#1c1c1e] text-[#fafafa] antialiased selection:bg-orange-500/30; @apply bg-[#1c1c1e] text-[#fafafa] antialiased selection:bg-orange-500/30;
font-feature-settings: "cv02", "cv03", "cv04", "cv11"; font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
} }
/* Global Input Text Fix */ /* Global Input Text Fix */
input, input,
textarea, textarea,
select { select {
@apply bg-zinc-950 text-white border-zinc-800 focus:ring-1 focus:ring-orange-600 outline-none transition-all; @apply bg-zinc-950 text-white border-zinc-800 focus:ring-1 focus:ring-orange-600 outline-hidden transition-all;
} }
input::placeholder, input::placeholder,
textarea::placeholder { textarea::placeholder {
@apply text-zinc-600; @apply text-zinc-600;
} }
h1, h1,
h2, h2,
h3, h3,
h4, h4,
.font-display { .font-display {
font-family: var(--font-inter), system-ui, sans-serif; font-family: var(--font-inter), system-ui, sans-serif;
letter-spacing: -0.02em; letter-spacing: -0.02em;
}
@layer utilities {
.glass {
@apply backdrop-blur-md bg-zinc-900/50 border border-zinc-800/50;
}
.glass-dark {
@apply backdrop-blur-md bg-zinc-950/80 border border-zinc-900/50;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
} }
} }

View File

@@ -12,6 +12,8 @@ import MainContentWrapper from "@/components/MainContentWrapper";
import SyncHandler from "@/components/SyncHandler"; import SyncHandler from "@/components/SyncHandler";
import CookieBanner from "@/components/CookieBanner"; import CookieBanner from "@/components/CookieBanner";
import OnboardingTutorial from "@/components/OnboardingTutorial"; import OnboardingTutorial from "@/components/OnboardingTutorial";
import BackgroundRemovalHandler from "@/components/BackgroundRemovalHandler";
import SentryInit from "@/components/SentryInit";
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' }); const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
@@ -48,12 +50,15 @@ export default function RootLayout({
return ( return (
<html lang="de" suppressHydrationWarning={true}> <html lang="de" suppressHydrationWarning={true}>
<body className={`${inter.variable} font-sans`}> <body className={`${inter.variable} font-sans`}>
<SentryInit />
<I18nProvider> <I18nProvider>
<AuthProvider> <AuthProvider>
<SessionProvider> <SessionProvider>
<ActiveSessionBanner /> <ActiveSessionBanner />
<MainContentWrapper> <MainContentWrapper>
<SyncHandler /> <SyncHandler />
<BackgroundRemovalHandler />
<PWARegistration /> <PWARegistration />
<UploadQueue /> <UploadQueue />
{children} {children}

View File

@@ -2,8 +2,8 @@ import { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: 'Whisky Vault', name: 'Dramlog.eu',
short_name: 'WhiskyVault', short_name: 'Dramlog',
description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.', description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.',
start_url: '/', start_url: '/',
display: 'standalone', display: 'standalone',

View File

@@ -1,30 +1,29 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import BottleGrid from "@/components/BottleGrid"; import BottleGrid from "@/components/BottleGrid";
import AuthForm from "@/components/AuthForm"; import AuthForm from "@/components/AuthForm";
import BuddyList from "@/components/BuddyList";
import SessionList from "@/components/SessionList";
import StatsDashboard from "@/components/StatsDashboard";
import DramOfTheDay from "@/components/DramOfTheDay";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import OfflineIndicator from "@/components/OfflineIndicator"; import OfflineIndicator from "@/components/OfflineIndicator";
import { useI18n } from "@/i18n/I18nContext"; import { useI18n } from "@/i18n/I18nContext";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { useSession } from "@/context/SessionContext"; import { useSession } from "@/context/SessionContext";
import TastingHub from "@/components/TastingHub"; import TastingHub from "@/components/TastingHub";
import { Sparkles, X, Loader2 } from "lucide-react"; import { Sparkles, Loader2, Search, SlidersHorizontal } from "lucide-react";
import { BottomNavigation } from '@/components/BottomNavigation'; import { BottomNavigation } from '@/components/BottomNavigation';
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow'; import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
import UserStatusBadge from '@/components/UserStatusBadge'; import UserStatusBadge from '@/components/UserStatusBadge';
import { getActiveSplits } from '@/services/split-actions'; import { getActiveSplits } from '@/services/split-actions';
import SplitCard from '@/components/SplitCard'; import SplitCard from '@/components/SplitCard';
import HeroBanner from '@/components/HeroBanner';
import QuickActionsGrid from '@/components/QuickActionsGrid';
import { checkIsAdmin } from '@/services/track-api-usage';
export default function Home() { export default function Home() {
const supabase = createClient(); const supabase = createClient();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [bottles, setBottles] = useState<any[]>([]); const [bottles, setBottles] = useState<any[]>([]);
const { user, isLoading: isAuthLoading } = useAuth(); const { user, isLoading: isAuthLoading } = useAuth();
const [isInternalLoading, setIsInternalLoading] = useState(false); const [isInternalLoading, setIsInternalLoading] = useState(false);
@@ -36,6 +35,7 @@ export default function Home() {
const [capturedFile, setCapturedFile] = useState<File | null>(null); const [capturedFile, setCapturedFile] = useState<File | null>(null);
const [hasMounted, setHasMounted] = useState(false); const [hasMounted, setHasMounted] = useState(false);
const [publicSplits, setPublicSplits] = useState<any[]>([]); const [publicSplits, setPublicSplits] = useState<any[]>([]);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => { useEffect(() => {
setHasMounted(true); setHasMounted(true);
@@ -47,7 +47,6 @@ export default function Home() {
}; };
useEffect(() => { useEffect(() => {
// Only fetch if auth is ready and user exists
if (!isAuthLoading && user) { if (!isAuthLoading && user) {
fetchCollection(); fetchCollection();
} else if (!isAuthLoading && !user) { } else if (!isAuthLoading && !user) {
@@ -56,14 +55,12 @@ export default function Home() {
}, [user, isAuthLoading]); }, [user, isAuthLoading]);
useEffect(() => { useEffect(() => {
// Fetch public splits if guest
getActiveSplits().then(res => { getActiveSplits().then(res => {
if (res.success && res.splits) { if (res.success && res.splits) {
setPublicSplits(res.splits); setPublicSplits(res.splits);
} }
}); });
// Listen for collection updates (e.g., after offline sync completes)
const handleCollectionUpdated = () => { const handleCollectionUpdated = () => {
console.log('[Home] Collection update event received, refreshing...'); console.log('[Home] Collection update event received, refreshing...');
fetchCollection(); fetchCollection();
@@ -78,7 +75,6 @@ export default function Home() {
const fetchCollection = async () => { const fetchCollection = async () => {
setIsInternalLoading(true); setIsInternalLoading(true);
try { try {
// Fetch bottles with their latest tasting date
const { data, error } = await supabase const { data, error } = await supabase
.from('bottles') .from('bottles')
.select(` .select(`
@@ -90,13 +86,10 @@ export default function Home() {
`) `)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (error) { if (error) throw error;
throw error;
}
console.log(`Fetched ${data?.length || 0} bottles from Supabase`); console.log(`Fetched ${data?.length || 0} bottles from Supabase`);
// Process data to get the absolute latest tasting date for each bottle
const processedBottles = (data || []).map(bottle => { const processedBottles = (data || []).map(bottle => {
const lastTasted = bottle.tastings && bottle.tastings.length > 0 const lastTasted = bottle.tastings && bottle.tastings.length > 0
? bottle.tastings.reduce((latest: string, current: any) => ? bottle.tastings.reduce((latest: string, current: any) =>
@@ -105,41 +98,18 @@ export default function Home() {
) )
: null; : null;
return { return { ...bottle, last_tasted: lastTasted };
...bottle,
last_tasted: lastTasted
};
}); });
setBottles(processedBottles); setBottles(processedBottles);
} catch (err: any) { } catch (err: any) {
// Enhanced logging for empty-looking error objects console.warn('[Home] Fetch collection error:', err?.message);
console.warn('[Home] Fetch collection error caught:', {
name: err?.name,
message: err?.message,
keys: err ? Object.keys(err) : [],
allProps: err ? Object.getOwnPropertyNames(err) : [],
stack: err?.stack,
online: navigator.onLine
});
// Silently skip if offline or common network failure
const isNetworkError = !navigator.onLine || const isNetworkError = !navigator.onLine ||
err?.name === 'TypeError' || err?.name === 'TypeError' ||
err?.message?.includes('Failed to fetch') || err?.message?.includes('Failed to fetch');
err?.message?.includes('NetworkError') ||
err?.message?.includes('ERR_INTERNET_DISCONNECTED') ||
(err && typeof err === 'object' && !err.message && Object.keys(err).length === 0);
if (isNetworkError) { if (!isNetworkError) {
console.log('[fetchCollection] Skipping due to offline mode or network error'); setFetchError(err?.message || 'Unknown error');
setFetchError(null);
} else {
console.error('Detailed fetch error:', err);
// Safe stringification for Error objects
const errorMessage = err?.message ||
(err && typeof err === 'object' ? JSON.stringify(err, Object.getOwnPropertyNames(err)) : String(err));
setFetchError(errorMessage);
} }
} finally { } finally {
setIsInternalLoading(false); setIsInternalLoading(false);
@@ -150,6 +120,17 @@ export default function Home() {
await supabase.auth.signOut(); await supabase.auth.signOut();
}; };
// Filter bottles by search query
const filteredBottles = bottles.filter(bottle => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
bottle.name?.toLowerCase().includes(query) ||
bottle.distillery?.toLowerCase().includes(query) ||
bottle.category?.toLowerCase().includes(query)
);
});
if (!hasMounted) { if (!hasMounted) {
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950"> <main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950">
@@ -158,6 +139,7 @@ export default function Home() {
); );
} }
// Guest / Login View
if (!user) { if (!user) {
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950"> <main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950">
@@ -168,13 +150,10 @@ export default function Home() {
<p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide"> <p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide">
{t('home.tagline')} {t('home.tagline')}
</p> </p>
<div className="mt-8">
<LanguageSwitcher />
</div>
</div> </div>
<AuthForm /> <AuthForm />
{!user && publicSplits.length > 0 && ( {publicSplits.length > 0 && (
<div className="mt-16 w-full max-w-lg space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-300"> <div className="mt-16 w-full max-w-lg space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-300">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<h2 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600/60"> <h2 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600/60">
@@ -199,16 +178,30 @@ export default function Home() {
const isLoading = isAuthLoading || isInternalLoading; const isLoading = isAuthLoading || isInternalLoading;
// Authenticated Home View - New Layout
return ( return (
<main className="flex min-h-screen flex-col items-center gap-6 md:gap-12 p-4 md:p-24 bg-[var(--background)] pb-32"> <div className="flex flex-col min-h-screen bg-(--background) relative">
<div className="z-10 max-w-5xl w-full flex flex-col items-center gap-12"> {/* Scrollable Content Area */}
<header className="w-full flex flex-col sm:flex-row justify-between items-center gap-4 sm:gap-0"> <div className="flex-1 overflow-y-auto pb-24">
<div className="flex flex-col items-center sm:items-start group"> {/* 1. Header */}
<h1 className="text-4xl font-bold text-zinc-50 tracking-tighter"> <header className="px-4 pt-4 pb-2 space-y-2">
{/* Row 1: Logo + Logout */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-zinc-50 tracking-tighter">
DRAM<span className="text-orange-600">LOG</span> DRAM<span className="text-orange-600">LOG</span>
</h1> </h1>
{activeSession && ( <button
<div className="flex items-center gap-2 mt-1 animate-in fade-in slide-in-from-left-2 duration-700"> onClick={handleLogout}
className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 hover:text-white transition-colors"
>
{t('home.logout')}
</button>
</div>
{/* Row 2: Session info + Status */}
<div className="flex items-center justify-between">
{activeSession ? (
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-700">
<div className="relative flex h-2 w-2"> <div className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span> <span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span>
@@ -218,42 +211,54 @@ export default function Home() {
Live: {activeSession.name} Live: {activeSession.name}
</span> </span>
</div> </div>
) : (
<div />
)} )}
</div> <div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-3 md:gap-4">
<UserStatusBadge /> <UserStatusBadge />
<OfflineIndicator /> <OfflineIndicator />
<LanguageSwitcher /> </div>
<DramOfTheDay bottles={bottles} />
<button
onClick={handleLogout}
className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 hover:text-white transition-colors"
>
{t('home.logout')}
</button>
</div> </div>
</header> </header>
<div className="w-full"> {/* 2. Hero Banner (optional) */}
<StatsDashboard bottles={bottles} /> <div className="px-4 mt-2 mb-4">
<HeroBanner />
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 w-full max-w-5xl">
<div className="flex flex-col gap-8"> {/* 3. Quick Actions Grid */}
<SessionList /> <div className="px-4 mb-4">
<QuickActionsGrid />
</div> </div>
<div>
<BuddyList /> {/* 4. Sticky Search Bar */}
<div className="sticky top-0 z-20 px-4 py-3 bg-zinc-950/95 backdrop-blur-md border-b border-zinc-900">
<div className="flex items-center gap-3">
<div className="flex-1 relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
placeholder={t('home.searchPlaceholder') || 'Search collection...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600/50 focus:ring-1 focus:ring-orange-600/20"
/>
</div>
<button className="p-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-500 hover:text-white hover:border-zinc-700 transition-colors">
<SlidersHorizontal size={18} />
</button>
</div> </div>
</div> </div>
<div className="w-full mt-4" id="collection"> {/* 5. Collection */}
<div className="flex items-end justify-between mb-8"> <div className="px-4 mt-4">
<h2 className="text-3xl font-bold text-zinc-50 uppercase tracking-tight"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-zinc-50">
{t('home.collection')} {t('home.collection')}
</h2> </h2>
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pb-1"> <span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
{bottles.length} {t('home.bottleCount')} {filteredBottles.length} {t('home.bottleCount')}
</span> </span>
</div> </div>
@@ -262,20 +267,23 @@ export default function Home() {
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-orange-600"></div> <div className="animate-spin rounded-full h-10 w-10 border-b-2 border-orange-600"></div>
</div> </div>
) : fetchError ? ( ) : fetchError ? (
<div className="p-12 bg-zinc-900 border border-zinc-800 rounded-3xl text-center"> <div className="p-8 bg-zinc-900 border border-zinc-800 rounded-2xl text-center">
<p className="text-zinc-50 font-bold text-xl mb-2">{t('common.error')}</p> <p className="text-zinc-50 font-bold mb-2">{t('common.error')}</p>
<p className="text-zinc-500 text-xs italic mb-8 mx-auto max-w-xs">{fetchError}</p> <p className="text-zinc-500 text-xs mb-6">{fetchError}</p>
<button <button
onClick={fetchCollection} onClick={fetchCollection}
className="px-10 py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl text-[10px] font-bold uppercase tracking-widest transition-all shadow-lg shadow-orange-950/20" className="px-6 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-xl text-xs font-bold uppercase tracking-widest transition-all"
> >
{t('home.reTry')} {t('home.reTry')}
</button> </button>
</div> </div>
) : ( ) : filteredBottles.length > 0 ? (
bottles.length > 0 && <BottleGrid bottles={bottles} /> <BottleGrid bottles={filteredBottles} />
)} ) : bottles.length > 0 ? (
<div className="text-center py-12 text-zinc-500">
<p className="text-sm">No bottles match your search</p>
</div> </div>
) : null}
</div> </div>
{/* Footer */} {/* Footer */}
@@ -288,7 +296,9 @@ export default function Home() {
<a href="/settings" className="hover:text-orange-500 transition-colors">{t('home.settings')}</a> <a href="/settings" className="hover:text-orange-500 transition-colors">{t('home.settings')}</a>
</div> </div>
</footer> </footer>
</div>
{/* Bottom Navigation with FAB */}
<BottomNavigation <BottomNavigation
onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })} onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })} onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })}
@@ -308,6 +318,6 @@ export default function Home() {
imageFile={capturedFile} imageFile={capturedFile}
onBottleSaved={() => fetchCollection()} onBottleSaved={() => fetchCollection()}
/> />
</main> </div>
); );
} }

View File

@@ -16,6 +16,7 @@ import SessionABVCurve from '@/components/SessionABVCurve';
import OfflineIndicator from '@/components/OfflineIndicator'; import OfflineIndicator from '@/components/OfflineIndicator';
import BulkScanSheet from '@/components/BulkScanSheet'; import BulkScanSheet from '@/components/BulkScanSheet';
import BottleSkeletonCard from '@/components/BottleSkeletonCard'; import BottleSkeletonCard from '@/components/BottleSkeletonCard';
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
interface Buddy { interface Buddy {
id: string; id: string;
@@ -34,12 +35,20 @@ interface Session {
name: string; name: string;
scheduled_at: string; scheduled_at: string;
ended_at?: string; ended_at?: string;
is_blind: boolean;
is_revealed: boolean;
user_id: string;
} }
interface SessionTasting { interface SessionTasting {
id: string; id: string;
rating: number; rating: number;
tasted_at: string; tasted_at: string;
blind_label?: string;
guess_abv?: number;
guess_age?: number;
guess_region?: string;
guess_points?: number;
bottles: { bottles: {
id: string; id: string;
name: string; name: string;
@@ -57,21 +66,36 @@ interface SessionTasting {
} }
export default function SessionDetailPage() { export default function SessionDetailPage() {
const { t } = useI18n(); const { t, locale } = useI18n();
const { id } = useParams(); const { id } = useParams();
const router = useRouter(); const router = useRouter();
const { activeSession, setActiveSession } = useSession();
const supabase = createClient(); const supabase = createClient();
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [participants, setParticipants] = useState<Participant[]>([]);
const [tastings, setTastings] = useState<SessionTasting[]>([]); const [tastings, setTastings] = useState<SessionTasting[]>([]);
const [participants, setParticipants] = useState<Participant[]>([]);
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]); const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const { user, isLoading: isAuthLoading } = useAuth(); const { user, isLoading: isAuthLoading } = useAuth();
const { activeSession, setActiveSession } = useSession();
const [isAddingParticipant, setIsAddingParticipant] = useState(false); const [isAddingParticipant, setIsAddingParticipant] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [isBulkScanOpen, setIsBulkScanOpen] = useState(false); const [isBulkScanOpen, setIsBulkScanOpen] = useState(false);
const [isUpdatingBlind, setIsUpdatingBlind] = useState(false);
// New: Direct Scan Flow
const [isScanFlowOpen, setIsScanFlowOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setIsScanFlowOpen(true);
}
};
useEffect(() => { useEffect(() => {
if (!isAuthLoading && user) { if (!isAuthLoading && user) {
@@ -131,6 +155,11 @@ export default function SessionDetailPage() {
id, id,
rating, rating,
tasted_at, tasted_at,
blind_label,
guess_abv,
guess_age,
guess_region,
guess_points,
bottles(id, name, distillery, image_url, abv, category, processing_status), bottles(id, name, distillery, image_url, abv, category, processing_status),
tasting_tags(tags(name)) tasting_tags(tags(name))
`) `)
@@ -183,21 +212,67 @@ export default function SessionDetailPage() {
const handleCloseSession = async () => { const handleCloseSession = async () => {
if (!confirm('Möchtest du diese Session wirklich abschließen?')) return; if (!confirm('Möchtest du diese Session wirklich abschließen?')) return;
setIsClosing(true); setIsClosing(true);
const result = await closeSession(id as string); const { success } = await closeSession(id as string);
if (success) {
if (result.success) {
if (activeSession?.id === id) { if (activeSession?.id === id) {
setActiveSession(null); setActiveSession(null);
} }
fetchSessionData(); fetchSessionData();
} else {
alert(result.error);
} }
setIsClosing(false); setIsClosing(false);
}; };
const handleToggleBlindMode = async () => {
if (!session) return;
setIsUpdatingBlind(true);
const { error } = await supabase
.from('tasting_sessions')
.update({ is_blind: !session.is_blind })
.eq('id', id);
if (!error) {
fetchSessionData();
}
setIsUpdatingBlind(false);
};
const handleRevealBlindMode = async () => {
if (!session) return;
if (!confirm('Möchtest du alle Flaschen aufdecken?')) return;
setIsUpdatingBlind(true);
const { error } = await supabase
.from('tasting_sessions')
.update({ is_revealed: true })
.eq('id', id);
if (!error) {
fetchSessionData();
}
setIsUpdatingBlind(false);
};
const calculateGuessPoints = (tasting: SessionTasting) => {
let points = 0;
// ABV Scoring (100 base - 10 per 1% dev)
if (tasting.guess_abv && tasting.bottles.abv) {
const abvDev = Math.abs(tasting.guess_abv - tasting.bottles.abv);
points += Math.max(0, 100 - (abvDev * 10));
}
// Age Scoring (100 base - 5 per year dev)
// Note: bottles table has 'age' as integer
const bottleAge = (tasting.bottles as any).age;
if (tasting.guess_age && bottleAge) {
const ageDev = Math.abs(tasting.guess_age - bottleAge);
points += Math.max(0, 100 - (ageDev * 5));
}
return Math.round(points);
};
const handleDeleteSession = async () => { const handleDeleteSession = async () => {
if (!confirm('Möchtest du diese Session wirklich löschen? Alle Verknüpfungen gehen verloren.')) return; if (!confirm('Möchtest du diese Session wirklich löschen? Alle Verknüpfungen gehen verloren.')) return;
@@ -233,95 +308,129 @@ export default function SessionDetailPage() {
} }
return ( return (
<main className="min-h-screen bg-zinc-950 p-4 md:p-12 lg:p-24"> <main className="min-h-screen bg-(--background) p-4 md:p-12 lg:p-24 pb-32">
<div className="max-w-4xl mx-auto space-y-8"> <div className="max-w-6xl mx-auto space-y-12">
{/* Back Button */} {/* Back Link & Info */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<Link <Link
href="/" href="/"
className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]" className="group inline-flex items-center gap-3 text-zinc-500 hover:text-orange-600 transition-all font-black uppercase text-[10px] tracking-[0.3em]"
> >
<div className="p-2 rounded-full border border-zinc-800 group-hover:border-orange-500/50 transition-colors">
<ChevronLeft size={16} /> <ChevronLeft size={16} />
</div>
Alle Sessions Alle Sessions
</Link> </Link>
<div className="flex items-center gap-4">
<OfflineIndicator /> <OfflineIndicator />
<input
type="file"
accept="image/*"
capture="environment"
className="hidden"
ref={fileInputRef}
onChange={handleFileSelect}
/>
</div>
</div> </div>
{/* Hero */} {/* Immersive Header */}
<header className="bg-zinc-900 rounded-3xl p-8 border border-zinc-800 shadow-xl relative overflow-hidden group"> <header className="relative bg-zinc-900 border border-white/5 rounded-[48px] p-8 md:p-12 shadow-[0_20px_80px_rgba(0,0,0,0.5)] overflow-hidden group">
{/* Visual Eyecatcher: Background Glow */} {/* Background Visuals */}
<div className="absolute inset-0 bg-linear-to-br from-zinc-900 via-zinc-900 to-black z-0" />
{tastings.length > 0 && tastings[0].bottles.image_url && ( {tastings.length > 0 && tastings[0].bottles.image_url && (
<div className="absolute top-0 right-0 w-1/2 h-full opacity-20 dark:opacity-30 pointer-events-none"> <div className="absolute top-0 right-0 w-2/3 h-full opacity-30 z-0">
<div <div
className="absolute inset-0 bg-cover bg-center scale-150 blur-3xl transition-all duration-1000 group-hover:scale-125" className="absolute inset-0 bg-cover bg-center scale-150 blur-[100px]"
style={{ backgroundImage: `url(${tastings[0].bottles.image_url})` }} style={{ backgroundImage: `url(${tastings[0].bottles.image_url})` }}
/> />
</div> </div>
)} )}
<div className="absolute top-0 right-0 p-8 opacity-5 text-zinc-400"> {/* Decorative Rings */}
<GlassWater size={120} /> <div className="absolute -top-24 -right-24 w-96 h-96 border border-orange-500/10 rounded-full z-0" />
</div> <div className="absolute -top-12 -right-12 w-96 h-96 border border-orange-500/5 rounded-full z-0" />
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start md:items-center gap-6"> <div className="relative z-10 flex flex-col lg:flex-row justify-between items-start lg:items-end gap-8">
<div className="flex-1 flex flex-col md:flex-row gap-6 items-start md:items-center"> <div className="space-y-6 flex-1">
{/* Visual Eyecatcher: Bottle Preview */} <div className="flex items-center gap-3">
{tastings.length > 0 && tastings[0].bottles.image_url && ( <div className="px-3 py-1 bg-orange-600/10 border border-orange-500/20 rounded-full flex items-center gap-2">
<div className="shrink-0 relative"> <Sparkles size={12} className="text-orange-500 animate-pulse" />
<div className="w-20 h-20 md:w-24 md:h-24 rounded-2xl bg-zinc-800 border-2 border-orange-500/20 shadow-2xl overflow-hidden relative group-hover:rotate-3 transition-transform duration-500"> <span className="text-[10px] font-black text-orange-500 uppercase tracking-[0.2em]">Tasting Session</span>
<img
src={tastings[0].bottles.image_url}
alt={tastings[0].bottles.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<div className="absolute -bottom-2 -right-2 bg-orange-600 text-white text-[10px] font-black px-2 py-1 rounded-lg shadow-lg rotate-12">
LATEST
</div>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-orange-600 font-black uppercase text-[10px] tracking-widest">
<Sparkles size={14} />
Tasting Session
</div> </div>
{session.ended_at && ( {session.ended_at && (
<span className="bg-zinc-100 dark:bg-zinc-800 text-zinc-500 text-[8px] font-black px-2 py-0.5 rounded-md uppercase tracking-widest border border-zinc-200 dark:border-zinc-700">Abgeschlossen</span> <span className="px-3 py-1 bg-zinc-800/50 border border-zinc-700/50 rounded-full text-[10px] font-black text-zinc-500 uppercase tracking-[0.2em]">Archiviert</span>
)}
{session.is_blind && (
<span className="px-3 py-1 bg-purple-600/10 border border-purple-500/20 rounded-full flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-purple-500 rounded-full animate-pulse" />
<span className="text-[10px] font-black text-purple-500 uppercase tracking-[0.2em]">Blind Modus</span>
</span>
)}
{session.is_blind && session.is_revealed && (
<span className="px-3 py-1 bg-green-600/10 border border-green-500/20 rounded-full flex items-center gap-2">
<Sparkles size={10} className="text-green-500" />
<span className="text-[10px] font-black text-green-500 uppercase tracking-[0.2em]">Revealed</span>
</span>
)} )}
</div> </div>
<h1 className="text-4xl md:text-5xl font-black text-zinc-50 tracking-tighter">
<h1 className="text-5xl md:text-7xl font-black text-white tracking-tighter leading-[0.9]">
{session.name} {session.name}
</h1> </h1>
<div className="flex flex-wrap items-center gap-3 sm:gap-6 text-zinc-500 font-bold text-sm">
<span className="flex items-center gap-1.5 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm"> <div className="flex flex-wrap items-center gap-4 text-zinc-400">
<div className="flex items-center gap-2 bg-black/30 backdrop-blur-md px-4 py-2 rounded-2xl border border-white/5 shadow-inner">
<Calendar size={16} className="text-orange-600" /> <Calendar size={16} className="text-orange-600" />
{new Date(session.scheduled_at).toLocaleDateString('de-DE')} <span className="text-xs font-black uppercase tracking-widest">{new Date(session.scheduled_at).toLocaleDateString('de-DE')}</span>
</span> </div>
{participants.length > 0 && ( {participants.length > 0 && (
<div className="flex items-center gap-2 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm"> <div className="flex items-center gap-3 bg-black/30 backdrop-blur-md px-4 py-2 rounded-2xl border border-white/5">
<Users size={16} className="text-orange-600" /> <Users size={16} className="text-orange-600" />
<AvatarStack names={participants.map(p => p.buddies.name)} limit={5} /> <AvatarStack names={participants.map(p => p.buddies.name)} limit={5} />
</div> </div>
)} )}
{tastings.length > 0 && ( <div className="flex items-center gap-2 bg-black/30 backdrop-blur-md px-4 py-2 rounded-2xl border border-white/5">
<span className="flex items-center gap-1.5 bg-zinc-800/40 px-3 py-1.5 rounded-2xl border border-zinc-800 shadow-sm transition-all animate-in fade-in slide-in-from-left-2">
<GlassWater size={16} className="text-orange-600" /> <GlassWater size={16} className="text-orange-600" />
{tastings.length} {tastings.length === 1 ? 'Whisky' : 'Whiskys'} <span className="text-xs font-black tracking-widest">{tastings.length} {tastings.length === 1 ? 'DRAM' : 'DRAMS'}</span>
</span>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-wrap gap-3 z-20">
{/* Host Controls for Blind Mode */}
{user?.id === session.user_id && !session.ended_at && (
<>
<button
onClick={handleToggleBlindMode}
disabled={isUpdatingBlind}
className={`px-6 py-4 rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all border ${session.is_blind
? 'bg-purple-600/20 border-purple-500/50 text-purple-400'
: 'bg-zinc-800/50 border-zinc-700/50 text-zinc-400 hover:border-zinc-500'
}`}
>
{isUpdatingBlind ? <Loader2 size={16} className="animate-spin" /> : <Play size={14} className={session.is_blind ? "fill-purple-400" : ""} />}
Blind Mode
</button>
{session.is_blind && !session.is_revealed && (
<button
onClick={handleRevealBlindMode}
disabled={isUpdatingBlind}
className="px-6 py-4 bg-green-600 hover:bg-green-500 text-white rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all shadow-lg shadow-green-950/20"
>
<Sparkles size={16} />
Reveal
</button>
)}
</>
)}
{!session.ended_at && ( {!session.ended_at && (
activeSession?.id !== session.id ? ( activeSession?.id !== session.id ? (
<button <button
onClick={() => setActiveSession({ id: session.id, name: session.name })} onClick={() => setActiveSession({ id: session.id, name: session.name })}
className="px-6 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-xl shadow-orange-950/20" className="px-8 py-4 bg-orange-600 hover:bg-orange-500 text-white rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all shadow-[0_10px_40px_rgba(234,88,12,0.3)] hover:-translate-y-1 active:translate-y-0"
> >
<Play size={18} fill="currentColor" /> <Play size={18} fill="currentColor" />
Starten Starten
@@ -330,74 +439,87 @@ export default function SessionDetailPage() {
<button <button
onClick={handleCloseSession} onClick={handleCloseSession}
disabled={isClosing} disabled={isClosing}
className="px-6 py-3 bg-zinc-100 text-zinc-900 rounded-2xl text-sm font-black uppercase tracking-widest flex items-center gap-2 border border-zinc-800 hover:bg-red-600 hover:text-white transition-all group" className="px-8 py-4 bg-zinc-800/50 backdrop-blur-xl border border-zinc-700/50 text-zinc-100 hover:bg-red-600 hover:border-red-500 rounded-2xl text-[10px] font-black uppercase tracking-[0.2em] flex items-center gap-3 transition-all hover:shadow-[0_10px_40px_rgba(220,38,38,0.2)]"
> >
{isClosing ? <Loader2 size={18} className="animate-spin" /> : <Square size={18} className="text-red-500 group-hover:text-white transition-colors" fill="currentColor" />} {isClosing ? <Loader2 size={18} className="animate-spin" /> : <Square size={16} className="text-red-500 group-hover:text-white" fill="currentColor" />}
Beenden Session Beenden
</button> </button>
) )
)} )}
<button <button
onClick={handleDeleteSession} onClick={handleDeleteSession}
disabled={isDeleting} disabled={isDeleting}
title="Session löschen" className="p-4 bg-zinc-950 border border-white/5 text-zinc-600 hover:text-red-500 rounded-2xl transition-all"
className="p-3 bg-red-900/10 text-red-400 rounded-2xl hover:bg-red-600 hover:text-white transition-all border border-red-900/20 disabled:opacity-50"
> >
{isDeleting ? <Loader2 size={20} className="animate-spin" /> : <Trash2 size={20} />} {isDeleting ? <Loader2 size={18} className="animate-spin" /> : <Trash2 size={18} />}
</button> </button>
</div> </div>
</div> </div>
</header> </header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> {/* Blind Mode Reveal Leaderboard */}
{/* Sidebar: Participants */} {session.is_blind && session.is_revealed && (
<aside className="md:col-span-1 space-y-6"> <section className="bg-purple-900/10 rounded-[40px] p-8 md:p-12 border border-purple-500/30 shadow-2xl relative overflow-hidden">
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 shadow-lg"> <div className="absolute top-0 right-0 p-12 opacity-5">
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-500 mb-6 flex items-center gap-2"> <Sparkles size={120} className="text-purple-500" />
<Users size={16} className="text-orange-600" />
Teilnehmer
</h3>
<div className="space-y-3 mb-6">
{participants.length === 0 ? (
<p className="text-xs text-zinc-500 italic">Noch keine Teilnehmer...</p>
) : (
participants.map((p) => (
<div key={p.buddy_id} className="flex items-center justify-between group">
<span className="text-sm font-bold text-zinc-300">{p.buddies.name}</span>
<button
onClick={() => handleRemoveParticipant(p.buddy_id)}
className="text-zinc-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 size={14} />
</button>
</div> </div>
))
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-12 relative">
<div className="space-y-1">
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-purple-400">Leaderboard</h3>
<p className="text-3xl font-black text-white tracking-tight leading-none italic">Die Goldene Nase</p>
</div>
<div className="px-4 py-2 bg-purple-600/20 border border-purple-500/30 rounded-2xl text-[10px] font-black text-purple-400 uppercase tracking-widest">
Mystery Revealed
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 relative">
{tastings.map((t, idx) => {
const score = calculateGuessPoints(t);
return (
<div key={t.id} className="bg-black/40 border border-white/5 rounded-[32px] p-6 group hover:border-purple-500/30 transition-all">
<div className="flex justify-between items-start mb-6">
<div className="w-10 h-10 bg-purple-600/20 border border-purple-500/20 rounded-full flex items-center justify-center text-xs font-black text-purple-400">
{String.fromCharCode(65 + idx)}
</div>
<div className="text-right">
<div className="text-2xl font-black text-white">{score}</div>
<div className="text-[9px] font-black text-purple-400 uppercase tracking-tighter">Punkte</div>
</div>
</div>
<div className="space-y-4">
<div className="text-[11px] font-black text-zinc-300 uppercase truncate group-hover:text-white transition-colors">
{t.bottles.name}
</div>
<div className="grid grid-cols-2 gap-2 pt-4 border-t border-white/5">
<div>
<div className="text-[8px] font-black text-zinc-500 uppercase tracking-widest mb-1">Guess</div>
<div className="text-[10px] font-bold text-purple-400">
{t.guess_abv ? `${t.guess_abv}%` : '-'} / {t.guess_age ? `${t.guess_age}y` : '-'}
</div>
</div>
<div className="text-right">
<div className="text-[8px] font-black text-zinc-500 uppercase tracking-widest mb-1">Reality</div>
<div className="text-[10px] font-bold text-white">
{t.bottles.abv}% / {(t.bottles as any).age || '?'}y
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</section>
)} )}
</div>
<div className="border-t border-zinc-800 pt-6"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-500 block mb-3">Buddy hinzufügen</label> {/* Left Rail: Stats & Team */}
<select <div className="lg:col-span-4 space-y-8 lg:sticky lg:top-12">
onChange={(e) => { {/* ABV Analysis */}
if (e.target.value) handleAddParticipant(e.target.value);
e.target.value = "";
}}
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-3 py-2 text-xs font-bold text-zinc-300 outline-none focus:ring-2 focus:ring-orange-500/50"
>
<option value="">Auswählen...</option>
{allBuddies
.filter(b => !participants.some(p => p.buddy_id === b.id))
.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))
}
</select>
</div>
</div>
{/* ABV Curve */}
{tastings.length > 0 && ( {tastings.length > 0 && (
<SessionABVCurve <SessionABVCurve
tastings={tastings.map(t => ({ tastings={tastings.map(t => ({
@@ -407,33 +529,87 @@ export default function SessionDetailPage() {
}))} }))}
/> />
)} )}
</aside>
{/* Main Content: Bottle List */} {/* Team */}
<section className="md:col-span-2 space-y-6"> <div className="bg-zinc-900 rounded-[32px] p-8 border border-white/5 shadow-2xl">
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 shadow-lg"> <h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-zinc-500 mb-8 flex items-center justify-between">
<div className="flex justify-between items-center mb-8"> <span className="flex items-center gap-2">
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-500 flex items-center gap-2"> <Users size={14} className="text-orange-600" />
<GlassWater size={16} className="text-orange-600" /> Crew
Verkostete Flaschen </span>
<span className="opacity-50">{participants.length}</span>
</h3> </h3>
<div className="flex gap-2">
<div className="space-y-4 mb-8">
{participants.length === 0 ? (
<p className="text-[10px] text-zinc-600 font-bold uppercase italic tracking-wider">Noch keiner an Bord...</p>
) : (
participants.map((p) => (
<div key={p.buddy_id} className="group flex items-center justify-between p-3 rounded-2xl hover:bg-white/5 transition-colors border border-transparent hover:border-white/5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-zinc-800 flex items-center justify-center text-[10px] font-black text-orange-500 border border-white/5 uppercase">
{p.buddies.name[0]}
</div>
<span className="text-sm font-black text-zinc-200">{p.buddies.name}</span>
</div>
<button
onClick={() => handleRemoveParticipant(p.buddy_id)}
className="p-2 text-zinc-700 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
<div className="pt-8 border-t border-white/5">
<p className="text-[8px] font-black uppercase tracking-widest text-zinc-600 mb-4 ml-1">Buddy hinzufügen</p>
<select
onChange={(e) => {
if (e.target.value) handleAddParticipant(e.target.value);
e.target.value = "";
}}
className="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-4 py-3 text-[10px] font-black uppercase tracking-wider text-zinc-400 outline-hidden focus:border-orange-500/50 transition-colors appearance-none"
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'%23a1a1aa\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\'/%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 1rem center', backgroundSize: '1rem' }}
>
<option value="">Auswahl...</option>
{allBuddies
.filter(b => !participants.some(p => p.buddy_id === b.id))
.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))
}
</select>
</div>
</div>
</div>
{/* Main Feed: Timeline */}
<div className="lg:col-span-8 space-y-8">
<section className="bg-zinc-900 rounded-[40px] p-8 md:p-12 border border-white/5 shadow-2xl min-h-screen">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-12">
<div className="space-y-1">
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600">Timeline</h3>
<p className="text-2xl font-black text-white tracking-tight">Verkostungs-Historie</p>
</div>
<div className="flex gap-2 w-full md:w-auto">
{!session.ended_at && ( {!session.ended_at && (
<button <button
onClick={() => setIsBulkScanOpen(true)} onClick={() => setIsBulkScanOpen(true)}
className="bg-zinc-800 hover:bg-zinc-700 text-orange-500 px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all border border-zinc-700" className="flex-1 md:flex-none bg-zinc-950 border border-white/5 hover:border-orange-500/30 text-zinc-400 hover:text-orange-500 px-5 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all group"
> >
<Zap size={16} /> <Zap size={14} className="group-hover:animate-pulse" />
Bulk Scan Bulk Scan
</button> </button>
)} )}
<Link <button
href={`/?session_id=${id}`} onClick={() => fileInputRef.current?.click()}
className="bg-orange-600 hover:bg-orange-700 text-white px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-orange-600/20" className="flex-1 md:flex-none bg-orange-600 hover:bg-orange-500 text-white px-6 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest flex items-center justify-center gap-2 transition-all shadow-xl shadow-orange-950/20"
> >
<Plus size={16} /> <Plus size={16} />
Flasche Flasche
</Link> </button>
</div> </div>
</div> </div>
@@ -447,24 +623,38 @@ export default function SessionDetailPage() {
tags: t.tasting_tags?.map((tg: any) => tg.tags.name) || [], tags: t.tasting_tags?.map((tg: any) => tg.tags.name) || [],
category: t.bottles.category category: t.bottles.category
}))} }))}
sessionStart={session.scheduled_at} // Fallback to scheduled time if no started_at sessionStart={session.scheduled_at}
isBlind={session.is_blind}
isRevealed={session.is_revealed}
/> />
</div>
</section> </section>
</div> </div>
</div> </div>
</div>
{/* Bulk Scan Sheet */}
<BulkScanSheet <BulkScanSheet
isOpen={isBulkScanOpen} isOpen={isBulkScanOpen}
onClose={() => setIsBulkScanOpen(false)} onClose={() => setIsBulkScanOpen(false)}
sessionId={id as string} sessionId={id as string}
sessionName={session.name} sessionName={session.name}
onSuccess={(bottleIds) => { onSuccess={() => {
setIsBulkScanOpen(false); setIsBulkScanOpen(false);
fetchSessionData(); fetchSessionData();
}} }}
/> />
<ScanAndTasteFlow
isOpen={isScanFlowOpen}
onClose={() => {
setIsScanFlowOpen(false);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
imageFile={selectedFile}
onBottleSaved={() => {
fetchSessionData();
}}
/>
</main> </main>
); );
} }

288
src/app/sessions/page.tsx Normal file
View File

@@ -0,0 +1,288 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Calendar, Plus, GlassWater, Loader2, ChevronRight, Users, Sparkles, Clock, Trash2 } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import Link from 'next/link';
import { useSession } from '@/context/SessionContext';
import { useAuth } from '@/context/AuthContext';
import { deleteSession } from '@/services/delete-session';
import AvatarStack from '@/components/AvatarStack';
interface Session {
id: string;
name: string;
scheduled_at: string;
ended_at?: string;
participant_count?: number;
whisky_count?: number;
participants?: string[];
}
export default function SessionsPage() {
const router = useRouter();
const { t, locale } = useI18n();
const supabase = createClient();
const { activeSession, setActiveSession } = useSession();
const { user, isLoading: isAuthLoading } = useAuth();
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
useEffect(() => {
if (!isAuthLoading && user) {
fetchSessions();
}
}, [user, isAuthLoading]);
const fetchSessions = async () => {
setIsLoading(true);
const { data, error } = await supabase
.from('tasting_sessions')
.select(`
*,
session_participants (buddies(name)),
tastings (count)
`)
.order('scheduled_at', { ascending: false });
if (error) {
console.error('Error fetching sessions:', error);
// Fallback without tastings join
const { data: fallbackData } = await supabase
.from('tasting_sessions')
.select(`*, session_participants (buddies(name))`)
.order('scheduled_at', { ascending: false });
if (fallbackData) {
setSessions(fallbackData.map(s => {
const participants = (s.session_participants as any[])?.filter(p => p.buddies).map(p => p.buddies.name) || [];
return { ...s, participant_count: participants.length, participants, whisky_count: 0 };
}));
}
} else {
setSessions(data?.map(s => {
const participants = (s.session_participants as any[])?.filter(p => p.buddies).map(p => p.buddies.name) || [];
return {
...s,
participant_count: participants.length,
participants,
whisky_count: (s.tastings as any[])?.[0]?.count || 0
};
}) || []);
}
setIsLoading(false);
};
const handleCreateSession = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
setIsCreating(true);
const { data, error } = await supabase
.from('tasting_sessions')
.insert({ name: newName.trim(), scheduled_at: new Date().toISOString() })
.select()
.single();
if (!error && data) {
setSessions(prev => [{ ...data, participant_count: 0, whisky_count: 0 }, ...prev]);
setNewName('');
setShowCreateForm(false);
setActiveSession({ id: data.id, name: data.name });
}
setIsCreating(false);
};
const handleDeleteSession = async (id: string) => {
setIsDeleting(id);
const result = await deleteSession(id);
if (result.success) {
setSessions(prev => prev.filter(s => s.id !== id));
if (activeSession?.id === id) {
setActiveSession(null);
}
}
setIsDeleting(null);
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
};
const activeSessions = sessions.filter(s => !s.ended_at);
const pastSessions = sessions.filter(s => s.ended_at);
return (
<main className="min-h-screen bg-zinc-950 pb-24">
{/* Header */}
<div className="sticky top-0 z-20 bg-zinc-950/95 backdrop-blur-md border-b border-zinc-900">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<button
onClick={() => router.back()}
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</button>
<div className="flex-1">
<h1 className="text-xl font-bold text-white">
{t('session.title')}
</h1>
<p className="text-xs text-zinc-500">
{sessions.length} Sessions
</p>
</div>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="p-2.5 bg-orange-600 hover:bg-orange-700 rounded-xl text-white transition-colors"
>
<Plus size={20} />
</button>
</div>
</div>
<div className="max-w-2xl mx-auto px-4 pt-6">
{/* Create Form */}
{showCreateForm && (
<form onSubmit={handleCreateSession} className="mb-6 p-4 bg-zinc-900 rounded-2xl border border-zinc-800 animate-in fade-in slide-in-from-top-2">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('session.sessionName')}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600 mb-3"
autoFocus
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowCreateForm(false)}
className="flex-1 py-2 bg-zinc-800 text-zinc-400 rounded-xl text-sm font-bold"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={isCreating || !newName.trim()}
className="flex-1 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-xl text-sm font-bold disabled:opacity-50 flex items-center justify-center gap-2"
>
{isCreating ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
Start Session
</button>
</div>
</form>
)}
{/* Active Session Banner */}
{activeSession && (
<Link href={`/sessions/${activeSession.id}`}>
<div className="mb-6 p-4 bg-linear-to-r from-orange-600/20 to-orange-500/10 rounded-2xl border border-orange-600/30 flex items-center gap-4 group hover:border-orange-500/50 transition-colors">
<div className="relative">
<div className="w-12 h-12 rounded-xl bg-orange-600/20 flex items-center justify-center">
<Sparkles size={24} className="text-orange-500" />
</div>
<span className="absolute -top-1 -right-1 w-3 h-3 bg-orange-500 rounded-full animate-pulse" />
</div>
<div className="flex-1">
<p className="text-[10px] font-bold uppercase tracking-widest text-orange-500/80">
Live Session
</p>
<p className="text-lg font-bold text-white">
{activeSession.name}
</p>
</div>
<ChevronRight size={20} className="text-orange-500 group-hover:translate-x-1 transition-transform" />
</div>
</Link>
)}
{/* Sessions List */}
{isLoading ? (
<div className="flex justify-center py-20">
<Loader2 size={32} className="animate-spin text-orange-600" />
</div>
) : sessions.length === 0 ? (
<div className="text-center py-20">
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
<Calendar size={32} className="text-zinc-600" />
</div>
<p className="text-lg font-bold text-white mb-2">No Sessions Yet</p>
<p className="text-sm text-zinc-500 max-w-xs mx-auto">
Start your first tasting session to track what you're drinking.
</p>
</div>
) : (
<div className="space-y-3">
{sessions.map(session => (
<div
key={session.id}
className={`p-4 bg-zinc-900 rounded-2xl border transition-all group ${activeSession?.id === session.id
? 'border-orange-600/50'
: 'border-zinc-800 hover:border-zinc-700'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${session.ended_at
? 'bg-zinc-800 text-zinc-500'
: 'bg-orange-600/20 text-orange-500'
}`}>
<GlassWater size={24} />
</div>
<div className="flex-1 min-w-0">
<Link href={`/sessions/${session.id}`}>
<h3 className="font-bold text-white truncate hover:text-orange-500 transition-colors">
{session.name}
</h3>
</Link>
<div className="flex items-center gap-3 text-xs text-zinc-500 mt-0.5">
<span className="flex items-center gap-1">
<Clock size={12} />
{formatDate(session.scheduled_at)}
</span>
{session.whisky_count ? (
<span>{session.whisky_count} Whiskys</span>
) : null}
</div>
</div>
{session.participants && session.participants.length > 0 && (
<AvatarStack names={session.participants} limit={3} size="sm" />
)}
<div className="flex items-center gap-1">
<button
onClick={() => handleDeleteSession(session.id)}
disabled={isDeleting === session.id}
className="p-2 text-zinc-600 hover:text-red-500 hover:bg-zinc-800 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
>
{isDeleting === session.id ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Trash2 size={16} />
)}
</button>
<Link href={`/sessions/${session.id}`}>
<button className="p-2 text-zinc-500 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors">
<ChevronRight size={18} />
</button>
</Link>
</div>
</div>
</div>
))}
</div>
)}
</div>
</main>
);
}

View File

@@ -135,7 +135,7 @@ export default function SplitPublicPage() {
alt={split.bottle.name} alt={split.bottle.name}
className="w-full h-full object-cover opacity-80" className="w-full h-full object-cover opacity-80"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-transparent" /> <div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-transparent" />
</div> </div>
)} )}

View File

@@ -158,7 +158,7 @@ export default function SplitManagePage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-bold text-white truncate">{split.bottleName}</p> <p className="font-bold text-white truncate">{split.bottleName}</p>
{!split.isActive && ( {!split.isActive && (
<span className="px-2 py-0.5 bg-zinc-800 rounded text-[10px] font-bold text-zinc-500"> <span className="px-2 py-0.5 bg-zinc-800 rounded-sm text-[10px] font-bold text-zinc-500">
Geschlossen Geschlossen
</span> </span>
)} )}

84
src/app/stats/page.tsx Normal file
View File

@@ -0,0 +1,84 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import AnalyticsDashboard from '@/components/AnalyticsDashboard';
import { useI18n } from '@/i18n/I18nContext';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { ChartSkeleton, StatsCardSkeleton } from '@/components/Skeletons';
export default function StatsPage() {
const router = useRouter();
const { t } = useI18n();
const [bottles, setBottles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const supabase = createClient();
useEffect(() => {
const fetchBottles = async () => {
setIsLoading(true);
const { data } = await supabase
.from('bottles')
.select(`
id,
name,
distillery,
purchase_price,
category,
abv,
age,
status,
tastings (rating)
`);
setBottles(data || []);
setIsLoading(false);
};
fetchBottles();
}, []);
return (
<main className="min-h-screen bg-zinc-950 p-4 pb-24">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => router.back()}
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</button>
<div>
<h1 className="text-2xl font-bold text-white">
{t('home.stats.title')}
</h1>
<p className="text-sm text-zinc-500">
Deep dive into your collection
</p>
</div>
</div>
{/* Dashboard */}
{isLoading ? (
<div className="space-y-6">
{/* KPI Cards Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatsCardSkeleton />
<StatsCardSkeleton />
<StatsCardSkeleton />
<StatsCardSkeleton />
</div>
{/* Charts Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<ChartSkeleton height={300} />
<ChartSkeleton height={300} />
</div>
<ChartSkeleton height={400} />
</div>
) : (
<AnalyticsDashboard bottles={bottles} />
)}
</div>
</main>
);
}

47
src/app/wishlist/page.tsx Normal file
View File

@@ -0,0 +1,47 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Heart, Plus } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
export default function WishlistPage() {
const router = useRouter();
const { t } = useI18n();
return (
<main className="min-h-screen bg-zinc-950 p-4 pb-24">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => router.back()}
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</button>
<div>
<h1 className="text-2xl font-bold text-white">
{t('nav.wishlist')}
</h1>
<p className="text-sm text-zinc-500">
Bottles you want to try
</p>
</div>
</div>
{/* Empty State */}
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="p-4 bg-zinc-900 rounded-full mb-4">
<Heart size={32} className="text-zinc-600" />
</div>
<h2 className="text-lg font-bold text-white mb-2">
Coming Soon
</h2>
<p className="text-sm text-zinc-500 max-w-xs">
Your wishlist will appear here. You'll be able to save bottles you want to try in the future.
</p>
</div>
</div>
</main>
);
}

View File

@@ -6,43 +6,62 @@ import { GlassWater, Square, ArrowRight, Sparkles } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { motion, AnimatePresence } from 'framer-motion';
export default function ActiveSessionBanner() { export default function ActiveSessionBanner() {
const { activeSession, setActiveSession } = useSession(); const { activeSession, setActiveSession } = useSession();
const { t } = useI18n(); const { t } = useI18n();
if (!activeSession) return null;
return ( return (
<div className="fixed top-0 left-0 right-0 z-[100] animate-in slide-in-from-top duration-500"> <AnimatePresence>
<div className="bg-orange-600 text-white px-4 py-2 flex items-center justify-between shadow-lg"> {activeSession && (
<motion.div
initial={{ y: 50, opacity: 0, x: '-50%' }}
animate={{ y: 0, opacity: 1, x: '-50%' }}
exit={{ y: 50, opacity: 0, x: '-50%' }}
className="fixed bottom-32 left-1/2 z-50 w-[calc(100%-2rem)] max-w-sm"
>
<div className="bg-zinc-900/90 backdrop-blur-2xl border border-orange-500/20 rounded-[32px] p-2 flex items-center justify-between shadow-2xl ring-1 ring-white/5 overflow-hidden">
{/* Session Info Link */}
<Link <Link
href={`/sessions/${activeSession.id}`} href={`/sessions/${activeSession.id}`}
className="flex items-center gap-3 flex-1 min-w-0" className="flex items-center gap-3 px-3 py-2 flex-1 min-w-0 hover:bg-white/5 rounded-2xl transition-colors"
> >
<div className="relative shrink-0"> <div className="relative shrink-0">
<div className="bg-white/20 p-1.5 rounded-lg"> <div className="bg-orange-600/10 p-2.5 rounded-2xl border border-orange-500/20">
<Sparkles size={16} className="text-white animate-pulse" /> <Sparkles size={16} className="text-orange-500" />
</div> </div>
<div className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-orange-600 animate-ping" /> <div className="absolute -top-1 -right-1 w-3 h-3 bg-orange-600 rounded-full border-2 border-zinc-900 animate-pulse shadow-[0_0_8px_rgba(234,88,12,0.6)]" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<span className="text-[9px] font-black uppercase tracking-widest bg-white/20 px-1.5 py-0.5 rounded leading-none text-white whitespace-nowrap">Live Jetzt</span> <span className="text-[8px] font-black uppercase tracking-widest text-orange-600 animate-pulse">Live</span>
<p className="text-[10px] font-black uppercase tracking-wider opacity-90 leading-none truncate">{t('session.activeSession')}</p> <p className="text-[9px] font-bold uppercase tracking-wider text-zinc-500 truncate leading-none">{t('session.activeSession')}</p>
</div> </div>
<p className="text-sm font-bold truncate leading-none">{activeSession.name}</p> <p className="text-sm font-bold text-zinc-100 truncate leading-none tracking-tight">{activeSession.name}</p>
</div> </div>
<ArrowRight size={14} className="opacity-50 ml-1 shrink-0" />
</Link> </Link>
{/* Action Buttons */}
<div className="flex items-center gap-1 pr-1">
<Link
href={`/sessions/${activeSession.id}`}
className="p-3 text-zinc-400 hover:text-orange-500 transition-colors"
>
<ArrowRight size={18} />
</Link>
<div className="w-px h-8 bg-zinc-800 mx-1" />
<button <button
onClick={() => setActiveSession(null)} onClick={() => setActiveSession(null)}
className="ml-4 p-2 hover:bg-white/10 rounded-full transition-colors" className="p-3 text-zinc-600 hover:text-red-500 transition-colors"
title="End Session" title="End Session"
> >
<Square size={20} fill="currentColor" /> <Square size={16} fill="currentColor" className="opacity-40" />
</button> </button>
</div> </div>
</div> </div>
</motion.div>
)}
</AnimatePresence>
); );
} }

View File

@@ -0,0 +1,406 @@
'use client';
import React, { useMemo } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, ScatterChart, Scatter, ZAxis, Legend, AreaChart, Area
} from 'recharts';
import { TrendingUp, CreditCard, Star, Home, BarChart3, Droplets, Clock, Activity, DollarSign } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface Bottle {
id: string;
name: string;
distillery?: string;
purchase_price?: number | null;
rating?: number | null; // Calculated avg rating
category?: string;
abv?: number;
age?: number;
status?: string | null;
tastings?: { rating: number }[];
}
interface AnalyticsDashboardProps {
bottles: Bottle[];
}
// Custom Colors
const COLORS = [
'#f97316', // Orange 500
'#a855f7', // Purple 500
'#3b82f6', // Blue 500
'#10b981', // Emerald 500
'#ef4444', // Red 500
'#eab308', // Yellow 500
'#ec4899', // Pink 500
'#6366f1', // Indigo 500
];
export default function AnalyticsDashboard({ bottles }: AnalyticsDashboardProps) {
const { t, locale } = useI18n();
// --- Process Data ---
const stats = useMemo(() => {
const enrichedBottles = bottles.map(b => {
const ratings = b.tastings?.map(t => t.rating) || [];
const avgRating = ratings.length > 0
? ratings.reduce((a, b) => a + b, 0) / ratings.length
: null;
return { ...b, rating: avgRating };
});
// 1. High Level Metrics
const totalValue = enrichedBottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0);
const bottlesWithRating = enrichedBottles.filter(b => b.rating !== null);
const avgCollectionRating = bottlesWithRating.length > 0
? bottlesWithRating.reduce((sum, b) => sum + (b.rating || 0), 0) / bottlesWithRating.length
: 0;
// 2. Category Distribution
const catMap = new Map<string, number>();
enrichedBottles.forEach(b => {
const cat = b.category || 'Unknown';
catMap.set(cat, (catMap.get(cat) || 0) + 1);
});
const categoryData = Array.from(catMap.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
// 3. Status Distribution
const statusMap = new Map<string, number>();
enrichedBottles.forEach(b => {
const s = b.status || 'sealed';
statusMap.set(s, (statusMap.get(s) || 0) + 1);
});
const statusData = Array.from(statusMap.entries())
.map(([name, value]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), value }));
// 4. Distillery Top 10
const distMap = new Map<string, number>();
enrichedBottles.forEach(b => {
if (b.distillery) {
distMap.set(b.distillery, (distMap.get(b.distillery) || 0) + 1);
}
});
const distilleryData = Array.from(distMap.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 10);
// 5. ABV Buckets
const abvBuckets = {
'< 40%': 0,
'40-43%': 0,
'43-46%': 0,
'46-50%': 0,
'50-55%': 0,
'55-60%': 0,
'> 60%': 0,
};
enrichedBottles.forEach(b => {
if (!b.abv) return;
if (b.abv < 40) abvBuckets['< 40%']++;
else if (b.abv <= 43) abvBuckets['40-43%']++;
else if (b.abv <= 46) abvBuckets['43-46%']++;
else if (b.abv <= 50) abvBuckets['46-50%']++;
else if (b.abv <= 55) abvBuckets['50-55%']++;
else if (b.abv <= 60) abvBuckets['55-60%']++;
else abvBuckets['> 60%']++;
});
const abvData = Object.entries(abvBuckets).map(([name, value]) => ({ name, value }));
// 6. Age Buckets
const ageBuckets = {
'NAS': 0,
'< 10y': 0,
'10-12y': 0,
'13-18y': 0,
'19-25y': 0,
'> 25y': 0
};
enrichedBottles.forEach(b => {
if (!b.age) {
ageBuckets['NAS']++;
return;
}
if (b.age < 10) ageBuckets['< 10y']++;
else if (b.age <= 12) ageBuckets['10-12y']++;
else if (b.age <= 18) ageBuckets['13-18y']++;
else if (b.age <= 25) ageBuckets['19-25y']++;
else ageBuckets['> 25y']++;
});
const ageData = Object.entries(ageBuckets).map(([name, value]) => ({ name, value }));
// 7. Price vs Quality
const scatterData = enrichedBottles
.filter(b => b.purchase_price && b.rating)
.map(b => ({
x: b.purchase_price,
y: b.rating,
name: b.name,
z: 1 // size
}));
return {
totalValue,
avgCollectionRating,
totalCount: bottles.length,
topDistillery: distilleryData[0]?.name || 'N/A',
categoryData,
statusData,
distilleryData,
abvData,
ageData,
scatterData
};
}, [bottles]);
// Helper for Custom Tooltip
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-xl shadow-xl">
<p className="font-bold text-white mb-1">{label}</p>
{payload.map((entry: any, index: number) => (
<p key={index} className="text-sm" style={{ color: entry.color }}>
{entry.name}: {entry.value}
</p>
))}
</div>
);
}
return null;
};
// Scatter Tooltip
const ScatterTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-xl shadow-xl max-w-[200px]">
<p className="font-bold text-white mb-1 text-xs">{data.name}</p>
<p className="text-xs text-zinc-400">Price: <span className="text-green-500 font-bold">{data.x}</span></p>
<p className="text-xs text-zinc-400">Rating: <span className="text-orange-500 font-bold">{data.y?.toFixed(1)}</span></p>
</div>
);
}
return null;
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-top-4 duration-500">
{/* KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
<div className="flex items-center gap-2 mb-2 text-zinc-500">
<CreditCard size={18} className="text-green-500" />
<span className="text-xs font-bold uppercase tracking-wider">Total Value</span>
</div>
<div className="text-2xl md:text-3xl font-black text-white">
{stats.totalValue.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
<div className="flex items-center gap-2 mb-2 text-zinc-500">
<Home size={18} className="text-blue-500" />
<span className="text-xs font-bold uppercase tracking-wider">Bottles</span>
</div>
<div className="text-2xl md:text-3xl font-black text-white">
{stats.totalCount}
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
<div className="flex items-center gap-2 mb-2 text-zinc-500">
<Star size={18} className="text-orange-500" />
<span className="text-xs font-bold uppercase tracking-wider">Avg Rating</span>
</div>
<div className="text-2xl md:text-3xl font-black text-white">
{stats.avgCollectionRating.toFixed(1)}
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
<div className="flex items-center gap-2 mb-2 text-zinc-500">
<Activity size={18} className="text-purple-500" />
<span className="text-xs font-bold uppercase tracking-wider">Favorite</span>
</div>
<div className="text-xl md:text-2xl font-black text-white truncate" title={stats.topDistillery}>
{stats.topDistillery}
</div>
</div>
</div>
{/* Row 1: Categories & Status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Category Distribution */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<BarChart3 size={20} className="text-zinc-500" />
Categories
</h3>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={stats.categoryData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={5}
dataKey="value"
stroke="none"
>
{stats.categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '20px' }} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
{/* Status Distribution */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<Activity size={20} className="text-zinc-500" />
Status
</h3>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.statusData} layout="vertical" margin={{ left: 20 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#27272a" />
<XAxis type="number" stroke="#52525b" fontSize={12} />
<YAxis dataKey="name" type="category" stroke="#a1a1aa" fontSize={12} width={80} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="value" name="Bottles" radius={[0, 4, 4, 0]}>
{stats.statusData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Row 2: Distillery Top 10 */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<Home size={20} className="text-zinc-500" />
Top Distilleries
</h3>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.distilleryData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={12} interval={0} angle={-45} textAnchor="end" height={60} />
<YAxis stroke="#52525b" fontSize={12} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#27272a' }} />
<Bar dataKey="value" name="Bottles" fill="#f97316" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Row 3: Technical Specs (ABV & Age) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* ABV */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<Droplets size={20} className="text-zinc-500" />
Strength (ABV)
</h3>
<div className="h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.abvData}>
<defs>
<linearGradient id="colorAbv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={10} />
<YAxis stroke="#52525b" fontSize={10} />
<Tooltip content={<CustomTooltip />} />
<Area type="monotone" dataKey="value" name="Bottles" stroke="#8b5cf6" fillOpacity={1} fill="url(#colorAbv)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Age */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<Clock size={20} className="text-zinc-500" />
Age Statements
</h3>
<div className="h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.ageData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={10} />
<YAxis stroke="#52525b" fontSize={10} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#27272a' }} />
<Bar dataKey="value" name="Bottles" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Row 4: Price vs Quality */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-white flex items-center gap-2">
<DollarSign size={20} className="text-zinc-500" />
Price vs. Quality
</h3>
<span className="text-xs text-zinc-500 px-2 py-1 bg-zinc-800 rounded-lg">Excludes free/unrated bottles</span>
</div>
<p className="text-xs text-zinc-500 mb-6">
Find hidden gems: Low price (left) but high rating (top).
</p>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#27272a" />
<XAxis
type="number"
dataKey="x"
name="Price"
unit="€"
stroke="#52525b"
fontSize={12}
label={{ value: 'Price (€)', position: 'insideBottom', offset: -10, fill: '#71717a', fontSize: 12 }}
/>
<YAxis
type="number"
dataKey="y"
name="Rating"
domain={[0, 100]}
stroke="#52525b"
fontSize={12}
label={{ value: 'Rating (0-100)', angle: -90, position: 'insideLeft', fill: '#71717a', fontSize: 12 }}
/>
<ZAxis type="number" dataKey="z" range={[50, 400]} />
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: '3 3' }} />
<Scatter name="Bottles" data={stats.scatterData} fill="#ec4899" fillOpacity={0.6} stroke="#fff" strokeWidth={1} />
</ScatterChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}

View File

@@ -133,7 +133,7 @@ export default function AuthForm() {
placeholder="dein_username" placeholder="dein_username"
required required
maxLength={20} maxLength={20}
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600" className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
/> />
</div> </div>
<p className="text-[10px] text-zinc-600 ml-1">Nur Kleinbuchstaben, Zahlen und _</p> <p className="text-[10px] text-zinc-600 ml-1">Nur Kleinbuchstaben, Zahlen und _</p>
@@ -149,7 +149,7 @@ export default function AuthForm() {
onChange={(e) => setFullName(e.target.value)} onChange={(e) => setFullName(e.target.value)}
placeholder="Max Mustermann" placeholder="Max Mustermann"
maxLength={50} maxLength={50}
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600" className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
/> />
</div> </div>
</div> </div>
@@ -166,7 +166,7 @@ export default function AuthForm() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de" placeholder="name@beispiel.de"
required required
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600" className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
/> />
</div> </div>
</div> </div>
@@ -181,7 +181,7 @@ export default function AuthForm() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
required required
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600" className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
/> />
</div> </div>
</div> </div>

View File

@@ -30,7 +30,7 @@ export default function AvatarStack({ names, limit = 3, size = 'sm' }: AvatarSta
{visibleNames.map((name, i) => ( {visibleNames.map((name, i) => (
<div <div
key={`${name}-${i}`} key={`${name}-${i}`}
className={`${sizeClasses} rounded-full bg-orange-900/30 border-2 border-zinc-950 flex items-center justify-center text-orange-400 font-bold ring-1 ring-orange-500/10 shadow-sm relative group`} className={`${sizeClasses} rounded-full bg-orange-900/30 border-2 border-zinc-950 flex items-center justify-center text-orange-400 font-bold ring-1 ring-orange-500/10 shadow-xs relative group`}
title={name} title={name}
> >
{getInitials(name)} {getInitials(name)}
@@ -42,7 +42,7 @@ export default function AvatarStack({ names, limit = 3, size = 'sm' }: AvatarSta
))} ))}
{extraCount > 0 && ( {extraCount > 0 && (
<div <div
className={`${sizeClasses} rounded-full bg-zinc-100 dark:bg-zinc-800 border-2 border-white dark:border-zinc-900 flex items-center justify-center text-zinc-500 dark:text-zinc-400 font-black ring-1 ring-zinc-500/10 shadow-sm relative group`} className={`${sizeClasses} rounded-full bg-zinc-100 dark:bg-zinc-800 border-2 border-white dark:border-zinc-900 flex items-center justify-center text-zinc-500 dark:text-zinc-400 font-black ring-1 ring-zinc-500/10 shadow-xs relative group`}
title={`${extraCount} weitere Personen`} title={`${extraCount} weitere Personen`}
> >
+{extraCount} +{extraCount}

View File

@@ -0,0 +1,66 @@
'use client';
import { useEffect, useRef } from 'react';
import { useImageProcessor } from '@/hooks/useImageProcessor';
import { db } from '@/lib/db';
import { FEATURES } from '@/config/features';
/**
* Global handler for background AI image processing.
* Mount this in root layout to ensure processing continues in background.
* It also scans for unprocessed local images on load.
*/
export default function BackgroundRemovalHandler() {
const { addToQueue } = useImageProcessor();
const hasScannedRef = useRef(false);
useEffect(() => {
if (!FEATURES.ENABLE_AI_BG_REMOVAL) return;
if (hasScannedRef.current) return;
hasScannedRef.current = true;
const scanAndQueue = async () => {
try {
// 1. Check pending_scans (offline scans)
const pendingScans = await db.pending_scans
.filter(scan => !scan.bgRemoved)
.toArray();
for (const scan of pendingScans) {
if (scan.imageBase64 && scan.temp_id) {
// Convert base64 back to blob for the worker
const res = await fetch(scan.imageBase64);
const blob = await res.blob();
addToQueue(scan.temp_id, blob);
}
}
// 2. Check cache_bottles (successfully saved bottles)
const cachedBottles = await db.cache_bottles
.filter(bottle => !bottle.bgRemoved)
.limit(10) // Limit to avoid overwhelming on start
.toArray();
for (const bottle of cachedBottles) {
if (bottle.image_url && bottle.id) {
try {
const res = await fetch(bottle.image_url);
const blob = await res.blob();
addToQueue(bottle.id, blob);
} catch (e) {
console.warn(`[BG-Removal] Failed to fetch image for bottle ${bottle.id}:`, e);
}
}
}
} catch (err) {
console.error('[BG-Removal] Initial scan error:', err);
}
};
// Delay slightly to not block initial app boot
const timer = setTimeout(scanAndQueue, 3000);
return () => clearTimeout(timer);
}, [addToQueue]);
return null; // Logic-only component
}

View File

@@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus, Share2 } from 'lucide-react'; import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus, Share2, TrendingUp } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { updateBottle } from '@/services/update-bottle'; import { updateBottle } from '@/services/update-bottle';
import { getStorageUrl } from '@/lib/supabase'; import { getStorageUrl } from '@/lib/supabase';
@@ -12,6 +12,8 @@ import DeleteBottleButton from '@/components/DeleteBottleButton';
import EditBottleForm from '@/components/EditBottleForm'; import EditBottleForm from '@/components/EditBottleForm';
import { useBottleData } from '@/hooks/useBottleData'; import { useBottleData } from '@/hooks/useBottleData';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import FlavorRadar from './FlavorRadar';
interface BottleDetailsProps { interface BottleDetailsProps {
bottleId: string; bottleId: string;
@@ -96,7 +98,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
return ( return (
<div className="max-w-4xl mx-auto pb-24"> <div className="max-w-4xl mx-auto pb-24">
{/* Header / Hero Section */} {/* Header / Hero Section */}
<div className="relative w-full overflow-hidden bg-[var(--surface)] shadow-2xl"> <div className="relative w-full overflow-hidden bg-(--surface) shadow-2xl">
{/* Back Button Overlay */} {/* Back Button Overlay */}
<div className="absolute top-6 left-6 z-20"> <div className="absolute top-6 left-6 z-20">
<Link <Link
@@ -108,9 +110,9 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
</div> </div>
{/* Hero Image - Slightly More Compact Aspect for better title flow */} {/* Hero Image - Slightly More Compact Aspect for better title flow */}
<div className="relative aspect-[4/3] md:aspect-[16/8] w-full flex items-center justify-center p-6 md:p-10 overflow-hidden"> <div className="relative aspect-4/3 md:aspect-16/8 w-full flex items-center justify-center p-6 md:p-10 overflow-hidden">
{/* Background Glow */} {/* Background Glow */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-orange-600/10 via-transparent to-transparent opacity-30" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,var(--tw-gradient-stops))] from-orange-600/10 via-transparent to-transparent opacity-30" />
<img <img
src={getStorageUrl(bottle.image_url)} src={getStorageUrl(bottle.image_url)}
alt={bottle.name} alt={bottle.name}
@@ -119,7 +121,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
</div> </div>
{/* Info Overlay - Mobile Gradient */} {/* Info Overlay - Mobile Gradient */}
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-[var(--background)] to-transparent pointer-events-none" /> <div className="absolute inset-x-0 bottom-0 h-48 bg-linear-to-t from-(--background) to-transparent pointer-events-none" />
</div> </div>
{/* Content Container */} {/* Content Container */}
@@ -132,7 +134,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
<p className="text-[9px] font-black uppercase tracking-widest text-orange-500">Offline Mode</p> <p className="text-[9px] font-black uppercase tracking-widest text-orange-500">Offline Mode</p>
</div> </div>
)} )}
<h2 className="text-xs md:text-sm font-black text-orange-500 uppercase tracking-[0.25em] drop-shadow-sm"> <h2 className="text-xs md:text-sm font-black text-orange-500 uppercase tracking-[0.25em] drop-shadow-xs">
{bottle.distillery || 'Unknown Distillery'} {bottle.distillery || 'Unknown Distillery'}
</h2> </h2>
<h1 className="text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[1.05] drop-shadow-md"> <h1 className="text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[1.05] drop-shadow-md">
@@ -167,6 +169,47 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
exit={{ opacity: 0, x: 20 }} exit={{ opacity: 0, x: 20 }}
className="p-6 md:p-8 space-y-8" className="p-6 md:p-8 space-y-8"
> >
{/* Flavor Profile Section */}
{tastings && tastings.some((t: any) => t.flavor_profile) && (
<div className="bg-black/20 rounded-3xl border border-white/5 p-6 space-y-4">
<div className="flex items-center gap-2 px-1">
<TrendingUp size={14} className="text-orange-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-zinc-500">Average Flavor Profile</span>
</div>
<div className="flex flex-col md:flex-row items-center gap-6">
<div className="w-full md:w-1/2">
<FlavorRadar
profile={(() => {
const validProfiles = tastings.filter((t: any) => t.flavor_profile).map((t: any) => t.flavor_profile);
const count = validProfiles.length;
return {
smoky: Math.round(validProfiles.reduce((s, p) => s + p.smoky, 0) / count),
fruity: Math.round(validProfiles.reduce((s, p) => s + p.fruity, 0) / count),
spicy: Math.round(validProfiles.reduce((s, p) => s + p.spicy, 0) / count),
sweet: Math.round(validProfiles.reduce((s, p) => s + p.sweet, 0) / count),
floral: Math.round(validProfiles.reduce((s, p) => s + p.floral, 0) / count),
};
})()}
size={220}
/>
</div>
<div className="w-full md:w-1/2 space-y-2">
<p className="text-xs text-zinc-400 leading-relaxed font-medium italic">
Basierend auf {tastings.filter((t: any) => t.flavor_profile).length} Verkostungen. Dieses Diagramm zeigt den durchschnittlichen Charakter dieser Flasche.
</p>
<div className="grid grid-cols-2 gap-2 pt-2">
{['smoky', 'fruity', 'spicy', 'sweet', 'floral'].map(attr => (
<div key={attr} className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-orange-600" />
<span className="text-[9px] font-black uppercase tracking-wider text-zinc-500">{attr}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* Fact Grid - Integrated Metadata & Stats */} {/* Fact Grid - Integrated Metadata & Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<FactCard label="Category" value={bottle.category || 'Whisky'} icon={<Wine size={14} />} /> <FactCard label="Category" value={bottle.category || 'Whisky'} icon={<Wine size={14} />} />

View File

@@ -32,61 +32,46 @@ interface BottleCardProps {
function BottleCard({ bottle, sessionId }: BottleCardProps) { function BottleCard({ bottle, sessionId }: BottleCardProps) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const imageUrl = getStorageUrl(bottle.image_url);
return ( return (
<Link <Link
href={`/bottles/${bottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`} href={`/bottles/${bottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`}
className="block h-full group relative overflow-hidden rounded-2xl bg-zinc-800/20 backdrop-blur-sm border border-white/[0.05] transition-all duration-500 hover:border-orange-500/30 hover:shadow-2xl hover:shadow-orange-950/20 active:scale-[0.98] flex flex-col" className="block h-full group relative overflow-hidden rounded-2xl bg-zinc-900 border border-white/5 transition-all duration-500 hover:border-orange-500/30 hover:shadow-2xl hover:shadow-orange-950/20 active:scale-[0.98]"
> >
{/* Image Layer - Clean Split Top */} {/* === SPOTIFY-STYLE IMAGE SECTION === */}
<div className="aspect-[4/3] overflow-hidden shrink-0"> <div className="relative aspect-3/4 overflow-hidden">
{/* Layer 1: Blurred Backdrop */}
<div className="absolute inset-0 z-0">
<img <img
src={getStorageUrl(bottle.image_url)} src={imageUrl}
alt={bottle.name} alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 ease-out" loading="lazy"
className="w-full h-full object-cover scale-125 blur-[20px] saturate-150 brightness-[0.6]"
/>
{/* Vignette Overlay */}
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(circle, rgba(0,0,0,0) 20%, rgba(0,0,0,0.5) 80%)'
}}
/> />
</div> </div>
{/* Info Layer - Clean Split Bottom */} {/* Layer 2: Sharp Foreground Image */}
<div className="p-4 flex-1 flex flex-col justify-between space-y-4"> <div className="absolute inset-[10px] z-10 flex items-center justify-center">
<div className="space-y-1"> <img
<p className="text-[10px] font-black text-orange-600 uppercase tracking-[0.2em] leading-none mb-1"> src={imageUrl}
{bottle.distillery} alt={bottle.name}
</p> loading="lazy"
<h3 className="font-bold text-lg text-zinc-50 leading-tight tracking-tight"> className="max-w-full max-h-full object-contain drop-shadow-[0_10px_20px_rgba(0,0,0,0.5)] group-hover:scale-105 transition-transform duration-500 ease-out"
{bottle.name || t('grid.unknownBottle')} />
</h3>
</div>
<div className="space-y-4 pt-2">
<div className="flex flex-wrap gap-2">
<span className="px-2 py-1 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
{shortenCategory(bottle.category)}
</span>
<span className="px-2 py-1 bg-zinc-800 text-zinc-400 text-[9px] font-bold uppercase tracking-widest rounded-md">
{bottle.abv}% VOL
</span>
</div>
{/* Metadata items */}
<div className="flex items-center gap-4 pt-3 border-t border-zinc-800/50 mt-auto">
<div className="flex items-center gap-1 text-[10px] font-medium text-zinc-500">
<Calendar size={12} className="text-zinc-500" />
{new Date(bottle.created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</div>
{bottle.last_tasted && (
<div className="flex items-center gap-1 text-[10px] font-medium text-zinc-500">
<Clock size={12} className="text-zinc-500" />
{new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</div>
)}
</div>
</div>
</div> </div>
{/* Top Overlays */} {/* Top Overlays */}
{(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && ( {(bottle.is_whisky === false || (bottle.confidence && bottle.confidence < 70)) && (
<div className="absolute top-3 right-3 z-10"> <div className="absolute top-3 right-3 z-20">
<div className="bg-red-500 text-white p-1.5 rounded-full shadow-lg"> <div className="bg-red-500 text-white p-1.5 rounded-full shadow-lg">
<AlertCircle size={12} /> <AlertCircle size={12} />
</div> </div>
@@ -94,15 +79,44 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
)} )}
{sessionId && ( {sessionId && (
<div className="absolute top-3 left-3 z-10 bg-orange-600 text-white text-[9px] font-bold px-2 py-1 rounded-md flex items-center gap-1.5 shadow-xl"> <div className="absolute top-3 left-3 z-20 bg-orange-600 text-white text-[9px] font-bold px-2 py-1 rounded-md flex items-center gap-1.5 shadow-xl">
<PlusCircle size={12} /> <PlusCircle size={12} />
ADD ADD
</div> </div>
)} )}
{/* Bottom Gradient Overlay for Text */}
<div
className="absolute bottom-0 left-0 right-0 z-10 h-32"
style={{
background: 'linear-gradient(to top, rgba(0,0,0,0.9) 0%, transparent 100%)'
}}
/>
{/* Info Overlay at Bottom */}
<div className="absolute bottom-0 left-0 right-0 z-20 p-4 text-white">
<p className="text-[10px] font-black text-orange-500 uppercase tracking-[0.2em] leading-none mb-1">
{bottle.distillery}
</p>
<h3 className="font-bold text-lg text-zinc-50 leading-tight tracking-tight line-clamp-2">
{bottle.name || t('grid.unknownBottle')}
</h3>
<div className="flex flex-wrap gap-2 mt-3">
<span className="px-2 py-1 bg-white/10 backdrop-blur-xs text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
{shortenCategory(bottle.category)}
</span>
<span className="px-2 py-1 bg-white/10 backdrop-blur-xs text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
{bottle.abv}% VOL
</span>
</div>
</div>
</div>
</Link> </Link>
); );
} }
interface BottleGridProps { interface BottleGridProps {
bottles: any[]; bottles: any[];
} }
@@ -202,7 +216,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
placeholder={t('grid.searchPlaceholder')} placeholder={t('grid.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-8 py-4 bg-transparent border-b border-zinc-800 focus:border-orange-500 outline-none transition-all text-zinc-50 placeholder:text-zinc-500" className="w-full pl-8 pr-8 py-4 bg-transparent border-b border-zinc-800 focus:border-orange-500 outline-hidden transition-all text-zinc-50 placeholder:text-zinc-500"
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -218,7 +232,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)} onChange={(e) => setSortBy(e.target.value as any)}
className="bg-transparent border-none text-zinc-500 text-xs font-bold uppercase tracking-widest outline-none cursor-pointer hover:text-white transition-colors appearance-none" className="bg-transparent border-none text-zinc-500 text-xs font-bold uppercase tracking-widest outline-hidden cursor-pointer hover:text-white transition-colors appearance-none"
> >
<option value="created_at" className="bg-zinc-950">{t('grid.sortBy.createdAt')}</option> <option value="created_at" className="bg-zinc-950">{t('grid.sortBy.createdAt')}</option>
<option value="last_tasted" className="bg-zinc-950">{t('grid.sortBy.lastTasted')}</option> <option value="last_tasted" className="bg-zinc-950">{t('grid.sortBy.lastTasted')}</option>

View File

@@ -32,7 +32,7 @@ export default function BottleSkeletonCard({
}`} }`}
> >
{/* Image */} {/* Image */}
<div className="aspect-[3/4] bg-zinc-950 relative overflow-hidden"> <div className="aspect-3/4 bg-zinc-950 relative overflow-hidden">
{imageUrl ? ( {imageUrl ? (
<img <img
src={imageUrl} src={imageUrl}
@@ -81,12 +81,12 @@ export default function BottleSkeletonCard({
{/* Info */} {/* Info */}
<div className="p-3"> <div className="p-3">
{/* Skeleton Name */} {/* Skeleton Name */}
<div className="h-4 bg-zinc-800 rounded animate-pulse mb-2 w-3/4" /> <div className="h-4 bg-zinc-800 rounded-sm animate-pulse mb-2 w-3/4" />
{/* Skeleton Details */} {/* Skeleton Details */}
<div className="flex gap-2"> <div className="flex gap-2">
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-12" /> <div className="h-3 bg-zinc-800/50 rounded-sm animate-pulse w-12" />
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-8" /> <div className="h-3 bg-zinc-800/50 rounded-sm animate-pulse w-8" />
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@@ -106,7 +106,7 @@ export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHand
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" className="fixed inset-0 bg-black/80 backdrop-blur-xs z-50 flex items-center justify-center p-4"
onClick={onClose} onClick={onClose}
> >
<motion.div <motion.div
@@ -240,7 +240,7 @@ export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHand
} }
}} }}
placeholder="XXXXXX" placeholder="XXXXXX"
className="w-full text-center text-3xl font-black tracking-[0.4em] bg-zinc-950 border-2 border-zinc-800 rounded-2xl py-4 text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-500 transition-colors font-mono" className="w-full text-center text-3xl font-black tracking-[0.4em] bg-zinc-950 border-2 border-zinc-800 rounded-2xl py-4 text-white placeholder:text-zinc-700 focus:outline-hidden focus:border-orange-500 transition-colors font-mono"
maxLength={6} maxLength={6}
autoFocus autoFocus
/> />

View File

@@ -112,7 +112,7 @@ export default function BuddyList() {
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t('buddy.placeholder')} placeholder={t('buddy.placeholder')}
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-none focus:border-orange-600 transition-colors" className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
<button <button
type="submit" type="submit"
@@ -183,12 +183,12 @@ export default function BuddyList() {
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1"> <div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
<div className="flex -space-x-1.5 overflow-hidden"> <div className="flex -space-x-1.5 overflow-hidden">
{buddies.slice(0, 5).map((b, i) => ( {buddies.slice(0, 5).map((b, i) => (
<div key={b.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-sm"> <div key={b.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-xs">
{b.name[0].toUpperCase()} {b.name[0].toUpperCase()}
</div> </div>
))} ))}
{buddies.length > 5 && ( {buddies.length > 5 && (
<div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-sm"> <div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-xs">
+{buddies.length - 5} +{buddies.length - 5}
</div> </div>
)} )}

View File

@@ -122,7 +122,7 @@ export default function BulkScanSheet({
className="fixed inset-0 bg-black z-50 flex flex-col" className="fixed inset-0 bg-black z-50 flex flex-col"
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 bg-zinc-900/80 backdrop-blur-sm border-b border-zinc-800"> <div className="flex items-center justify-between p-4 bg-zinc-900/80 backdrop-blur-xs border-b border-zinc-800">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
<Zap size={20} className="text-orange-500" /> <Zap size={20} className="text-orange-500" />
@@ -240,7 +240,7 @@ export default function BulkScanSheet({
<X size={12} className="text-white" /> <X size={12} className="text-white" />
</button> </button>
)} )}
<span className="absolute bottom-0.5 left-0.5 text-[9px] font-bold text-white bg-black/60 px-1 rounded"> <span className="absolute bottom-0.5 left-0.5 text-[9px] font-bold text-white bg-black/60 px-1 rounded-sm">
#{i + 1} #{i + 1}
</span> </span>
</motion.div> </motion.div>

View File

@@ -19,6 +19,8 @@ import { shortenCategory } from '@/lib/format';
import { scanLabel } from '@/app/actions/scanner'; import { scanLabel } from '@/app/actions/scanner';
import { enrichData } from '@/app/actions/enrich-data'; import { enrichData } from '@/app/actions/enrich-data';
import { processImageForAI } from '@/utils/image-processing'; import { processImageForAI } from '@/utils/image-processing';
import { runCascadeOCR } from '@/services/cascade-ocr';
import { FEATURES } from '@/config/features';
interface CameraCaptureProps { interface CameraCaptureProps {
onImageCaptured?: (base64Image: string) => void; onImageCaptured?: (base64Image: string) => void;
@@ -64,7 +66,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const [isDiscovering, setIsDiscovering] = useState(false); const [isDiscovering, setIsDiscovering] = useState(false);
const [originalFile, setOriginalFile] = useState<File | null>(null); const [originalFile, setOriginalFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [aiProvider, setAiProvider] = useState<'gemini' | 'mistral'>('gemini'); const [aiProvider, setAiProvider] = useState<'gemini' | 'openrouter'>('gemini');
const [perfMetrics, setPerfMetrics] = useState<{ const [perfMetrics, setPerfMetrics] = useState<{
compression: number; compression: number;
@@ -159,6 +161,13 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const formData = new FormData(); const formData = new FormData();
formData.append('file', processed.file); formData.append('file', processed.file);
// Run Cascade OCR in parallel (for comparison/logging only - doesn't block AI)
if (FEATURES.ENABLE_CASCADE_OCR) {
runCascadeOCR(processed.file).catch(err => {
console.warn('[CameraCapture] Cascade OCR failed:', err);
});
}
const startAi = performance.now(); const startAi = performance.now();
const response = await scanLabel(formData); const response = await scanLabel(formData);
const endAi = performance.now(); const endAi = performance.now();
@@ -298,10 +307,10 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
Gemini Gemini
</button> </button>
<button <button
onClick={() => setAiProvider('mistral')} onClick={() => setAiProvider('openrouter')}
className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'mistral' ? 'bg-orange-600 text-white shadow-xl shadow-orange-950/20' : 'text-zinc-500 hover:text-zinc-300'}`} className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${aiProvider === 'openrouter' ? 'bg-orange-600 text-white shadow-xl shadow-orange-950/20' : 'text-zinc-500 hover:text-zinc-300'}`}
> >
Mistral 3 🇪🇺 Gemma 🇪🇺
</button> </button>
</div> </div>
)} )}
@@ -464,7 +473,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
)} )}
{isQueued && ( {isQueued && (
<div className="flex flex-col gap-3 p-5 bg-gradient-to-br from-purple-500/10 to-amber-500/10 dark:from-purple-900/20 dark:to-amber-900/20 border border-purple-500/20 rounded-3xl w-full animate-in zoom-in-95 duration-500"> <div className="flex flex-col gap-3 p-5 bg-linear-to-br from-purple-500/10 to-amber-500/10 dark:from-purple-900/20 dark:to-amber-900/20 border border-purple-500/20 rounded-3xl w-full animate-in zoom-in-95 duration-500">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30"><Sparkles size={20} /></div> <div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30"><Sparkles size={20} /></div>
<div className="flex flex-col"> <div className="flex flex-col">

View File

@@ -24,7 +24,7 @@ export default function CookieBanner() {
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }} exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }} transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 z-[100] p-4 md:p-6" className="fixed bottom-0 left-0 right-0 z-100 p-4 md:p-6"
> >
<div className="max-w-4xl mx-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden"> <div className="max-w-4xl mx-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden">
{/* Header */} {/* Header */}

View File

@@ -55,7 +55,7 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
</button> </button>
{suggestion && ( {suggestion && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-zinc-950/80 backdrop-blur-sm animate-in fade-in duration-300"> <div className="fixed inset-0 z-100 flex items-center justify-center p-6 bg-zinc-950/80 backdrop-blur-xs animate-in fade-in duration-300">
<div className="bg-zinc-900 w-full max-w-sm rounded-[40px] p-8 shadow-2xl border border-orange-500/20 relative animate-in zoom-in-95 duration-300"> <div className="bg-zinc-900 w-full max-w-sm rounded-[40px] p-8 shadow-2xl border border-orange-500/20 relative animate-in zoom-in-95 duration-300">
<button <button
onClick={() => setSuggestion(null)} onClick={() => setSuggestion(null)}

View File

@@ -36,10 +36,10 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
name: bottle.name, name: bottle.name,
distillery: bottle.distillery || '', distillery: bottle.distillery || '',
category: bottle.category || '', category: bottle.category || '',
abv: bottle.abv || 0, abv: bottle.abv?.toString() || '',
age: bottle.age || 0, age: bottle.age?.toString() || '',
whiskybase_id: bottle.whiskybase_id || '', whiskybase_id: bottle.whiskybase_id || '',
purchase_price: bottle.purchase_price || '', purchase_price: bottle.purchase_price?.toString() || '',
distilled_at: bottle.distilled_at || '', distilled_at: bottle.distilled_at || '',
bottled_at: bottle.bottled_at || '', bottled_at: bottle.bottled_at || '',
batch_info: bottle.batch_info || '', batch_info: bottle.batch_info || '',
@@ -54,8 +54,8 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
const result = await discoverWhiskybaseId({ const result = await discoverWhiskybaseId({
name: formData.name, name: formData.name,
distillery: formData.distillery, distillery: formData.distillery,
abv: formData.abv, abv: formData.abv ? parseFloat(formData.abv) : undefined,
age: formData.age, age: formData.age ? parseInt(formData.age) : undefined,
distilled_at: formData.distilled_at || undefined, distilled_at: formData.distilled_at || undefined,
bottled_at: formData.bottled_at || undefined, bottled_at: formData.bottled_at || undefined,
batch_info: formData.batch_info || undefined, batch_info: formData.batch_info || undefined,
@@ -83,14 +83,14 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
try { try {
const response = await updateBottle(bottle.id, { const response = await updateBottle(bottle.id, {
...formData, ...formData,
abv: Number(formData.abv), abv: formData.abv ? parseFloat(formData.abv.replace(',', '.')) : null,
age: formData.age ? Number(formData.age) : undefined, age: formData.age ? parseInt(formData.age) : null,
purchase_price: formData.purchase_price ? Number(formData.purchase_price) : undefined, purchase_price: formData.purchase_price ? parseFloat(formData.purchase_price.replace(',', '.')) : null,
distilled_at: formData.distilled_at || undefined, distilled_at: formData.distilled_at || undefined,
bottled_at: formData.bottled_at || undefined, bottled_at: formData.bottled_at || undefined,
batch_info: formData.batch_info || undefined, batch_info: formData.batch_info || undefined,
cask_type: formData.cask_type || undefined, cask_type: formData.cask_type || undefined,
}); } as any);
if (response.success) { if (response.success) {
setIsEditing(false); setIsEditing(false);
@@ -115,7 +115,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text" type="text"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
@@ -125,7 +125,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text" type="text"
value={formData.distillery} value={formData.distillery}
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })} onChange={(e) => setFormData({ ...formData, distillery: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
@@ -136,7 +136,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text" type="text"
value={formData.category} value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })} onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
@@ -145,22 +145,23 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.abvLabel')}</label> <label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.abvLabel')}</label>
<input <input
type="number" type="text"
inputMode="decimal" inputMode="decimal"
step="0.1"
value={formData.abv} value={formData.abv}
onChange={(e) => setFormData({ ...formData, abv: parseFloat(e.target.value) })} onChange={(e) => setFormData({ ...formData, abv: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-orange-500 text-sm font-bold transition-all" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-orange-500 text-sm font-bold transition-all"
placeholder="e.g. 46.3"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.ageLabel')}</label> <label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.ageLabel')}</label>
<input <input
type="number" type="text"
inputMode="numeric" inputMode="numeric"
value={formData.age} value={formData.age}
onChange={(e) => setFormData({ ...formData, age: parseInt(e.target.value) })} onChange={(e) => setFormData({ ...formData, age: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all"
placeholder="e.g. 12"
/> />
</div> </div>
</div> </div>
@@ -175,7 +176,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="YYYY" placeholder="YYYY"
value={formData.distilled_at} value={formData.distilled_at}
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })} onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -186,7 +187,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="YYYY" placeholder="YYYY"
value={formData.bottled_at} value={formData.bottled_at}
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })} onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
</div> </div>
@@ -196,13 +197,12 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.priceLabel')} ()</label> <label className="text-[10px] font-black uppercase text-zinc-500 ml-1 tracking-widest">{t('bottle.priceLabel')} ()</label>
<input <input
type="number" type="text"
inputMode="decimal" inputMode="decimal"
step="0.01"
placeholder="0.00" placeholder="0.00"
value={formData.purchase_price} value={formData.purchase_price}
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })} onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 font-bold text-orange-500 text-sm transition-all" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 font-bold text-orange-500 text-sm transition-all"
/> />
</div> </div>
@@ -225,7 +225,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
inputMode="numeric" inputMode="numeric"
value={formData.whiskybase_id} value={formData.whiskybase_id}
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })} onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-300 text-sm font-mono transition-all" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-300 text-sm font-mono transition-all"
/> />
{discoveryResult && ( {discoveryResult && (
<div className="absolute top-full left-0 right-0 z-50 mt-3 p-4 bg-zinc-900 border border-orange-500/30 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-300"> <div className="absolute top-full left-0 right-0 z-50 mt-3 p-4 bg-zinc-900 border border-orange-500/30 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-300">
@@ -263,7 +263,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="e.g. Batch 12 or L-Code" placeholder="e.g. Batch 12 or L-Code"
value={formData.batch_info} value={formData.batch_info}
onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })} onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -273,7 +273,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="e.g. Oloroso Sherry" placeholder="e.g. Oloroso Sherry"
value={formData.cask_type} value={formData.cask_type}
onChange={(e) => setFormData({ ...formData, cask_type: e.target.value })} onChange={(e) => setFormData({ ...formData, cask_type: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
</div> </div>
@@ -296,7 +296,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
className="flex-[2] py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all flex items-center justify-center gap-2 shadow-xl shadow-orange-950/20 disabled:opacity-50" className="flex-2 py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all flex items-center justify-center gap-2 shadow-xl shadow-orange-950/20 disabled:opacity-50"
> >
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />} {isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{t('bottle.saveChanges')} {t('bottle.saveChanges')}

View File

@@ -0,0 +1,65 @@
'use client';
import React from 'react';
import {
Radar,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer
} from 'recharts';
interface FlavorProfile {
smoky: number;
fruity: number;
spicy: number;
sweet: number;
floral: number;
}
interface FlavorRadarProps {
profile: FlavorProfile;
size?: number;
showAxis?: boolean;
}
export default function FlavorRadar({ profile, size = 300, showAxis = true }: FlavorRadarProps) {
const data = [
{ subject: 'Smoky', A: profile.smoky, fullMark: 100 },
{ subject: 'Fruity', A: profile.fruity, fullMark: 100 },
{ subject: 'Spicy', A: profile.spicy, fullMark: 100 },
{ subject: 'Sweet', A: profile.sweet, fullMark: 100 },
{ subject: 'Floral', A: profile.floral, fullMark: 100 },
];
return (
<div style={{ width: '100%', height: size }} className="flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={data}>
<PolarGrid stroke="#3f3f46" />
<PolarAngleAxis
dataKey="subject"
tick={{ fill: '#71717a', fontSize: 10, fontWeight: 700 }}
/>
{!showAxis && <PolarRadiusAxis axisLine={false} tick={false} />}
{showAxis && (
<PolarRadiusAxis
angle={30}
domain={[0, 100]}
tick={{ fill: '#3f3f46', fontSize: 8 }}
axisLine={false}
/>
)}
<Radar
name="Flavor"
dataKey="A"
stroke="#d97706"
fill="#d97706"
fillOpacity={0.5}
/>
</RadarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -108,7 +108,7 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
ease: "easeInOut", ease: "easeInOut",
repeatDelay: 3 repeatDelay: 3
}} }}
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 -z-0" className="absolute inset-0 bg-linear-to-r from-transparent via-white/40 to-transparent skew-x-12 z-0"
/> />
)} )}

View File

@@ -0,0 +1,85 @@
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import Link from 'next/link';
import { ChevronRight } from 'lucide-react';
interface Banner {
id: string;
title: string;
image_url: string;
link_target: string | null;
cta_text: string;
}
export default function HeroBanner() {
const [banner, setBanner] = useState<Banner | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchBanner = async () => {
try {
const supabase = createClient();
const { data, error } = await supabase
.from('app_banners')
.select('*')
.eq('is_active', true)
.limit(1)
.maybeSingle();
if (!error && data) {
setBanner(data);
}
} catch (err) {
console.warn('[HeroBanner] Failed to fetch:', err);
} finally {
setIsLoading(false);
}
};
fetchBanner();
}, []);
// Don't render if no active banner
if (isLoading || !banner) {
return null;
}
const content = (
<div
className="relative h-48 rounded-2xl overflow-hidden bg-zinc-900 group"
style={{
backgroundImage: `url(${banner.image_url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{/* Overlay gradient */}
<div className="absolute inset-0 bg-linear-to-t from-black/80 via-black/20 to-transparent" />
{/* Content */}
<div className="absolute bottom-0 left-0 right-0 p-4">
<h3 className="text-lg font-bold text-white mb-1 line-clamp-2">
{banner.title}
</h3>
{banner.link_target && (
<div className="flex items-center gap-1 text-orange-500 text-xs font-bold uppercase tracking-wider">
{banner.cta_text}
<ChevronRight size={14} className="group-hover:translate-x-1 transition-transform" />
</div>
)}
</div>
</div>
);
if (banner.link_target) {
return (
<Link href={banner.link_target} className="block">
{content}
</Link>
);
}
return content;
}

View File

@@ -11,7 +11,7 @@ const LanguageSwitcher = () => {
<button <button
onClick={() => setLocale('de')} onClick={() => setLocale('de')}
className={`p-1.5 rounded-lg transition-all ${locale === 'de' className={`p-1.5 rounded-lg transition-all ${locale === 'de'
? 'bg-orange-950/30 scale-110 shadow-sm shadow-orange-950/20' ? 'bg-orange-950/30 scale-110 shadow-xs shadow-orange-950/20'
: 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0' : 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0'
}`} }`}
title="Deutsch" title="Deutsch"
@@ -21,7 +21,7 @@ const LanguageSwitcher = () => {
<button <button
onClick={() => setLocale('en')} onClick={() => setLocale('en')}
className={`p-1.5 rounded-lg transition-all ${locale === 'en' className={`p-1.5 rounded-lg transition-all ${locale === 'en'
? 'bg-orange-950/30 scale-110 shadow-sm shadow-orange-950/20' ? 'bg-orange-950/30 scale-110 shadow-xs shadow-orange-950/20'
: 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0' : 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0'
}`} }`}
title="English" title="English"

View File

@@ -0,0 +1,277 @@
'use client';
/**
* Native OCR Scanner Component
*
* Uses the Shape Detection API (TextDetector) for zero-latency,
* zero-download OCR directly from the camera stream.
*
* Only works on Android/Chrome/Edge. iOS uses the Live Text fallback.
*/
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { X, Camera, Loader2, Zap, CheckCircle } from 'lucide-react';
import { useScanFlow } from '@/hooks/useScanFlow';
import { normalizeDistillery } from '@/lib/distillery-matcher';
interface NativeOCRScannerProps {
isOpen: boolean;
onClose: () => void;
onTextDetected: (texts: string[]) => void;
onAutoCapture?: (result: {
rawTexts: string[];
distillery: string | null;
abv: number | null;
age: number | null;
}) => void;
}
// RegEx patterns for auto-extraction
const PATTERNS = {
abv: /(\d{1,2}[.,]\d{1}|\d{1,2})\s*%\s*(?:vol|alc)?/i,
age: /(\d{1,2})\s*(?:years?|yo|y\.?o\.?|jahre?)\s*(?:old)?/i,
};
export default function NativeOCRScanner({
isOpen,
onClose,
onTextDetected,
onAutoCapture
}: NativeOCRScannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const animationRef = useRef<number | null>(null);
const { processVideoFrame } = useScanFlow();
const [isStreaming, setIsStreaming] = useState(false);
const [detectedTexts, setDetectedTexts] = useState<string[]>([]);
const [extractedData, setExtractedData] = useState<{
distillery: string | null;
abv: number | null;
age: number | null;
}>({ distillery: null, abv: null, age: null });
const [isAutoCapturing, setIsAutoCapturing] = useState(false);
// Start camera stream
const startStream = useCallback(async () => {
try {
console.log('[NativeOCR] Starting camera stream...');
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
setIsStreaming(true);
console.log('[NativeOCR] Camera stream started');
}
} catch (err) {
console.error('[NativeOCR] Camera access failed:', err);
}
}, []);
// Stop camera stream
const stopStream = useCallback(() => {
console.log('[NativeOCR] Stopping camera stream...');
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setIsStreaming(false);
setDetectedTexts([]);
}, []);
// Process frames continuously
const processLoop = useCallback(async () => {
if (!videoRef.current || !isStreaming) return;
const texts = await processVideoFrame(videoRef.current);
if (texts.length > 0) {
setDetectedTexts(texts);
onTextDetected(texts);
// Try to extract structured data
const allText = texts.join(' ');
// ABV
const abvMatch = allText.match(PATTERNS.abv);
const abv = abvMatch ? parseFloat(abvMatch[1].replace(',', '.')) : null;
// Age
const ageMatch = allText.match(PATTERNS.age);
const age = ageMatch ? parseInt(ageMatch[1], 10) : null;
// Distillery (fuzzy match)
let distillery: string | null = null;
for (const text of texts) {
if (text.length >= 4 && text.length <= 40) {
const match = normalizeDistillery(text);
if (match.matched) {
distillery = match.name;
break;
}
}
}
setExtractedData({ distillery, abv, age });
// Auto-capture if we have enough data
if (distillery && (abv || age) && !isAutoCapturing) {
console.log('[NativeOCR] Auto-capture triggered:', { distillery, abv, age });
setIsAutoCapturing(true);
if (onAutoCapture) {
onAutoCapture({
rawTexts: texts,
distillery,
abv,
age,
});
}
// Visual feedback before closing
setTimeout(() => {
onClose();
}, 1500);
}
}
// Continue loop (throttled to ~5 FPS for performance)
animationRef.current = window.setTimeout(() => {
requestAnimationFrame(processLoop);
}, 200) as unknown as number;
}, [isStreaming, processVideoFrame, onTextDetected, onAutoCapture, isAutoCapturing, onClose]);
// Start/stop based on isOpen
useEffect(() => {
if (isOpen) {
startStream();
} else {
stopStream();
}
return () => {
stopStream();
};
}, [isOpen, startStream, stopStream]);
// Start processing loop when streaming
useEffect(() => {
if (isStreaming) {
processLoop();
}
}, [isStreaming, processLoop]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 bg-black">
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4 bg-linear-to-b from-black/80 to-transparent">
<div className="flex items-center gap-2 text-white">
<Zap size={20} className="text-orange-500" />
<span className="font-bold text-sm">Native OCR</span>
</div>
<button
onClick={onClose}
className="p-2 rounded-full bg-white/10 text-white hover:bg-white/20"
>
<X size={24} />
</button>
</div>
{/* Video Feed */}
<video
ref={videoRef}
playsInline
muted
className="w-full h-full object-cover"
/>
{/* Scan Overlay */}
<div className="absolute inset-0 pointer-events-none">
{/* Scan Frame */}
<div className="absolute inset-[10%] border-2 border-orange-500/50 rounded-2xl">
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-orange-500 rounded-tl-xl" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-orange-500 rounded-tr-xl" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-orange-500 rounded-bl-xl" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-orange-500 rounded-br-xl" />
</div>
{/* Scanning indicator */}
{isStreaming && !isAutoCapturing && (
<div className="absolute top-[12%] left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-black/60 rounded-full text-white text-sm">
<Loader2 size={16} className="animate-spin text-orange-500" />
Scanning...
</div>
)}
{/* Auto-capture success */}
{isAutoCapturing && (
<div className="absolute top-[12%] left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-green-600 rounded-full text-white text-sm">
<CheckCircle size={16} />
Captured!
</div>
)}
</div>
{/* Detected Text Display */}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-linear-to-t from-black/90 to-transparent">
{extractedData.distillery && (
<div className="mb-2 px-3 py-1 bg-orange-600 rounded-full inline-block">
<span className="text-white text-sm font-bold">
🏭 {extractedData.distillery}
</span>
</div>
)}
<div className="flex gap-2 flex-wrap mb-2">
{extractedData.abv && (
<span className="px-2 py-1 bg-white/20 rounded-sm text-white text-xs">
{extractedData.abv}% ABV
</span>
)}
{extractedData.age && (
<span className="px-2 py-1 bg-white/20 rounded-sm text-white text-xs">
{extractedData.age} Years
</span>
)}
</div>
{detectedTexts.length > 0 && (
<div className="max-h-20 overflow-y-auto">
<p className="text-zinc-400 text-xs">
{detectedTexts.slice(0, 5).join(' • ')}
</p>
</div>
)}
{!detectedTexts.length && isStreaming && (
<p className="text-zinc-500 text-sm text-center">
Point camera at the bottle label
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import Link from 'next/link';
import { ReactNode } from 'react';
interface NavButtonProps {
icon: ReactNode;
label: string;
href: string;
badge?: number;
}
export default function NavButton({ icon, label, href, badge }: NavButtonProps) {
return (
<Link
href={href}
className="flex flex-col items-center justify-center gap-1.5 p-3 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 hover:border-zinc-700 rounded-xl transition-all active:scale-95"
>
<div className="relative text-zinc-400">
{icon}
{badge !== undefined && badge > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-orange-600 rounded-full text-[8px] font-black text-white flex items-center justify-center">
{badge > 9 ? '9+' : badge}
</span>
)}
</div>
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-wide">
{label}
</span>
</Link>
);
}

View File

@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react'; import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useAuth } from '@/context/AuthContext';
const ONBOARDING_KEY = 'dramlog_onboarding_complete'; const ONBOARDING_KEY = 'dramlog_onboarding_complete';
@@ -50,12 +51,22 @@ const getSteps = (t: (path: string) => string): OnboardingStep[] => [
export default function OnboardingTutorial() { export default function OnboardingTutorial() {
const { t } = useI18n(); const { t } = useI18n();
const { user, isLoading } = useAuth();
const STEPS = getSteps(t); const STEPS = getSteps(t);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [currentStep, setCurrentStep] = useState(0); const [currentStep, setCurrentStep] = useState(0);
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
// Don't show if auth is still loading
if (isLoading) return;
// Don't show if no user is logged in
if (!user) {
setIsOpen(false);
return;
}
// Don't show on login/auth pages // Don't show on login/auth pages
if (pathname === '/login' || pathname === '/auth' || pathname === '/register') { if (pathname === '/login' || pathname === '/auth' || pathname === '/register') {
return; return;
@@ -99,7 +110,7 @@ export default function OnboardingTutorial() {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] bg-black/90 backdrop-blur-sm flex items-center justify-center p-6" className="fixed inset-0 z-200 bg-black/90 backdrop-blur-xs flex items-center justify-center p-6"
> >
{/* Close button */} {/* Close button */}
<button <button

View File

@@ -76,7 +76,7 @@ export default function PasswordChangeForm() {
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
className="w-full px-4 py-3 pr-12 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" className="w-full px-4 py-3 pr-12 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/> />
<button <button
type="button" type="button"
@@ -98,7 +98,7 @@ export default function PasswordChangeForm() {
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/> />
</div> </div>
</div> </div>

View File

@@ -129,7 +129,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Actions Bar */} {/* Actions Bar */}
<div className="bg-zinc-900 rounded-2xl p-6 border border-zinc-800 shadow-sm"> <div className="bg-zinc-900 rounded-2xl p-6 border border-zinc-800 shadow-xs">
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={handleCreate} onClick={handleCreate}
@@ -167,10 +167,10 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
className={`bg-zinc-900 rounded-[32px] p-6 border-2 ${plan.is_active className={`bg-zinc-900 rounded-[32px] p-6 border-2 ${plan.is_active
? 'border-orange-500/30' ? 'border-orange-500/30'
: 'border-zinc-800 opacity-60' : 'border-zinc-800 opacity-60'
} shadow-sm relative`} } shadow-xs relative`}
> >
{!plan.is_active && ( {!plan.is_active && (
<div className="absolute top-4 right-4 px-2 py-1 bg-zinc-800 text-zinc-400 text-[8px] font-bold uppercase tracking-widest rounded"> <div className="absolute top-4 right-4 px-2 py-1 bg-zinc-800 text-zinc-400 text-[8px] font-bold uppercase tracking-widest rounded-sm">
Inactive Inactive
</div> </div>
)} )}
@@ -210,7 +210,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
{/* Edit/Create Modal */} {/* Edit/Create Modal */}
{(editingPlan || isCreating) && ( {(editingPlan || isCreating) && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 z-50">
<div className="bg-zinc-900 rounded-[32px] p-6 max-w-2xl w-full border border-zinc-800 shadow-2xl"> <div className="bg-zinc-900 rounded-[32px] p-6 max-w-2xl w-full border border-zinc-800 shadow-2xl">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-white uppercase tracking-tighter"> <h3 className="text-2xl font-bold text-white uppercase tracking-tighter">
@@ -233,7 +233,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. starter" placeholder="e.g. starter"
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div> <div>
@@ -243,7 +243,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
value={formData.display_name} value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
placeholder="e.g. Starter" placeholder="e.g. Starter"
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
</div> </div>
@@ -255,7 +255,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
type="number" type="number"
value={formData.monthly_credits} value={formData.monthly_credits}
onChange={(e) => setFormData({ ...formData, monthly_credits: parseInt(e.target.value) || 0 })} onChange={(e) => setFormData({ ...formData, monthly_credits: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div> <div>
@@ -265,7 +265,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
step="0.01" step="0.01"
value={formData.price} value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
</div> </div>
@@ -277,7 +277,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Brief description of the plan" placeholder="Brief description of the plan"
rows={3} rows={3}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
@@ -288,7 +288,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
type="number" type="number"
value={formData.sort_order} value={formData.sort_order}
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })} onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
@@ -297,7 +297,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
type="checkbox" type="checkbox"
checked={formData.is_active} checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })} onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="w-5 h-5 rounded border-zinc-700 bg-zinc-800 text-orange-600 focus:ring-orange-600" className="w-5 h-5 rounded-sm border-zinc-700 bg-zinc-800 text-orange-600 focus:ring-orange-600"
/> />
<span className="text-sm font-bold text-white">Active</span> <span className="text-sm font-bold text-white">Active</span>
</label> </label>

View File

@@ -78,7 +78,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder={t('bottle.nameLabel')} placeholder={t('bottle.nameLabel')}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,34 @@
'use client';
import NavButton from './NavButton';
import { Calendar, Users, BarChart3, Heart } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
export default function QuickActionsGrid() {
const { t } = useI18n();
return (
<div className="grid grid-cols-4 gap-3">
<NavButton
icon={<Calendar size={22} />}
label={t('nav.sessions') || 'Events'}
href="/sessions"
/>
<NavButton
icon={<Users size={22} />}
label={t('nav.buddies') || 'Buddies'}
href="/buddies"
/>
<NavButton
icon={<BarChart3 size={22} />}
label={t('nav.stats') || 'Stats'}
href="/stats"
/>
<NavButton
icon={<Heart size={22} />}
label={t('nav.wishlist') || 'Wishlist'}
href="/wishlist"
/>
</div>
);
}

View File

@@ -38,7 +38,7 @@ export default function ResultCard({ data, bottleName, image, onShare }: ResultC
className="flex flex-col items-center gap-6 w-full max-w-sm" className="flex flex-col items-center gap-6 w-full max-w-sm"
> >
{/* The Trading Card */} {/* The Trading Card */}
<div className="relative w-full aspect-[3/4] rounded-[32px] overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.9)] border border-zinc-800 bg-zinc-950 group"> <div className="relative w-full aspect-3/4 rounded-[32px] overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.9)] border border-zinc-800 bg-zinc-950 group">
{/* Bottle Image with Vignette */} {/* Bottle Image with Vignette */}
<div className="absolute inset-0"> <div className="absolute inset-0">
{image ? ( {image ? (
@@ -46,7 +46,7 @@ export default function ResultCard({ data, bottleName, image, onShare }: ResultC
) : ( ) : (
<div className="absolute inset-0 bg-zinc-900 flex items-center justify-center opacity-40 text-[20px] font-bold uppercase tracking-[1em] rotate-90">No Data</div> <div className="absolute inset-0 bg-zinc-900 flex items-center justify-center opacity-40 text-[20px] font-bold uppercase tracking-[1em] rotate-90">No Data</div>
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/20 to-transparent opacity-90" /> <div className="absolute inset-0 bg-linear-to-t from-zinc-950 via-zinc-950/20 to-transparent opacity-90" />
</div> </div>
{/* Content Overlay */} {/* Content Overlay */}

View File

@@ -15,6 +15,7 @@ import { useI18n } from '@/i18n/I18nContext';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useScanner, ScanStatus } from '@/hooks/useScanner'; import { useScanner, ScanStatus } from '@/hooks/useScanner';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { useImageProcessor } from '@/hooks/useImageProcessor';
type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR'; type FlowState = 'IDLE' | 'SCANNING' | 'EDITOR' | 'RESULT' | 'ERROR';
@@ -40,12 +41,13 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
const [isEnriching, setIsEnriching] = useState(false); const [isEnriching, setIsEnriching] = useState(false);
const [aiFallbackActive, setAiFallbackActive] = useState(false); const [aiFallbackActive, setAiFallbackActive] = useState(false);
const [pendingTastingData, setPendingTastingData] = useState<any>(null); const [pendingTastingData, setPendingTastingData] = useState<any>(null);
const { addToQueue } = useImageProcessor();
// Use the Gemini-only scanner hook // Use the AI-powered scanner hook
const scanner = useScanner({ const scanner = useScanner({
locale, locale,
onComplete: (cloudResult) => { onComplete: (cloudResult) => {
console.log('[ScanFlow] Gemini complete:', cloudResult); console.log('[ScanFlow] Gemma complete:', cloudResult);
setBottleMetadata(cloudResult); setBottleMetadata(cloudResult);
// Trigger background enrichment if we have name and distillery // Trigger background enrichment if we have name and distillery
@@ -202,9 +204,15 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
const bottleId = bottleResult.data.id; const bottleId = bottleResult.data.id;
// Queue for background removal
if (scanner.processedImage?.file) {
addToQueue(bottleId, scanner.processedImage.file);
}
const tastingNote = { const tastingNote = {
...formData, ...formData,
bottle_id: bottleId, bottle_id: bottleId,
session_id: activeSession?.id,
}; };
const tastingResult = await saveTasting(tastingNote); const tastingResult = await saveTasting(tastingNote);
@@ -264,6 +272,11 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
locale, locale,
metadata: bottleDataToSave as any metadata: bottleDataToSave as any
}); });
// Queue for background removal using temp_id
if (scanner.processedImage?.file) {
addToQueue(tempId, scanner.processedImage.file);
}
} }
await db.pending_tastings.add({ await db.pending_tastings.add({
@@ -341,12 +354,12 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-[60] bg-zinc-950 flex flex-col h-[100dvh] w-screen overflow-hidden overscroll-none" className="fixed inset-0 z-60 bg-zinc-950 flex flex-col h-dvh w-screen overflow-hidden overscroll-none"
> >
{/* Close Button */} {/* Close Button */}
<button <button
onClick={onClose} onClick={onClose}
className="absolute top-6 right-6 z-[70] p-2 rounded-full bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white transition-colors" className="absolute top-6 right-6 z-70 p-2 rounded-full bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white transition-colors"
> >
<X size={24} /> <X size={24} />
</button> </button>
@@ -507,7 +520,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
className="absolute inset-0 z-[80] bg-zinc-950/80 backdrop-blur-sm flex flex-col items-center justify-center gap-6" className="absolute inset-0 z-80 bg-zinc-950/80 backdrop-blur-xs flex flex-col items-center justify-center gap-6"
> >
<Loader2 size={48} className="animate-spin text-orange-600" /> <Loader2 size={48} className="animate-spin text-orange-600" />
<h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight"> <h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight">

View File

@@ -0,0 +1,48 @@
'use client';
import { useEffect } from 'react';
import * as Sentry from '@sentry/nextjs';
export default function SentryInit() {
useEffect(() => {
const dsn = process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
console.log('[Sentry Debug] NEXT_PUBLIC_GLITCHTIP_DSN:', dsn ? dsn.substring(0, 40) + '...' : 'NOT SET');
if (!dsn) {
console.warn('[Sentry] Client disabled - no DSN configured');
return;
}
try {
// Check if already initialized
const existingClient = Sentry.getClient();
if (existingClient) {
console.log('[Sentry] Already initialized, skipping');
return;
}
Sentry.init({
dsn,
environment: process.env.NODE_ENV || 'development',
sampleRate: 1.0,
tracesSampleRate: 0.1,
tunnel: '/api/glitchtip-tunnel',
debug: true,
beforeSend(event) {
console.log('[Sentry] Sending event:', event.event_id);
return event;
},
});
console.log('[Sentry] ✅ Client initialized successfully');
// Test that it works
console.log('[Sentry] Client:', Sentry.getClient() ? 'OK' : 'FAILED');
} catch (err) {
console.error('[Sentry] Initialization error:', err);
}
}, []);
return null;
}

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import React from 'react'; import React from 'react';
import { Activity, AlertCircle, TrendingUp, Zap } from 'lucide-react'; import { Activity, AlertCircle, CheckCircle, Zap, TrendingUp } from 'lucide-react';
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from 'recharts';
interface ABVTasting { interface ABVTasting {
id: string; id: string;
@@ -16,116 +17,121 @@ interface SessionABVCurveProps {
export default function SessionABVCurve({ tastings }: SessionABVCurveProps) { export default function SessionABVCurve({ tastings }: SessionABVCurveProps) {
if (!tastings || tastings.length < 2) { if (!tastings || tastings.length < 2) {
return ( return (
<div className="p-6 bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border border-dashed border-zinc-200 dark:border-zinc-800 text-center"> <div className="p-8 bg-zinc-900 rounded-3xl border border-dashed border-zinc-800 text-center">
<Activity size={24} className="mx-auto text-zinc-300 mb-2" /> <Activity size={32} className="mx-auto text-zinc-700 mb-3" />
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest">Kurve wird ab 2 Drams berechnet</p> <p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest leading-relaxed">
Kurve wird ab 2 Drams berechnet
</p>
</div> </div>
); );
} }
const sorted = [...tastings].sort((a, b) => new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime()); const data = [...tastings]
.sort((a, b) => new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime())
.map((t: ABVTasting, i: number) => ({
name: `Dram ${i + 1}`,
abv: t.abv,
timestamp: new Date(t.tasted_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
id: t.id
}));
// Normalize data: Y-axis is ABV (say 40-65 range), X-axis is time or just sequence index const hasBigJump = tastings.some((t: ABVTasting, i: number) => i > 0 && Math.abs(t.abv - tastings[i - 1].abv) > 10);
const minAbv = Math.min(...sorted.map(t => t.abv)); const avgAbv = (tastings.reduce((acc: number, t: ABVTasting) => acc + t.abv, 0) / tastings.length).toFixed(1);
const maxAbv = Math.max(...sorted.map(t => t.abv));
const range = Math.max(maxAbv - minAbv, 10); // at least 10 point range for scale
// SVG Dimensions
const width = 400;
const height = 150;
const padding = 20;
const getX = (index: number) => padding + (index * (width - 2 * padding) / (sorted.length - 1));
const getY = (abv: number) => {
const normalized = (abv - (minAbv - 2)) / (range + 4);
return height - padding - (normalized * (height - 2 * padding));
};
const points = sorted.map((t, i) => `${getX(i)},${getY(t.abv)}`).join(' ');
// Check for dangerous slope (sudden high ABV jump)
const hasBigJump = sorted.some((t, i) => i > 0 && t.abv - sorted[i - 1].abv > 10);
return ( return (
<div className="bg-zinc-900 rounded-3xl p-5 border border-white/5 shadow-2xl overflow-hidden relative group"> <div className="bg-zinc-900 rounded-3xl p-6 border border-white/5 shadow-2xl relative group overflow-hidden">
<div className="flex items-center justify-between mb-4"> {/* Header */}
<div className="flex items-center gap-2"> <div className="flex items-center justify-between mb-8">
<TrendingUp size={16} className="text-amber-500" /> <div className="flex items-center gap-3">
<div className="p-2 bg-orange-500/10 rounded-xl">
<TrendingUp size={18} className="text-orange-500" />
</div>
<div> <div>
<h4 className="text-[10px] font-black text-zinc-500 uppercase tracking-widest leading-none">ABV Kurve (Session)</h4> <h4 className="text-[10px] font-black text-zinc-500 uppercase tracking-widest leading-none mb-1">ABV Progression</h4>
<p className="text-[8px] text-zinc-600 font-bold uppercase tracking-tighter">Alcohol By Volume Progression</p> <p className="text-[8px] text-zinc-600 font-bold uppercase tracking-tighter">Alcohol By Volume Intensity</p>
</div> </div>
</div> </div>
{hasBigJump && ( {hasBigJump && (
<div className="flex items-center gap-1.5 px-2 py-1 bg-red-500/10 border border-red-500/20 rounded-lg animate-pulse"> <div className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 border border-red-500/20 rounded-full">
<AlertCircle size={10} className="text-red-500" /> <AlertCircle size={10} className="text-red-500" />
<span className="text-[8px] font-black text-red-500 uppercase tracking-tighter">Zick-Zack Gefahr</span> <span className="text-[8px] font-black text-red-500 uppercase tracking-widest">Spike Alert</span>
</div> </div>
)} )}
</div> </div>
<div className="relative h-[150px] w-full"> {/* Chart Container */}
{/* Grid Lines */} <div className="h-[180px] w-full -ml-4">
<div className="absolute inset-0 flex flex-col justify-between opacity-10 pointer-events-none"> <ResponsiveContainer width="100%" height="100%">
{[1, 2, 3, 4].map(i => <div key={i} className="border-t border-white" />)} <AreaChart data={data}>
</div>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full drop-shadow-[0_0_15px_rgba(217,119,6,0.2)]">
{/* Gradient under line */}
<defs> <defs>
<linearGradient id="curveGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="abvGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#d97706" stopOpacity="0.4" /> <stop offset="5%" stopColor="#ea580c" stopOpacity={0.3} />
<stop offset="100%" stopColor="#d97706" stopOpacity="0" /> <stop offset="95%" stopColor="#ea580c" stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#ffffff05" />
<path <XAxis
d={`M ${getX(0)} ${height} L ${points} L ${getX(sorted.length - 1)} ${height} Z`} dataKey="name"
fill="url(#curveGradient)" hide
/> />
<YAxis
<polyline domain={['dataMin - 5', 'dataMax + 5']}
points={points} hide
fill="none"
stroke="#d97706"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-all duration-700 ease-out"
/> />
<Tooltip
{sorted.map((t, i) => ( content={({ active, payload }) => {
<g key={t.id} className="group/dot"> if (active && payload && payload.length) {
<circle return (
cx={getX(i)} <div className="bg-zinc-950 border border-white/10 p-3 rounded-2xl shadow-2xl backdrop-blur-xl">
cy={getY(t.abv)} <p className="text-[10px] font-black text-zinc-500 uppercase tracking-widest mb-1">
r="4" {payload[0].payload.name} {payload[0].payload.timestamp}
fill="#d97706" </p>
className="transition-all hover:r-6 cursor-help" <p className="text-xl font-black text-white">
{payload[0].value}% <span className="text-[10px] text-zinc-500">ABV</span>
</p>
</div>
);
}
return null;
}}
/> />
<text <Area
x={getX(i)} type="monotone"
y={getY(t.abv) - 10} dataKey="abv"
textAnchor="middle" stroke="#ea580c"
className="text-[8px] fill-zinc-400 font-black opacity-0 group-hover/dot:opacity-100 transition-opacity" strokeWidth={3}
> fillOpacity={1}
{t.abv}% fill="url(#abvGradient)"
</text> animationDuration={1500}
</g> />
))} </AreaChart>
</svg> </ResponsiveContainer>
</div> </div>
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-white/5 pt-4"> {/* Stats Footer */}
<div className="flex flex-col"> <div className="mt-6 grid grid-cols-2 gap-4 border-t border-white/5 pt-6">
<span className="text-[8px] font-black text-zinc-500 uppercase tracking-widest">Ø Alkohol</span> <div className="flex flex-col gap-1">
<span className="text-sm font-black text-white">{(sorted.reduce((acc, t) => acc + t.abv, 0) / sorted.length).toFixed(1)}%</span> <span className="text-[9px] font-black text-zinc-500 uppercase tracking-[0.2em]">Ø Intensity</span>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-black text-white tracking-tighter">{avgAbv}</span>
<span className="text-[10px] font-bold text-zinc-500 uppercase">%</span>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className="text-[9px] font-black text-zinc-500 uppercase tracking-[0.2em]">Flow State</span>
<div className="flex items-center gap-2">
{hasBigJump ? (
<>
<Zap size={14} className="text-red-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-red-500">Aggressive</span>
</>
) : (
<>
<CheckCircle size={14} className="text-green-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-green-500">Smooth</span>
</>
)}
</div> </div>
<div className="flex flex-col items-end">
<span className="text-[8px] font-black text-zinc-500 uppercase tracking-widest">Status</span>
<span className={`text-[10px] font-black uppercase tracking-widest ${hasBigJump ? 'text-red-500' : 'text-green-500'}`}>
{hasBigJump ? 'Instabil' : 'Optimal'}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -76,7 +76,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
onClick={onClose} onClick={onClose}
className="fixed inset-0 bg-zinc-950/80 backdrop-blur-sm z-[80]" className="fixed inset-0 bg-zinc-950/80 backdrop-blur-xs z-80"
/> />
{/* Sheet */} {/* Sheet */}
@@ -85,7 +85,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
animate={{ y: 0 }} animate={{ y: 0 }}
exit={{ y: '100%' }} exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }} transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed bottom-0 left-0 right-0 bg-[var(--background)] border-t border-white/5 rounded-t-[40px] z-[90] p-8 pb-12 max-h-[85vh] overflow-y-auto shadow-[0_-20px_60px_rgba(0,0,0,0.8)] ring-1 ring-white/5" className="fixed bottom-0 left-0 right-0 bg-(--background) border-t border-white/5 rounded-t-[40px] z-90 p-8 pb-12 max-h-[85vh] overflow-y-auto shadow-[0_-20px_60px_rgba(0,0,0,0.8)] ring-1 ring-white/5"
> >
{/* Drag Handle */} {/* Drag Handle */}
<div className="w-10 h-1 bg-white/10 rounded-full mx-auto mb-8" /> <div className="w-10 h-1 bg-white/10 rounded-full mx-auto mb-8" />
@@ -100,7 +100,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
onChange={(e) => setNewSessionName(e.target.value)} onChange={(e) => setNewSessionName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateSession()} onKeyDown={(e) => e.key === 'Enter' && handleCreateSession()}
placeholder="Neue Session erstellen..." placeholder="Neue Session erstellen..."
className="w-full bg-zinc-900 border border-zinc-800 rounded-2xl py-4 px-6 text-zinc-50 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-900 border border-zinc-800 rounded-2xl py-4 px-6 text-zinc-50 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
<button <button
onClick={handleCreateSession} onClick={handleCreateSession}

View File

@@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users, Check, Trash2, ChevronDown, ChevronUp } from 'lucide-react'; import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users, Check, Trash2, ChevronDown, ChevronUp, Play, Sparkles } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import AvatarStack from './AvatarStack'; import AvatarStack from './AvatarStack';
import { deleteSession } from '@/services/delete-session'; import { deleteSession } from '@/services/delete-session';
@@ -170,7 +170,7 @@ export default function SessionList() {
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t('session.sessionName')} placeholder={t('session.sessionName')}
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-none focus:border-orange-600 transition-colors" className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
<button <button
type="submit" type="submit"
@@ -182,45 +182,52 @@ export default function SessionList() {
</form> </form>
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-8 text-zinc-500"> <div className="flex justify-center py-12 text-zinc-700">
<Loader2 size={24} className="animate-spin" /> <Loader2 size={32} className="animate-spin" />
</div> </div>
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-12 bg-zinc-950/50 rounded-[32px] border border-dashed border-zinc-800">
<div className="w-14 h-14 mx-auto rounded-2xl bg-zinc-800/50 flex items-center justify-center mb-4"> <div className="w-16 h-16 mx-auto rounded-full bg-zinc-900 flex items-center justify-center mb-6 border border-white/5 shadow-inner">
<Calendar size={24} className="text-zinc-500" /> <Calendar size={28} className="text-zinc-700" />
</div> </div>
<p className="text-sm font-bold text-zinc-400 mb-1">Keine Sessions</p> <p className="text-sm font-black text-zinc-400 mb-2 uppercase tracking-widest">{t('session.noSessions') || 'Keine Sessions'}</p>
<p className="text-xs text-zinc-600 max-w-[200px] mx-auto"> <p className="text-[10px] text-zinc-600 font-bold uppercase tracking-tight max-w-[200px] mx-auto leading-relaxed">
Erstelle eine Tasting-Session um mehrere Whiskys zu vergleichen Erstelle eine Tasting-Session um deine Drams zeitlich zu ordnen.
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-4">
{sessions.map((session) => ( {sessions.map((session) => (
<div <div
key={session.id} key={session.id}
className={`flex items-center justify-between p-4 rounded-2xl border transition-all ${activeSession?.id === session.id className={`group relative flex items-center justify-between p-5 rounded-[28px] border transition-all duration-500 overflow-hidden ${activeSession?.id === session.id
? 'bg-orange-600 border-orange-600 shadow-lg shadow-orange-950/20' ? 'bg-orange-500/3 border-orange-500/40 shadow-[0_0_40px_rgba(234,88,12,0.1)]'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700' : 'bg-zinc-950/50 border-white/5 hover:border-white/10'
}`} }`}
> >
<Link href={`/sessions/${session.id}`} className="flex-1 space-y-1 min-w-0"> {/* Active Glow Decor */}
<div className={`font-bold text-lg truncate flex items-center gap-2 ${activeSession?.id === session.id ? 'text-white' : 'text-zinc-50'}`}> {activeSession?.id === session.id && (
<div className="absolute top-0 right-0 w-32 h-32 bg-orange-600/10 blur-[60px] -mr-16 -mt-16 pointer-events-none" />
)}
<Link href={`/sessions/${session.id}`} className="flex-1 space-y-2 min-w-0 z-10">
<div className="flex items-center gap-3">
<div className={`font-black text-xl tracking-tight truncate ${activeSession?.id === session.id ? 'text-white' : 'text-zinc-200 group-hover:text-white transition-colors'}`}>
{session.name} {session.name}
</div>
{session.ended_at && ( {session.ended_at && (
<span className={`text-[8px] font-bold uppercase px-1.5 py-0.5 rounded border ${activeSession?.id === session.id ? 'bg-black/10 border-black/20 text-white' : 'bg-zinc-800 border-zinc-700 text-zinc-500'}`}>Closed</span> <span className="text-[8px] font-black uppercase px-2 py-0.5 rounded-full bg-zinc-800/50 border border-zinc-700/50 text-zinc-500 tracking-widest">Archiv</span>
)} )}
</div> </div>
<div className={`flex items-center gap-4 text-[10px] font-bold uppercase tracking-widest ${activeSession?.id === session.id ? 'text-white/60' : 'text-zinc-500'}`}> <div className={`flex items-center gap-5 text-[10px] font-black uppercase tracking-[0.15em] ${activeSession?.id === session.id ? 'text-orange-500/80' : 'text-zinc-500'}`}>
<span className="flex items-center gap-1"> <span className="flex items-center gap-2">
<Calendar size={12} /> <Calendar size={13} strokeWidth={2.5} />
{new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} {new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
</span> </span>
{session.whisky_count! > 0 && ( {session.whisky_count! > 0 && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-2">
<GlassWater size={12} /> <GlassWater size={13} strokeWidth={2.5} />
{session.whisky_count} Whiskys {session.whisky_count}
</span> </span>
)} )}
</div> </div>
@@ -230,34 +237,37 @@ export default function SessionList() {
</div> </div>
)} )}
</Link> </Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 z-10">
{activeSession?.id !== session.id ? ( {activeSession?.id !== session.id ? (
!session.ended_at ? ( !session.ended_at ? (
<button <button
onClick={() => setActiveSession({ id: session.id, name: session.name })} onClick={(e) => {
className="p-2 bg-zinc-800 text-zinc-50 rounded-xl hover:bg-orange-600 hover:text-white transition-all" e.preventDefault();
setActiveSession({ id: session.id, name: session.name });
}}
className="p-3 text-zinc-600 hover:text-orange-500 transition-all hover:scale-110 active:scale-95"
title="Start Session" title="Start Session"
> >
<GlassWater size={18} /> <Play size={22} fill="currentColor" className="opacity-40" />
</button> </button>
) : ( ) : (
<div className="p-2 bg-zinc-900 text-zinc-500 rounded-xl border border-zinc-800 opacity-50"> <div className="p-3 text-zinc-800">
<Check size={18} /> <Check size={20} />
</div> </div>
) )
) : ( ) : (
<div className="p-2 bg-black/10 text-white rounded-xl"> <div className="p-3 text-orange-500 animate-pulse">
<Check size={18} /> <Sparkles size={20} />
</div> </div>
)} )}
<ChevronRight size={20} className={activeSession?.id === session.id ? 'text-white/40' : 'text-zinc-700'} />
<div className="w-px h-8 bg-white/5 mx-1" />
<button <button
onClick={(e) => handleDeleteSession(e, session.id)} onClick={(e) => handleDeleteSession(e, session.id)}
disabled={!!isDeleting} disabled={!!isDeleting}
className={`p-2 rounded-xl transition-all ${activeSession?.id === session.id className="p-3 text-zinc-700 hover:text-red-500 transition-all opacity-0 group-hover:opacity-100"
? 'text-white/40 hover:text-white'
: 'text-zinc-600 hover:text-red-500'
}`}
title="Session löschen" title="Session löschen"
> >
{isDeleting === session.id ? ( {isDeleting === session.id ? (
@@ -266,6 +276,7 @@ export default function SessionList() {
<Trash2 size={18} /> <Trash2 size={18} />
)} )}
</button> </button>
<ChevronRight size={20} className="text-zinc-800 group-hover:text-zinc-600 transition-colors" />
</div> </div>
</div> </div>
))} ))}
@@ -278,12 +289,12 @@ export default function SessionList() {
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1"> <div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
<div className="flex -space-x-1.5 overflow-hidden"> <div className="flex -space-x-1.5 overflow-hidden">
{sessions.slice(0, 3).map((s, i) => ( {sessions.slice(0, 3).map((s, i) => (
<div key={s.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-sm"> <div key={s.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-xs">
{s.name[0].toUpperCase()} {s.name[0].toUpperCase()}
</div> </div>
))} ))}
{sessions.length > 3 && ( {sessions.length > 3 && (
<div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-sm"> <div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-xs">
+{sessions.length - 3} +{sessions.length - 3}
</div> </div>
)} )}

View File

@@ -17,12 +17,14 @@ interface TimelineTasting {
interface SessionTimelineProps { interface SessionTimelineProps {
tastings: TimelineTasting[]; tastings: TimelineTasting[];
sessionStart?: string; sessionStart?: string;
isBlind?: boolean;
isRevealed?: boolean;
} }
// Keywords that indicate a "Peat Bomb" // Keywords that indicate a "Peat Bomb"
const SMOKY_KEYWORDS = ['rauch', 'torf', 'smoke', 'peat', 'islay', 'ash', 'lagerfeuer', 'campfire', 'asphalte']; const SMOKY_KEYWORDS = ['rauch', 'torf', 'smoke', 'peat', 'islay', 'ash', 'lagerfeuer', 'campfire', 'asphalte'];
export default function SessionTimeline({ tastings, sessionStart }: SessionTimelineProps) { export default function SessionTimeline({ tastings, sessionStart, isBlind, isRevealed }: SessionTimelineProps) {
if (!tastings || tastings.length === 0) { if (!tastings || tastings.length === 0) {
return ( return (
<div className="p-8 text-center bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border border-dashed border-zinc-200 dark:border-zinc-800"> <div className="p-8 text-center bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border border-dashed border-zinc-200 dark:border-zinc-800">
@@ -51,6 +53,10 @@ export default function SessionTimeline({ tastings, sessionStart }: SessionTimel
const currentTime = tastedDate.getTime(); const currentTime = tastedDate.getTime();
const diffMinutes = Math.round((currentTime - firstTastingTime) / (1000 * 60)); const diffMinutes = Math.round((currentTime - firstTastingTime) / (1000 * 60));
const wallClockTime = tastedDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); const wallClockTime = tastedDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
// Blind Mode logic
const showDetails = !isBlind || isRevealed;
const displayName = showDetails ? tasting.bottle_name : `Sample ${String.fromCharCode(65 + index)}`;
const isSmoky = checkIsSmoky(tasting); const isSmoky = checkIsSmoky(tasting);
const wasPreviousSmoky = index > 0 && checkIsSmoky(sortedTastings[index - 1]); const wasPreviousSmoky = index > 0 && checkIsSmoky(sortedTastings[index - 1]);
@@ -61,11 +67,11 @@ export default function SessionTimeline({ tastings, sessionStart }: SessionTimel
return ( return (
<div key={tasting.id} className="relative group"> <div key={tasting.id} className="relative group">
{/* Dot */} {/* Dot */}
<div className={`absolute -left-[22px] top-4 w-5 h-5 rounded-full border-[3px] border-zinc-950 shadow-sm z-10 flex items-center justify-center ${isSmoky ? 'bg-orange-600' : 'bg-zinc-600'}`}> <div className={`absolute -left-[22px] top-4 w-5 h-5 rounded-full border-[3px] border-zinc-950 shadow-xs z-10 flex items-center justify-center ${isSmoky && showDetails ? 'bg-orange-600' : 'bg-zinc-600'}`}>
{isSmoky && <Droplets size={8} className="text-white fill-white" />} {isSmoky && showDetails && <Droplets size={8} className="text-white fill-white" />}
</div> </div>
<div className="bg-zinc-900 p-4 rounded-2xl border border-zinc-800 shadow-sm hover:shadow-md transition-shadow group-hover:border-orange-500/30"> <div className="bg-zinc-900 p-4 rounded-2xl border border-zinc-800 shadow-xs hover:shadow-md transition-shadow group-hover:border-orange-500/30">
<div className="flex justify-between items-start gap-3"> <div className="flex justify-between items-start gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@@ -73,31 +79,53 @@ export default function SessionTimeline({ tastings, sessionStart }: SessionTimel
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tight leading-none"> <span className="text-[10px] font-bold text-zinc-500 uppercase tracking-tight leading-none">
{wallClockTime} ({index === 0 ? 'Start' : `+${diffMinutes}m`}) {wallClockTime} ({index === 0 ? 'Start' : `+${diffMinutes}m`})
</span> </span>
{isSmoky && ( {isSmoky && showDetails && (
<span className="bg-orange-900/40 text-orange-500 text-[8px] font-bold px-1.5 py-0.5 rounded-md uppercase tracking-tighter border border-orange-500/20">Peat Bomb</span> <span className="bg-orange-900/40 text-orange-500 text-[8px] font-bold px-1.5 py-0.5 rounded-md uppercase tracking-tighter border border-orange-500/20">Peat Bomb</span>
)} )}
</div> </div>
{showDetails ? (
<Link <Link
href={`/bottles/${tasting.bottle_id}`} href={`/bottles/${tasting.bottle_id}`}
className="text-sm font-bold text-zinc-100 hover:text-orange-600 truncate block mt-0.5 uppercase tracking-tight" className="text-sm font-bold text-zinc-100 hover:text-orange-600 truncate block mt-0.5 uppercase tracking-tight"
> >
{tasting.bottle_name} {displayName}
</Link> </Link>
) : (
<div className="text-sm font-bold text-zinc-100 bg-zinc-800/30 blur-xs px-2 py-0.5 rounded-md select-none">
Unknown Bottle
</div>
)}
{!showDetails && (
<div className="mt-1 text-purple-500 font-black uppercase text-[12px] tracking-tight">
{displayName}
</div>
)}
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{tasting.tags.slice(0, 2).map(tag => ( {showDetails ? (
tasting.tags.slice(0, 2).map(tag => (
<span key={tag} className="text-[9px] text-zinc-500 bg-zinc-800/50 px-2 py-0.5 rounded-full border border-zinc-800"> <span key={tag} className="text-[9px] text-zinc-500 bg-zinc-800/50 px-2 py-0.5 rounded-full border border-zinc-800">
{tag} {tag}
</span> </span>
))} ))
) : (
<span className="text-[9px] text-zinc-600 bg-zinc-900 px-2 py-0.5 rounded-full border border-zinc-800 italic">
Noten versteckt...
</span>
)}
</div> </div>
</div> </div>
<div className="shrink-0 flex flex-col items-end"> <div className="shrink-0 flex flex-col items-end">
<div className="text-lg font-bold text-zinc-50 leading-none">{tasting.rating}</div> <div className="text-lg font-bold text-zinc-50 leading-none">
{showDetails ? tasting.rating : '?'}
</div>
<div className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter mt-1">Punkte</div> <div className="text-[9px] font-bold text-zinc-500 uppercase tracking-tighter mt-1">Punkte</div>
</div> </div>
</div> </div>
{wasPreviousSmoky && timeSinceLastDram < 20 && ( {wasPreviousSmoky && timeSinceLastDram < 20 && showDetails && (
<div className="mt-4 p-2 bg-orange-900/10 border border-orange-900/30 rounded-xl flex items-center gap-2 animate-in slide-in-from-top-1"> <div className="mt-4 p-2 bg-orange-900/10 border border-orange-900/30 rounded-xl flex items-center gap-2 animate-in slide-in-from-top-1">
<AlertTriangle size={12} className="text-orange-600 shrink-0" /> <AlertTriangle size={12} className="text-orange-600 shrink-0" />
<p className="text-[9px] text-orange-400 font-bold leading-tight"> <p className="text-[9px] text-orange-400 font-bold leading-tight">

View File

@@ -0,0 +1,102 @@
'use client';
import React from 'react';
// Generic Skeleton components for Suspense fallbacks
export function TastingListSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 animate-pulse"
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-zinc-800 rounded-xl shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-zinc-800 rounded w-3/4" />
<div className="h-3 bg-zinc-800 rounded w-1/2" />
<div className="h-3 bg-zinc-800 rounded w-1/4" />
</div>
<div className="w-10 h-10 bg-zinc-800 rounded-xl" />
</div>
</div>
))}
</div>
);
}
export function BottleDetailsSkeleton() {
return (
<div className="space-y-6 animate-pulse">
{/* Image skeleton */}
<div className="aspect-square bg-zinc-900 rounded-3xl" />
{/* Title skeleton */}
<div className="space-y-2">
<div className="h-8 bg-zinc-800 rounded w-3/4" />
<div className="h-5 bg-zinc-800 rounded w-1/2" />
</div>
{/* Badges skeleton */}
<div className="flex gap-2">
<div className="h-8 w-20 bg-zinc-800 rounded-xl" />
<div className="h-8 w-16 bg-zinc-800 rounded-xl" />
<div className="h-8 w-24 bg-zinc-800 rounded-xl" />
</div>
{/* Stats skeleton */}
<div className="grid grid-cols-2 gap-4">
<div className="h-20 bg-zinc-900 rounded-2xl" />
<div className="h-20 bg-zinc-900 rounded-2xl" />
</div>
</div>
);
}
export function ChartSkeleton({ height = 200 }: { height?: number }) {
return (
<div
className="bg-zinc-900 border border-zinc-800 rounded-2xl animate-pulse flex items-center justify-center"
style={{ height }}
>
<div className="text-zinc-700 text-sm">Loading chart...</div>
</div>
);
}
export function StatsCardSkeleton() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 animate-pulse">
<div className="h-3 bg-zinc-800 rounded w-1/2 mb-2" />
<div className="h-8 bg-zinc-800 rounded w-1/3" />
</div>
);
}
export function SessionTimelineSkeleton() {
return (
<div className="space-y-4 animate-pulse">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<div className="w-3 h-3 bg-zinc-700 rounded-full" />
<div className="flex-1 h-16 bg-zinc-900 border border-zinc-800 rounded-xl" />
</div>
))}
</div>
);
}
export function GridSkeleton({ count = 6 }: { count?: number }) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="aspect-[3/4] bg-zinc-900 border border-zinc-800 rounded-2xl animate-pulse"
/>
))}
</div>
);
}

View File

@@ -56,7 +56,7 @@ export default function SplitCard({ split, isParticipant, onSelect, showChevron
<Package size={10} /> <Package size={10} />
{split.amountCl}cl {split.amountCl}cl
</span> </span>
<span className={`px-1.5 py-0.5 rounded bg-white/5 border border-white/5 ${split.status === 'SHIPPED' ? 'text-green-500' : 'text-zinc-400'}`}> <span className={`px-1.5 py-0.5 rounded-sm bg-white/5 border border-white/5 ${split.status === 'SHIPPED' ? 'text-green-500' : 'text-zinc-400'}`}>
{statusLabels[split.status || ''] || split.status} {statusLabels[split.status || ''] || split.status}
</span> </span>
</> </>

View File

@@ -75,23 +75,23 @@ export default function SplitProgressBar({
{showLabels && ( {showLabels && (
<div className="flex flex-wrap gap-3 text-[10px] font-bold"> <div className="flex flex-wrap gap-3 text-[10px] font-bold">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm bg-zinc-600" /> <div className="w-2.5 h-2.5 rounded-xs bg-zinc-600" />
<span className="text-zinc-500">Host ({hostShare}cl)</span> <span className="text-zinc-500">Host ({hostShare}cl)</span>
</div> </div>
{taken > 0 && ( {taken > 0 && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm bg-orange-600" /> <div className="w-2.5 h-2.5 rounded-xs bg-orange-600" />
<span className="text-zinc-500">Vergeben ({taken}cl)</span> <span className="text-zinc-500">Vergeben ({taken}cl)</span>
</div> </div>
)} )}
{reserved > 0 && ( {reserved > 0 && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm bg-yellow-500" /> <div className="w-2.5 h-2.5 rounded-xs bg-yellow-500" />
<span className="text-zinc-500">Reserviert ({reserved}cl)</span> <span className="text-zinc-500">Reserviert ({reserved}cl)</span>
</div> </div>
)} )}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm bg-green-500" /> <div className="w-2.5 h-2.5 rounded-xs bg-green-500" />
<span className="text-zinc-500">Verfügbar ({available}cl)</span> <span className="text-zinc-500">Verfügbar ({available}cl)</span>
</div> </div>
</div> </div>

View File

@@ -90,7 +90,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Tag suchen oder hinzufügen..." placeholder="Tag suchen oder hinzufügen..."
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl text-[11px] font-medium focus:ring-1 focus:ring-orange-600/50 focus:border-orange-600/50 outline-none transition-all text-zinc-200 placeholder:text-zinc-600" className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl text-[11px] font-medium focus:ring-1 focus:ring-orange-600/50 focus:border-orange-600/50 outline-hidden transition-all text-zinc-200 placeholder:text-zinc-600"
/> />
{isCreating && ( {isCreating && (
<Loader2 className="absolute right-3.5 animate-spin text-orange-600" size={14} /> <Loader2 className="absolute right-3.5 animate-spin text-orange-600" size={14} />
@@ -150,7 +150,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
key={tag.id} key={tag.id}
type="button" type="button"
onClick={() => onToggleTag(tag.id)} onClick={() => onToggleTag(tag.id)}
className="px-3 py-1.5 rounded-xl bg-orange-950/20 text-orange-500 text-[10px] font-black uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-orange-600/20 flex items-center gap-1.5 shadow-sm" className="px-3 py-1.5 rounded-xl bg-orange-950/20 text-orange-500 text-[10px] font-black uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-orange-600/20 flex items-center gap-1.5 shadow-xs"
> >
<Sparkles size={10} /> <Sparkles size={10} />
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name} {tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}

View File

@@ -65,6 +65,12 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]); const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
const [bottlePurchasePrice, setBottlePurchasePrice] = useState(bottleMetadata.purchase_price?.toString() || ''); const [bottlePurchasePrice, setBottlePurchasePrice] = useState(bottleMetadata.purchase_price?.toString() || '');
// Guessing State (Blind Mode)
const [guessAbv, setGuessAbv] = useState<string>('');
const [guessAge, setGuessAge] = useState<string>('');
const [guessRegion, setGuessRegion] = useState<string>('');
const [isSessionBlind, setIsSessionBlind] = useState(false);
// Section collapse states // Section collapse states
const [isNoseExpanded, setIsNoseExpanded] = useState(defaultExpanded); const [isNoseExpanded, setIsNoseExpanded] = useState(defaultExpanded);
const [isPalateExpanded, setIsPalateExpanded] = useState(defaultExpanded); const [isPalateExpanded, setIsPalateExpanded] = useState(defaultExpanded);
@@ -80,7 +86,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
// Track last seen confidence to detect cloud vs local updates // Track last seen confidence to detect cloud vs local updates
const lastConfidenceRef = React.useRef<number>(0); const lastConfidenceRef = React.useRef<number>(0);
// Sync bottleMetadata prop changes to internal state (for live Gemini updates) // Sync bottleMetadata prop changes to internal state (for live AI updates)
// Cloud data (confidence >= 0.6 OR >= 60) overrides local OCR (confidence ~50 or ~0.5) // Cloud data (confidence >= 0.6 OR >= 60) overrides local OCR (confidence ~50 or ~0.5)
useEffect(() => { useEffect(() => {
// Normalize confidence to 0-100 scale (Gemini returns 0-1, local returns 0-100) // Normalize confidence to 0-100 scale (Gemini returns 0-1, local returns 0-100)
@@ -143,6 +149,17 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
setSelectedBuddyIds(participants.map(p => p.buddy_id)); setSelectedBuddyIds(participants.map(p => p.buddy_id));
} }
// Check if session is blind
const { data: sessionData } = await supabase
.from('tasting_sessions')
.select('is_blind')
.eq('id', activeSessionId)
.single();
if (sessionData?.is_blind) {
setIsSessionBlind(true);
}
const { data: lastTastings } = await supabase const { data: lastTastings } = await supabase
.from('tastings') .from('tastings')
.select(` .select(`
@@ -237,6 +254,10 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
is_sample: isSample, is_sample: isSample,
buddy_ids: selectedBuddyIds, buddy_ids: selectedBuddyIds,
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds, ...textureTagIds], tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds, ...textureTagIds],
// Guessing Data
guess_abv: guessAbv ? parseFloat(guessAbv) : null,
guess_age: guessAge ? parseInt(guessAge) : null,
guess_region: guessRegion || null,
// Visual data for ResultCard // Visual data for ResultCard
// Edited bottle metadata // Edited bottle metadata
bottleMetadata: { bottleMetadata: {
@@ -327,60 +348,99 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
{showBottleDetails && ( {showBottleDetails && (
<div className="p-4 pt-0 space-y-3 border-t border-zinc-800/50"> <div className="p-4 pt-0 space-y-3 border-t border-zinc-800/50">
{/* Helper to check field confidence */}
{(() => {
const checkConfidence = (field: string) => {
const scores = bottleMetadata.confidence_scores;
if (!scores) return true; // Default to neutral if no scores
const score = scores[field];
return score === undefined || score >= 80;
};
return (
<>
{/* Name */} {/* Name */}
<div className="mt-3"> <div className="mt-3">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5"> <div className="flex justify-between items-center mb-1.5">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
Flaschenname Flaschenname
</label> </label>
{!checkConfidence('name') && (
<span className="flex items-center gap-1 text-[8px] font-black text-yellow-600 uppercase tracking-tighter animate-pulse">
<AlertTriangle size={8} /> Unsicher
</span>
)}
</div>
<input <input
type="text" type="text"
value={bottleName} value={bottleName}
onChange={(e) => setBottleName(e.target.value)} onChange={(e) => setBottleName(e.target.value)}
placeholder="e.g. 12 Year Old" placeholder="e.g. 12 Year Old"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('name') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`}
/> />
</div> </div>
{/* Distillery */} {/* Distillery */}
<div> <div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5"> <div className="flex justify-between items-center mb-1.5">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
Destillerie Destillerie
</label> </label>
{!checkConfidence('distillery') && (
<span className="flex items-center gap-1 text-[8px] font-black text-yellow-600 uppercase tracking-tighter animate-pulse">
<AlertTriangle size={8} /> Unsicher
</span>
)}
</div>
<input <input
type="text" type="text"
value={bottleDistillery} value={bottleDistillery}
onChange={(e) => setBottleDistillery(e.target.value)} onChange={(e) => setBottleDistillery(e.target.value)}
placeholder="e.g. Lagavulin" placeholder="e.g. Lagavulin"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('distillery') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{/* ABV */} {/* ABV */}
<div> <div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5"> <div className="flex justify-between items-center mb-1.5">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
Alkohol (ABV %) Alkohol (ABV %)
</label> </label>
{!checkConfidence('abv') && (
<AlertTriangle size={8} className="text-yellow-600 animate-pulse" />
)}
</div>
<input <input
type="number" type="number"
step="0.1" step="0.1"
value={bottleAbv} value={bottleAbv}
onChange={(e) => setBottleAbv(e.target.value)} onChange={(e) => setBottleAbv(e.target.value)}
placeholder="43.0" placeholder="43.0"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('abv') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`}
/> />
</div> </div>
{/* Age */} {/* Age */}
<div> <div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5"> <div className="flex justify-between items-center mb-1.5">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
Alter (Jahre) Alter (Jahre)
</label> </label>
{!checkConfidence('age') && (
<AlertTriangle size={8} className="text-yellow-600 animate-pulse" />
)}
</div>
<input <input
type="number" type="number"
value={bottleAge} value={bottleAge}
onChange={(e) => setBottleAge(e.target.value)} onChange={(e) => setBottleAge(e.target.value)}
placeholder="12" placeholder="12"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('age') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`}
/> />
</div> </div>
</div> </div>
@@ -395,22 +455,33 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleCategory} value={bottleCategory}
onChange={(e) => setBottleCategory(e.target.value)} onChange={(e) => setBottleCategory(e.target.value)}
placeholder="e.g. Single Malt" placeholder="e.g. Single Malt"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
{/* Cask Type */} {/* Cask Type */}
<div> <div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5"> <div className="flex justify-between items-center mb-1.5">
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block">
Fass-Typ (Cask) Fass-Typ (Cask)
</label> </label>
{!checkConfidence('cask_type') && (
<span className="flex items-center gap-1 text-[8px] font-black text-yellow-600 uppercase tracking-tighter animate-pulse">
<AlertTriangle size={8} /> Unsicher
</span>
)}
</div>
<input <input
type="text" type="text"
value={bottleCaskType} value={bottleCaskType}
onChange={(e) => setBottleCaskType(e.target.value)} onChange={(e) => setBottleCaskType(e.target.value)}
placeholder="e.g. Oloroso Sherry Cask" placeholder="e.g. Oloroso Sherry Cask"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('cask_type') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`}
/> />
</div> </div>
</>
);
})()}
{/* Vintage */} {/* Vintage */}
<div> <div>
<label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5"> <label className="text-[9px] font-bold uppercase tracking-wider text-zinc-600 block mb-1.5">
@@ -421,7 +492,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleVintage} value={bottleVintage}
onChange={(e) => setBottleVintage(e.target.value)} onChange={(e) => setBottleVintage(e.target.value)}
placeholder="e.g. 2007" placeholder="e.g. 2007"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -435,7 +506,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleBottler} value={bottleBottler}
onChange={(e) => setBottleBottler(e.target.value)} onChange={(e) => setBottleBottler(e.target.value)}
placeholder="e.g. Independent Bottler" placeholder="e.g. Independent Bottler"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -449,7 +520,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleDistilledAt} value={bottleDistilledAt}
onChange={(e) => setBottleDistilledAt(e.target.value)} onChange={(e) => setBottleDistilledAt(e.target.value)}
placeholder="e.g. 2007" placeholder="e.g. 2007"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -463,7 +534,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleBottledAt} value={bottleBottledAt}
onChange={(e) => setBottleBottledAt(e.target.value)} onChange={(e) => setBottleBottledAt(e.target.value)}
placeholder="e.g. 2024" placeholder="e.g. 2024"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -477,7 +548,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleBatchInfo} value={bottleBatchInfo}
onChange={(e) => setBottleBatchInfo(e.target.value)} onChange={(e) => setBottleBatchInfo(e.target.value)}
placeholder="e.g. Oloroso Sherry Cask" placeholder="e.g. Oloroso Sherry Cask"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -491,7 +562,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleCode} value={bottleCode}
onChange={(e) => setBottleCode(e.target.value)} onChange={(e) => setBottleCode(e.target.value)}
placeholder="e.g. WB271235" placeholder="e.g. WB271235"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -504,7 +575,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
<select <select
value={status} value={status}
onChange={(e) => setStatus(e.target.value)} onChange={(e) => setStatus(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-hidden focus:border-orange-600 transition-colors"
> >
<option value="sealed">Versiegelt</option> <option value="sealed">Versiegelt</option>
<option value="open">Offen</option> <option value="open">Offen</option>
@@ -580,6 +651,54 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
</div> </div>
</button> </button>
{/* Blind Guessing Section */}
{isSessionBlind && (
<div className="bg-purple-900/10 rounded-[32px] p-8 border border-purple-500/30 space-y-8 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
<Sparkles size={80} className="text-purple-500" />
</div>
<div className="relative">
<h3 className="text-[10px] font-black uppercase tracking-[0.4em] text-purple-400 mb-1">Experimenteller Gaumen</h3>
<p className="text-2xl font-black text-white tracking-tighter">Was ist im Glas?</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 relative">
<div className="space-y-2">
<label className="text-[9px] font-black uppercase tracking-widest text-purple-400/60 ml-1">Geschätzter ABV (%)</label>
<input
type="number"
step="0.1"
value={guessAbv}
onChange={(e) => setGuessAbv(e.target.value)}
placeholder="z.B. 46.3"
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-hidden transition-all"
/>
</div>
<div className="space-y-2">
<label className="text-[9px] font-black uppercase tracking-widest text-purple-400/60 ml-1">Geschätztes Alter</label>
<input
type="number"
value={guessAge}
onChange={(e) => setGuessAge(e.target.value)}
placeholder="z.B. 12"
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-hidden transition-all"
/>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-[9px] font-black uppercase tracking-widest text-purple-400/60 ml-1">Region / Destillerie Tipp</label>
<input
type="text"
value={guessRegion}
onChange={(e) => setGuessRegion(e.target.value)}
placeholder="z.B. Islay / Lagavulin"
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-hidden transition-all"
/>
</div>
</div>
</div>
)}
{/* Shared Tasting Form Body */} {/* Shared Tasting Form Body */}
<TastingFormBody <TastingFormBody
rating={rating} rating={rating}
@@ -610,7 +729,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
</div> </div>
{/* Fixed/Sticky Footer for Save Action */} {/* Fixed/Sticky Footer for Save Action */}
<div className="w-full p-6 bg-gradient-to-t from-zinc-950 via-zinc-950/95 to-transparent border-t border-white/5 shrink-0 z-20"> <div className="w-full p-6 bg-linear-to-t from-zinc-950 via-zinc-950/95 to-transparent border-t border-white/5 shrink-0 z-20">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<button <button
onClick={handleInternalSave} onClick={handleInternalSave}

View File

@@ -161,7 +161,7 @@ export default function TastingFormBody({
onChange={(e) => setNose(e.target.value)} onChange={(e) => setNose(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700" className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-hidden resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
/> />
</div> </div>
</div> </div>
@@ -213,7 +213,7 @@ export default function TastingFormBody({
onChange={(e) => setPalate(e.target.value)} onChange={(e) => setPalate(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700" className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-hidden resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
/> />
</div> </div>
</div> </div>
@@ -281,7 +281,7 @@ export default function TastingFormBody({
onChange={(e) => setFinish(e.target.value)} onChange={(e) => setFinish(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700" className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-hidden resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
/> />
</div> </div>
</div> </div>

View File

@@ -179,7 +179,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
onClick={onClose} onClick={onClose}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60]" className="fixed inset-0 bg-black/80 backdrop-blur-xs z-60"
/> />
{/* Content */} {/* Content */}
@@ -188,7 +188,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
animate={{ y: 0 }} animate={{ y: 0 }}
exit={{ y: '100%' }} exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }} transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed bottom-0 left-0 right-0 h-[85vh] bg-[#09090b] border-t border-white/10 rounded-t-[40px] z-[70] flex flex-col shadow-2xl overflow-hidden" className="fixed bottom-0 left-0 right-0 h-[85vh] bg-[#09090b] border-t border-white/10 rounded-t-[40px] z-70 flex flex-col shadow-2xl overflow-hidden"
> >
{/* Header */} {/* Header */}
<div className="p-8 pb-4 flex items-center justify-between shrink-0"> <div className="p-8 pb-4 flex items-center justify-between shrink-0">
@@ -242,7 +242,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t('hub.placeholders.sessionName')} placeholder={t('hub.placeholders.sessionName')}
className="flex-1 bg-black/40 border border-white/5 rounded-2xl px-6 py-4 text-sm font-bold text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-600 transition-all ring-inset focus:ring-1 focus:ring-orange-600/50" className="flex-1 bg-black/40 border border-white/5 rounded-2xl px-6 py-4 text-sm font-bold text-white placeholder:text-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ring-inset focus:ring-1 focus:ring-orange-600/50"
/> />
<button <button
type="submit" type="submit"

View File

@@ -8,6 +8,8 @@ import { useI18n } from '@/i18n/I18nContext';
import { deleteTasting } from '@/services/delete-tasting'; import { deleteTasting } from '@/services/delete-tasting';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import FlavorRadar from './FlavorRadar';
interface Tasting { interface Tasting {
id: string; id: string;
@@ -38,6 +40,13 @@ interface Tasting {
}[]; }[];
user_id: string; user_id: string;
isPending?: boolean; isPending?: boolean;
flavor_profile?: {
smoky: number;
fruity: number;
spicy: number;
sweet: number;
floral: number;
};
} }
interface TastingListProps { interface TastingListProps {
@@ -92,7 +101,8 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
isPending: true, isPending: true,
tasting_buddies: [], tasting_buddies: [],
tasting_sessions: undefined, tasting_sessions: undefined,
tasting_tags: [] tasting_tags: [],
flavor_profile: undefined
})) }))
]; ];
@@ -139,7 +149,7 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
{sortedTastings.map((note) => ( {sortedTastings.map((note) => (
<div <div
key={note.id} key={note.id}
className="bg-white dark:bg-zinc-900 p-6 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm space-y-4 hover:border-amber-500/30 transition-all hover:shadow-md group" className="bg-white dark:bg-zinc-900 p-6 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xs space-y-4 hover:border-amber-500/30 transition-all hover:shadow-md group"
> >
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> <div className="flex flex-wrap items-center gap-2 sm:gap-3">
@@ -182,7 +192,7 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
<button <button
onClick={() => note.id && note.bottle_id && handleDelete(note.id, note.bottle_id)} onClick={() => note.id && note.bottle_id && handleDelete(note.id, note.bottle_id)}
disabled={!!isDeleting} disabled={!!isDeleting}
className="px-3 py-1.5 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded-xl transition-all disabled:opacity-50 flex items-center gap-2 border border-red-100 dark:border-red-900/30 font-black text-[10px] uppercase tracking-widest shadow-sm hover:bg-red-600 hover:text-white dark:hover:bg-red-600 dark:hover:text-white" className="px-3 py-1.5 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded-xl transition-all disabled:opacity-50 flex items-center gap-2 border border-red-100 dark:border-red-900/30 font-black text-[10px] uppercase tracking-widest shadow-xs hover:bg-red-600 hover:text-white dark:hover:bg-red-600 dark:hover:text-white"
title="Tasting löschen" title="Tasting löschen"
> >
{isDeleting === note.id ? ( {isDeleting === note.id ? (
@@ -198,7 +208,15 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 relative"> <div className={`grid grid-cols-1 ${note.flavor_profile ? 'md:grid-cols-4' : 'md:grid-cols-3'} gap-6 relative`}>
{note.flavor_profile && (
<div className="md:col-span-1 bg-zinc-950/50 rounded-2xl border border-white/5 p-2 flex flex-col items-center justify-center">
<div className="text-[8px] font-black text-zinc-500 uppercase tracking-[0.2em] mb-1">Flavor Profile</div>
<FlavorRadar profile={note.flavor_profile} size={140} showAxis={false} />
</div>
)}
<div className={`${note.flavor_profile ? 'md:col-span-3' : 'md:col-span-3'} grid grid-cols-1 md:grid-cols-3 gap-6 relative`}>
{/* Visual Divider for MD and up */} {/* Visual Divider for MD and up */}
<div className="hidden md:block absolute left-1/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" /> <div className="hidden md:block absolute left-1/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" />
<div className="hidden md:block absolute left-2/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" /> <div className="hidden md:block absolute left-2/3 top-0 bottom-0 w-px bg-zinc-100 dark:bg-zinc-800/50" />
@@ -228,6 +246,7 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
</div> </div>
)} )}
</div> </div>
</div>
{note.tasting_tags && note.tasting_tags.length > 0 && ( {note.tasting_tags && note.tasting_tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-2"> <div className="flex flex-wrap gap-1.5 pt-2">

View File

@@ -305,7 +305,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
)} )}
{/* Sticky Save Button Container */} {/* Sticky Save Button Container */}
<div className="sticky bottom-0 -mx-6 px-6 py-4 bg-gradient-to-t from-zinc-950 via-zinc-950/90 to-transparent z-10"> <div className="sticky bottom-0 -mx-6 px-6 py-4 bg-linear-to-t from-zinc-950 via-zinc-950/90 to-transparent z-10">
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}

View File

@@ -274,7 +274,7 @@ export default function UploadQueue() {
if (totalInQueue === 0) return null; if (totalInQueue === 0) return null;
return ( return (
<div className="fixed bottom-24 right-6 md:bottom-6 md:right-6 z-[100] flex flex-col items-end gap-3 translate-y-0"> <div className="fixed bottom-24 right-6 md:bottom-6 md:right-6 z-100 flex flex-col items-end gap-3 translate-y-0">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{!isCollapsed ? ( {!isCollapsed ? (
<motion.div <motion.div

View File

@@ -166,7 +166,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Search Bar */} {/* Search Bar */}
<div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-sm"> <div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-xs">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={20} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={20} />
<input <input
@@ -174,13 +174,13 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
placeholder="Search users by email or username..." placeholder="Search users by email or username..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-2xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-2xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
</div> </div>
{/* User Table */} {/* User Table */}
<div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-sm overflow-hidden"> <div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-xs overflow-hidden">
<h2 className="text-xl font-bold text-white uppercase tracking-tighter mb-6">Users ({filteredUsers.length})</h2> <h2 className="text-xl font-bold text-white uppercase tracking-tighter mb-6">Users ({filteredUsers.length})</h2>
<div className="overflow-x-auto -mx-6"> <div className="overflow-x-auto -mx-6">
<table className="w-full"> <table className="w-full">
@@ -235,7 +235,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
{/* Edit Modal */} {/* Edit Modal */}
{editingUser && ( {editingUser && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 z-50">
<div className="bg-zinc-900 rounded-[32px] p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-zinc-800 shadow-2xl space-y-8"> <div className="bg-zinc-900 rounded-[32px] p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-zinc-800 shadow-2xl space-y-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -278,7 +278,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
value={creditAmount} value={creditAmount}
onChange={(e) => setCreditAmount(e.target.value)} onChange={(e) => setCreditAmount(e.target.value)}
placeholder="e.g. 100 or -50" placeholder="e.g. 100 or -50"
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div> <div>
@@ -288,7 +288,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
value={reason} value={reason}
onChange={(e) => setReason(e.target.value)} onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Monthly bonus" placeholder="e.g. Monthly bonus"
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
</div> </div>
@@ -319,7 +319,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
<select <select
value={selectedPlan} value={selectedPlan}
onChange={(e) => setSelectedPlan(e.target.value)} onChange={(e) => setSelectedPlan(e.target.value)}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white appearance-none" className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white appearance-none"
> >
<option value="">Select a plan...</option> <option value="">Select a plan...</option>
{plans.map(plan => ( {plans.map(plan => (
@@ -352,7 +352,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
value={dailyLimit} value={dailyLimit}
onChange={(e) => setDailyLimit(e.target.value)} onChange={(e) => setDailyLimit(e.target.value)}
placeholder="Global (80)" placeholder="Global (80)"
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div> <div>
@@ -361,7 +361,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
type="number" type="number"
value={googleCost} value={googleCost}
onChange={(e) => setGoogleCost(e.target.value)} onChange={(e) => setGoogleCost(e.target.value)}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div> <div>
@@ -370,7 +370,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
type="number" type="number"
value={geminiCost} value={geminiCost}
onChange={(e) => setGeminiCost(e.target.value)} onChange={(e) => setGeminiCost(e.target.value)}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
</div> </div>

13
src/config/features.ts Normal file
View File

@@ -0,0 +1,13 @@
export const FEATURES = {
// Global Toggle: Set to false to disable download and processing completely
ENABLE_AI_BG_REMOVAL: false,
// Feathering intensity in pixels (1-3px is usually best for bottles)
BG_REMOVAL_FEATHER_AMOUNT: 2,
// Enable cascade OCR (Native TextDetector → RegEx → Fuzzy Match → window.ai)
ENABLE_CASCADE_OCR: true,
// Enable Smart Scan Flow (Native TextDetector on Android, Live Text fallback on iOS)
ENABLE_SMART_SCAN: true,
};

View File

@@ -0,0 +1,152 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { FEATURES } from '../config/features';
import { db } from '@/lib/db';
import { createClient } from '@/lib/supabase/client';
import { v4 as uuidv4 } from 'uuid';
/**
* Upload processed image to Supabase and update bottle record
*/
async function uploadToSupabase(bottleId: string, blob: Blob): Promise<string | null> {
try {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
console.warn('[ImageProcessor] No user session, skipping Supabase upload');
return null;
}
// Upload to storage
const fileName = `${user.id}/${bottleId}_nobg_${uuidv4()}.png`;
const { error: uploadError } = await supabase.storage
.from('bottles')
.upload(fileName, blob, {
contentType: 'image/png',
upsert: true,
});
if (uploadError) {
console.error('[ImageProcessor] Upload error:', uploadError);
return null;
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('bottles')
.getPublicUrl(fileName);
// Update bottle record with new image URL
const { error: updateError } = await supabase
.from('bottles')
.update({ image_url: publicUrl })
.eq('id', bottleId);
if (updateError) {
console.error('[ImageProcessor] DB update error:', updateError);
return null;
}
console.log(`[ImageProcessor] Uploaded to Supabase: ${bottleId}`);
return publicUrl;
} catch (err) {
console.error('[ImageProcessor] Supabase sync failed:', err);
return null;
}
}
export function useImageProcessor() {
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
if (!FEATURES.ENABLE_AI_BG_REMOVAL) return;
// Initialize worker
console.log('[ImageProcessor] Initializing worker...');
const worker = new Worker(
'/bg-processor.worker.js',
{ type: 'module' }
);
workerRef.current = worker;
console.log('[ImageProcessor] Worker instance created');
worker.postMessage({ type: 'ping' });
console.log('[ImageProcessor] Sent ping to worker');
worker.onmessage = async (e) => {
if (e.data.type === 'pong') {
console.log('[ImageProcessor] Received pong from worker - Communication OK');
return;
}
const { id, status, blob, error } = e.data;
if (status === 'success' && blob) {
// Convert blob to Base64 for IndexedDB storage
const reader = new FileReader();
reader.onloadend = async () => {
const base64data = reader.result as string;
try {
// Update cache_bottles if it exists there
const cachedBottle = await db.cache_bottles.get(id);
if (cachedBottle) {
await db.cache_bottles.update(id, {
image_url: base64data,
bgRemoved: true,
updated_at: new Date().toISOString()
});
console.log(`[ImageProcessor] Background removed for cached bottle: ${id}`);
// Upload to Supabase (fire and forget, don't block UI)
uploadToSupabase(id, blob).then(url => {
if (url) {
// Update local cache with the new Supabase URL
db.cache_bottles.update(id, { image_url: url });
}
});
}
// Update pending_scans if it exists there by temp_id
const pendingScan = await db.pending_scans.where('temp_id').equals(id).first();
if (pendingScan) {
await db.pending_scans.update(pendingScan.id!, {
imageBase64: base64data,
bgRemoved: true
});
console.log(`[ImageProcessor] Background removed for pending scan: ${id}`);
}
} catch (err) {
console.error('[ImageProcessor] Failed to update DB:', err);
}
};
reader.readAsDataURL(blob);
} else if (status === 'error') {
console.error('[ImageProcessor] Worker error:', error);
}
};
return () => {
worker.terminate();
};
}, []);
const addToQueue = useCallback(async (id: string, imageBlob: Blob) => {
if (!FEATURES.ENABLE_AI_BG_REMOVAL || !workerRef.current) {
console.warn('[ImageProcessor] Background removal disabled or worker not ready');
return;
}
// Check if already processed to avoid redundant work
const cached = await db.cache_bottles.get(id);
if (cached?.bgRemoved) return;
const pending = await db.pending_scans.where('temp_id').equals(id).first();
if (pending?.bgRemoved) return;
workerRef.current.postMessage({ id, imageBlob });
}, []);
return { addToQueue };
}

View File

@@ -0,0 +1,70 @@
'use client';
import { useOptimistic, useTransition } from 'react';
/**
* Hook for optimistic updates with automatic rollback on error.
* Uses React 19's useOptimistic + startTransition pattern.
*
* @example
* const { optimisticData, isPending, mutate } = useOptimisticMutation(
* tastings,
* async (newTasting) => await saveTasting(newTasting)
* );
*/
export function useOptimisticMutation<T, TInput>(
initialData: T[],
mutationFn: (input: TInput) => Promise<{ success: boolean; error?: string }>
) {
const [isPending, startTransition] = useTransition();
const [optimisticData, addOptimistic] = useOptimistic<T[], TInput>(
initialData,
(currentData, newItem) => [...currentData, newItem as unknown as T]
);
const mutate = async (input: TInput, optimisticValue: T) => {
startTransition(async () => {
// Immediately show optimistic update
addOptimistic(input);
// Perform actual mutation
const result = await mutationFn(input);
if (!result.success) {
console.error('[OptimisticMutation] Failed:', result.error);
// Note: React will automatically rollback on error
}
});
};
return {
optimisticData,
isPending,
mutate,
};
}
/**
* Simple optimistic state for single values (like ratings).
*/
export function useOptimisticValue<T>(
serverValue: T,
updateFn: (value: T) => Promise<{ success: boolean }>
) {
const [isPending, startTransition] = useTransition();
const [optimisticValue, setOptimistic] = useOptimistic(serverValue);
const setValue = (value: T) => {
startTransition(async () => {
setOptimistic(value);
await updateFn(value);
});
};
return {
value: optimisticValue,
isPending,
setValue,
};
}

196
src/hooks/useScanFlow.ts Normal file
View File

@@ -0,0 +1,196 @@
'use client';
/**
* Smart Scan Flow Hook
*
* "Chamäleon Strategy":
* - Branch A (Android/Chrome): Native TextDetector OCR
* - Branch B (iOS/Unsupported): System Keyboard with Live Text
*
* This is separate from the OpenRouter/Gemma cloud workflow.
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import '@/types/text-detector.d.ts';
export interface ScanFlowState {
hasNativeOCR: boolean;
isIOS: boolean;
isAndroid: boolean;
isCameraActive: boolean;
isFormOpen: boolean;
detectedTexts: string[];
isProcessing: boolean;
}
export interface UseScanFlowReturn {
state: ScanFlowState;
triggerScan: () => void;
startCamera: () => void;
stopCamera: () => void;
openFormWithFocus: () => void;
processVideoFrame: (video: HTMLVideoElement) => Promise<string[]>;
}
/**
* Hook for smart scan flow with device capability detection
*/
export function useScanFlow(): UseScanFlowReturn {
// Feature detection (run once on mount)
const [state, setState] = useState<ScanFlowState>({
hasNativeOCR: false,
isIOS: false,
isAndroid: false,
isCameraActive: false,
isFormOpen: false,
detectedTexts: [],
isProcessing: false,
});
const detectorRef = useRef<InstanceType<NonNullable<typeof window.TextDetector>> | null>(null);
// Initialize feature detection
useEffect(() => {
if (typeof window === 'undefined') return;
const hasNativeOCR = 'TextDetector' in window;
const ua = navigator.userAgent;
const isIOS = /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream;
const isAndroid = /Android/.test(ua);
console.log('[ScanFlow] Feature Detection:', {
hasNativeOCR,
isIOS,
isAndroid,
userAgent: ua.substring(0, 50) + '...',
});
setState(prev => ({
...prev,
hasNativeOCR,
isIOS,
isAndroid,
}));
// Initialize TextDetector if available
if (hasNativeOCR && window.TextDetector) {
try {
detectorRef.current = new window.TextDetector();
console.log('[ScanFlow] TextDetector initialized');
} catch (err) {
console.warn('[ScanFlow] Failed to initialize TextDetector:', err);
}
}
}, []);
/**
* Main trigger function - "Chamäleon Strategy"
*/
const triggerScan = useCallback(() => {
if (state.hasNativeOCR) {
// PATH A: Android / Chrome (Automated OCR)
console.log('[ScanFlow] Branch A: Starting Native TextDetector...');
setState(prev => ({ ...prev, isCameraActive: true, isFormOpen: false }));
} else {
// PATH B: iOS / Fallback (System Keyboard)
console.log('[ScanFlow] Branch B: Native OCR missing. Fallback to System Keyboard Flow.');
openFormWithFocus();
}
}, [state.hasNativeOCR]);
/**
* Start camera for native OCR
*/
const startCamera = useCallback(() => {
console.log('[ScanFlow] Starting camera...');
setState(prev => ({ ...prev, isCameraActive: true }));
}, []);
/**
* Stop camera
*/
const stopCamera = useCallback(() => {
console.log('[ScanFlow] Stopping camera...');
setState(prev => ({ ...prev, isCameraActive: false, detectedTexts: [] }));
}, []);
/**
* Open form and auto-focus first input (iOS Live Text path)
*/
const openFormWithFocus = useCallback(() => {
console.log('[ScanFlow] Opening form with auto-focus...');
setState(prev => ({ ...prev, isFormOpen: true, isCameraActive: false }));
// UX Hack: Focus the field after a micro-task to ensure Modal is rendered
setTimeout(() => {
const inputField = document.querySelector('#field-bottle-name') as HTMLInputElement;
if (inputField) {
inputField.focus();
console.log('[ScanFlow] Focused #field-bottle-name for iOS Live Text');
} else {
// Fallback to any input with data-scan-target
const fallback = document.querySelector('[data-scan-target="true"]') as HTMLInputElement;
if (fallback) {
fallback.focus();
console.log('[ScanFlow] Focused fallback scan target');
}
}
}, 150);
}, []);
/**
* Process a video frame using TextDetector
*/
const processVideoFrame = useCallback(async (video: HTMLVideoElement): Promise<string[]> => {
if (!detectorRef.current) {
console.warn('[ScanFlow] TextDetector not available');
return [];
}
setState(prev => ({ ...prev, isProcessing: true }));
try {
const imageBitmap = await createImageBitmap(video);
const detections = await detectorRef.current.detect(imageBitmap);
const texts = detections
.map(d => d.rawValue)
.filter(Boolean)
.filter(text => text.length >= 2); // Filter very short strings
console.log('[ScanFlow] Detected texts:', texts);
setState(prev => ({
...prev,
detectedTexts: texts,
isProcessing: false,
}));
return texts;
} catch (err) {
console.error('[ScanFlow] Frame processing error:', err);
setState(prev => ({ ...prev, isProcessing: false }));
return [];
}
}, []);
return {
state,
triggerScan,
startCamera,
stopCamera,
openFormWithFocus,
processVideoFrame,
};
}
/**
* Utility: Get placeholder text based on device
*/
export function getScanPlaceholder(isIOS: boolean, defaultText: string = 'Bottle Name'): string {
if (isIOS) {
return "Tap here & use 'Scan Text' 📷";
}
return defaultText;
}

Some files were not shown because too many files have changed in this diff Show More