Compare commits

...

24 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
96 changed files with 6652 additions and 782 deletions

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

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

View File

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

View File

@@ -1,12 +1,48 @@
import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').Config} */
const nextConfig = {
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: {
// Note: cacheComponents (PPR) disabled - requires Suspense boundaries for all auth contexts
// Can be enabled later after refactoring to RSC-first architecture
serverActions: {
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,6 +15,7 @@
"@ai-sdk/google": "^2.0.51",
"@google/generative-ai": "^0.24.1",
"@mistralai/mistralai": "^1.11.0",
"@sentry/nextjs": "^10.34.0",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.47.10",
"@tanstack/react-query": "^5.62.7",
@@ -43,6 +44,7 @@
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/node": "^20",
@@ -50,13 +52,13 @@
"@types/react-dom": "^19.0.0",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^8",
"eslint-config-next": "16.1.0",
"eslint-plugin-security": "^2.1.1",
"jsdom": "^27.3.0",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"tailwindcss": "^4.1.18",
"typescript": "^5",
"vitest": "^4.0.16"
},

2250
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

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)
const STATIC_ASSETS = [
@@ -189,22 +189,33 @@ self.addEventListener('fetch', (event) => {
if (isNavigation || isAsset) {
event.respondWith(
caches.match(event.request).then(async (cachedResponse) => {
const fetchPromise = fetchWithTimeout(event.request, 10000)
.then(async (networkResponse) => {
// Try network first
try {
const networkResponse = await fetchWithTimeout(event.request, 10000);
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(CACHE_NAME);
cache.put(event.request, networkResponse.clone());
}
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 (cachedResponse) return cachedResponse;
const shell = await caches.match('/');
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' }
});
}
})
);
}

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();

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

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
@@ -49,7 +48,7 @@ export default async function OcrLogsPage() {
{/* 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-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="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Camera size={20} className="text-blue-600 dark:text-blue-400" />
@@ -60,7 +59,7 @@ export default async function OcrLogsPage() {
<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-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="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Calendar size={20} className="text-green-600 dark:text-green-400" />
@@ -71,7 +70,7 @@ export default async function OcrLogsPage() {
<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-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="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<Percent size={20} className="text-amber-600 dark:text-amber-400" />
@@ -82,7 +81,7 @@ export default async function OcrLogsPage() {
<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-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="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<TrendingUp size={20} className="text-purple-600 dark:text-purple-400" />
@@ -100,7 +99,7 @@ export default async function OcrLogsPage() {
{/* 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-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">Most Scanned Distilleries</h2>
<div className="flex flex-wrap gap-2">
{stats.topDistilleries.map((d, i) => (
@@ -119,7 +118,7 @@ export default async function OcrLogsPage() {
)}
{/* OCR Logs Grid */}
<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 OCR Scans</h2>
{logs.length === 0 ? (
@@ -136,7 +135,7 @@ export default async function OcrLogsPage() {
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">
<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}
@@ -175,7 +174,7 @@ export default async function OcrLogsPage() {
{log.distillery}
</span>
{log.distillery_source && (
<span className="text-[10px] px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded text-zinc-500">
<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>
)}
@@ -190,22 +189,22 @@ export default async function OcrLogsPage() {
<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 text-[10px] font-bold">
<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 text-[10px] font-bold">
<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 text-[10px] font-bold">
<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 text-[10px] font-bold">
<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>
)}
@@ -218,7 +217,7 @@ export default async function OcrLogsPage() {
<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 text-[9px] text-zinc-500 overflow-x-auto max-h-20 whitespace-pre-wrap">
<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>

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';
@@ -117,6 +116,36 @@ export default async function AdminPage() {
>
Manage Users
</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
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"
@@ -128,7 +157,7 @@ export default async function AdminPage() {
{/* Global 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-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="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<BarChart3 size={20} className="text-blue-600 dark:text-blue-400" />
@@ -139,7 +168,7 @@ export default async function AdminPage() {
<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-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="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Calendar size={20} className="text-green-600 dark:text-green-400" />
@@ -158,7 +187,7 @@ export default async function AdminPage() {
</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="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
@@ -169,7 +198,7 @@ export default async function AdminPage() {
<div className="text-xs text-zinc-500 mt-1">Whiskybase searches</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="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<Users size={20} className="text-purple-600 dark:text-purple-400" />
@@ -182,7 +211,7 @@ export default async function AdminPage() {
</div>
{/* 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>
<div className="space-y-3">
{topUsersWithStats.map((user, index) => (
@@ -205,7 +234,7 @@ export default async function AdminPage() {
</div>
{/* 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>
<div className="text-sm text-zinc-500 mb-4">
Total calls logged: {recentUsage?.length || 0}
@@ -266,7 +295,7 @@ export default async function AdminPage() {
{call.response_text && (
<details className="text-[10px]">
<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}
</pre>
</details>
@@ -280,7 +309,7 @@ export default async function AdminPage() {
<div className="group relative">
<span className="text-red-600 dark:text-red-400 font-black text-xs cursor-help">ERR</span>
{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}
</div>
)}

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
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}
onChange={(e) => setSearch(e.target.value)}
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 className="flex items-center gap-2">
@@ -96,7 +96,7 @@ export default function AdminTagsPage() {
<select
value={categoryFilter}
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="nose">Nose</option>
@@ -154,7 +154,7 @@ export default function AdminTagsPage() {
key={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
? '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'
}`}
>

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 { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
@@ -53,7 +52,7 @@ export default async function AdminUsersPage() {
{/* Statistics Cards */}
<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="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<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>
<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="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<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>
<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="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<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>
<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="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<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 { 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 { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/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 { AlertTriangle, RefreshCcw } from 'lucide-react';
import * as Sentry from "@sentry/nextjs";
export default function Error({
error,
@@ -12,8 +13,11 @@ export default function Error({
}) {
useEffect(() => {
console.error('App Crash Error:', error);
// Report error to Sentry/GlitchTip
Sentry.captureException(error);
}, [error]);
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="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';
import { RefreshCcw } from 'lucide-react';
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({
error,
@@ -9,6 +11,11 @@ export default function GlobalError({
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Report error to Sentry/GlitchTip
Sentry.captureException(error);
}, [error]);
return (
<html lang="de">
<body>

View File

@@ -1,6 +1,58 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@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 {
:root {
@@ -20,16 +72,17 @@
}
}
@layer utilities {
body {
@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 */
input,
textarea,
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,
@@ -45,22 +98,4 @@ h4,
font-family: var(--font-inter), system-ui, sans-serif;
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

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

View File

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

View File

@@ -1,30 +1,29 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import BottleGrid from "@/components/BottleGrid";
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 { useI18n } from "@/i18n/I18nContext";
import { useAuth } from "@/context/AuthContext";
import { useSession } from "@/context/SessionContext";
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 ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
import UserStatusBadge from '@/components/UserStatusBadge';
import { getActiveSplits } from '@/services/split-actions';
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() {
const supabase = createClient();
const router = useRouter();
const searchParams = useSearchParams();
const [bottles, setBottles] = useState<any[]>([]);
const { user, isLoading: isAuthLoading } = useAuth();
const [isInternalLoading, setIsInternalLoading] = useState(false);
@@ -36,6 +35,7 @@ export default function Home() {
const [capturedFile, setCapturedFile] = useState<File | null>(null);
const [hasMounted, setHasMounted] = useState(false);
const [publicSplits, setPublicSplits] = useState<any[]>([]);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
setHasMounted(true);
@@ -47,7 +47,6 @@ export default function Home() {
};
useEffect(() => {
// Only fetch if auth is ready and user exists
if (!isAuthLoading && user) {
fetchCollection();
} else if (!isAuthLoading && !user) {
@@ -56,14 +55,12 @@ export default function Home() {
}, [user, isAuthLoading]);
useEffect(() => {
// Fetch public splits if guest
getActiveSplits().then(res => {
if (res.success && res.splits) {
setPublicSplits(res.splits);
}
});
// Listen for collection updates (e.g., after offline sync completes)
const handleCollectionUpdated = () => {
console.log('[Home] Collection update event received, refreshing...');
fetchCollection();
@@ -78,7 +75,6 @@ export default function Home() {
const fetchCollection = async () => {
setIsInternalLoading(true);
try {
// Fetch bottles with their latest tasting date
const { data, error } = await supabase
.from('bottles')
.select(`
@@ -90,13 +86,10 @@ export default function Home() {
`)
.order('created_at', { ascending: false });
if (error) {
throw error;
}
if (error) throw error;
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 lastTasted = bottle.tastings && bottle.tastings.length > 0
? bottle.tastings.reduce((latest: string, current: any) =>
@@ -105,41 +98,18 @@ export default function Home() {
)
: null;
return {
...bottle,
last_tasted: lastTasted
};
return { ...bottle, last_tasted: lastTasted };
});
setBottles(processedBottles);
} catch (err: any) {
// Enhanced logging for empty-looking error objects
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
console.warn('[Home] Fetch collection error:', err?.message);
const isNetworkError = !navigator.onLine ||
err?.name === 'TypeError' ||
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);
err?.message?.includes('Failed to fetch');
if (isNetworkError) {
console.log('[fetchCollection] Skipping due to offline mode or network 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);
if (!isNetworkError) {
setFetchError(err?.message || 'Unknown error');
}
} finally {
setIsInternalLoading(false);
@@ -150,6 +120,17 @@ export default function Home() {
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) {
return (
<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) {
return (
<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">
{t('home.tagline')}
</p>
<div className="mt-8">
<LanguageSwitcher />
</div>
</div>
<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="flex flex-col items-center gap-2">
<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;
// Authenticated Home View - New Layout
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="z-10 max-w-5xl w-full flex flex-col items-center gap-12">
<header className="w-full flex flex-col sm:flex-row justify-between items-center gap-4 sm:gap-0">
<div className="flex flex-col items-center sm:items-start group">
<h1 className="text-4xl font-bold text-zinc-50 tracking-tighter">
<div className="flex flex-col min-h-screen bg-(--background) relative">
{/* Scrollable Content Area */}
<div className="flex-1 overflow-y-auto pb-24">
{/* 1. Header */}
<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>
</h1>
{activeSession && (
<div className="flex items-center gap-2 mt-1 animate-in fade-in slide-in-from-left-2 duration-700">
<button
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">
<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>
@@ -218,42 +211,54 @@ export default function Home() {
Live: {activeSession.name}
</span>
</div>
) : (
<div />
)}
</div>
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-3 md:gap-4">
<div className="flex items-center gap-2">
<UserStatusBadge />
<OfflineIndicator />
<LanguageSwitcher />
<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>
<div className="w-full">
<StatsDashboard bottles={bottles} />
{/* 2. Hero Banner (optional) */}
<div className="px-4 mt-2 mb-4">
<HeroBanner />
</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">
<SessionList />
{/* 3. Quick Actions Grid */}
<div className="px-4 mb-4">
<QuickActionsGrid />
</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 className="w-full mt-4" id="collection">
<div className="flex items-end justify-between mb-8">
<h2 className="text-3xl font-bold text-zinc-50 uppercase tracking-tight">
{/* 5. Collection */}
<div className="px-4 mt-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-zinc-50">
{t('home.collection')}
</h2>
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pb-1">
{bottles.length} {t('home.bottleCount')}
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
{filteredBottles.length} {t('home.bottleCount')}
</span>
</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>
) : fetchError ? (
<div className="p-12 bg-zinc-900 border border-zinc-800 rounded-3xl text-center">
<p className="text-zinc-50 font-bold text-xl mb-2">{t('common.error')}</p>
<p className="text-zinc-500 text-xs italic mb-8 mx-auto max-w-xs">{fetchError}</p>
<div className="p-8 bg-zinc-900 border border-zinc-800 rounded-2xl text-center">
<p className="text-zinc-50 font-bold mb-2">{t('common.error')}</p>
<p className="text-zinc-500 text-xs mb-6">{fetchError}</p>
<button
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')}
</button>
</div>
) : (
bottles.length > 0 && <BottleGrid bottles={bottles} />
)}
) : filteredBottles.length > 0 ? (
<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>
) : null}
</div>
{/* Footer */}
@@ -288,7 +296,9 @@ export default function Home() {
<a href="/settings" className="hover:text-orange-500 transition-colors">{t('home.settings')}</a>
</div>
</footer>
</div>
{/* Bottom Navigation with FAB */}
<BottomNavigation
onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })}
@@ -308,6 +318,6 @@ export default function Home() {
imageFile={capturedFile}
onBottleSaved={() => fetchCollection()}
/>
</main>
</div>
);
}

View File

@@ -308,7 +308,7 @@ export default function SessionDetailPage() {
}
return (
<main className="min-h-screen bg-[var(--background)] p-4 md:p-12 lg:p-24 pb-32">
<main className="min-h-screen bg-(--background) p-4 md:p-12 lg:p-24 pb-32">
<div className="max-w-6xl mx-auto space-y-12">
{/* Back Link & Info */}
<div className="flex justify-between items-center">
@@ -337,7 +337,7 @@ export default function SessionDetailPage() {
{/* Immersive Header */}
<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">
{/* Background Visuals */}
<div className="absolute inset-0 bg-gradient-to-br from-zinc-900 via-zinc-900 to-black z-0" />
<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 && (
<div className="absolute top-0 right-0 w-2/3 h-full opacity-30 z-0">
<div
@@ -570,7 +570,7 @@ export default function SessionDetailPage() {
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-none focus:border-orange-500/50 transition-colors appearance-none"
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>

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}
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>
)}

View File

@@ -158,7 +158,7 @@ export default function SplitManagePage() {
<div className="flex items-center gap-2">
<p className="font-bold text-white truncate">{split.bottleName}</p>
{!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
</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

@@ -19,7 +19,7 @@ export default function ActiveSessionBanner() {
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"
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 */}

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"
required
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>
<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)}
placeholder="Max Mustermann"
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>
@@ -166,7 +166,7 @@ export default function AuthForm() {
onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de"
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>
@@ -181,7 +181,7 @@ export default function AuthForm() {
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
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>

View File

@@ -30,7 +30,7 @@ export default function AvatarStack({ names, limit = 3, size = 'sm' }: AvatarSta
{visibleNames.map((name, i) => (
<div
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}
>
{getInitials(name)}
@@ -42,7 +42,7 @@ export default function AvatarStack({ names, limit = 3, size = 'sm' }: AvatarSta
))}
{extraCount > 0 && (
<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`}
>
+{extraCount}

View File

@@ -98,7 +98,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
return (
<div className="max-w-4xl mx-auto pb-24">
{/* 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 */}
<div className="absolute top-6 left-6 z-20">
<Link
@@ -110,9 +110,9 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
</div>
{/* 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 */}
<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
src={getStorageUrl(bottle.image_url)}
alt={bottle.name}
@@ -121,7 +121,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
</div>
{/* 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>
{/* Content Container */}
@@ -134,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>
</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'}
</h2>
<h1 className="text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[1.05] drop-shadow-md">

View File

@@ -37,10 +37,10 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
return (
<Link
href={`/bottles/${bottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`}
className="block h-full group relative overflow-hidden rounded-2xl bg-zinc-900 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]"
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]"
>
{/* === SPOTIFY-STYLE IMAGE SECTION === */}
<div className="relative aspect-[3/4] overflow-hidden">
<div className="relative aspect-3/4 overflow-hidden">
{/* Layer 1: Blurred Backdrop */}
<div className="absolute inset-0 z-0">
@@ -103,10 +103,10 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
</h3>
<div className="flex flex-wrap gap-2 mt-3">
<span className="px-2 py-1 bg-white/10 backdrop-blur-sm text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
<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-sm text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
<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>
@@ -216,7 +216,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
placeholder={t('grid.searchPlaceholder')}
value={searchQuery}
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 && (
<button
@@ -232,7 +232,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
<select
value={sortBy}
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="last_tasted" className="bg-zinc-950">{t('grid.sortBy.lastTasted')}</option>

View File

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

View File

@@ -106,7 +106,7 @@ export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHand
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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}
>
<motion.div
@@ -240,7 +240,7 @@ export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHand
}
}}
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}
autoFocus
/>

View File

@@ -112,7 +112,7 @@ export default function BuddyList() {
value={newName}
onChange={(e) => setNewName(e.target.value)}
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
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 -space-x-1.5 overflow-hidden">
{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()}
</div>
))}
{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}
</div>
)}

View File

@@ -122,7 +122,7 @@ export default function BulkScanSheet({
className="fixed inset-0 bg-black z-50 flex flex-col"
>
{/* 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="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
<Zap size={20} className="text-orange-500" />
@@ -240,7 +240,7 @@ export default function BulkScanSheet({
<X size={12} className="text-white" />
</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}
</span>
</motion.div>

View File

@@ -473,7 +473,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
)}
{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="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">

View File

@@ -24,7 +24,7 @@ export default function CookieBanner() {
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
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">
{/* Header */}

View File

@@ -55,7 +55,7 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
</button>
{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">
<button
onClick={() => setSuggestion(null)}

View File

@@ -115,7 +115,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text"
value={formData.name}
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>
@@ -125,7 +125,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text"
value={formData.distillery}
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>
@@ -136,7 +136,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text"
value={formData.category}
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>
@@ -149,7 +149,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
inputMode="decimal"
value={formData.abv}
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>
@@ -160,7 +160,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
inputMode="numeric"
value={formData.age}
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>
@@ -176,7 +176,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="YYYY"
value={formData.distilled_at}
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 className="space-y-2">
@@ -187,7 +187,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="YYYY"
value={formData.bottled_at}
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>
@@ -202,7 +202,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="0.00"
value={formData.purchase_price}
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>
@@ -225,7 +225,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
inputMode="numeric"
value={formData.whiskybase_id}
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 && (
<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"
value={formData.batch_info}
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 className="space-y-2">
@@ -273,7 +273,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="e.g. Oloroso Sherry"
value={formData.cask_type}
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>
@@ -296,7 +296,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
<button
onClick={handleSave}
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} />}
{t('bottle.saveChanges')}

View File

@@ -108,7 +108,7 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
ease: "easeInOut",
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
onClick={() => setLocale('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'
}`}
title="Deutsch"
@@ -21,7 +21,7 @@ const LanguageSwitcher = () => {
<button
onClick={() => setLocale('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'
}`}
title="English"

View File

@@ -187,7 +187,7 @@ export default function NativeOCRScanner({
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-gradient-to-b from-black/80 to-transparent">
<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>
@@ -236,7 +236,7 @@ export default function NativeOCRScanner({
</div>
{/* Detected Text Display */}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent">
<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">
@@ -247,12 +247,12 @@ export default function NativeOCRScanner({
<div className="flex gap-2 flex-wrap mb-2">
{extractedData.abv && (
<span className="px-2 py-1 bg-white/20 rounded text-white text-xs">
<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 text-white text-xs">
<span className="px-2 py-1 bg-white/20 rounded-sm text-white text-xs">
{extractedData.age} Years
</span>
)}

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

@@ -110,7 +110,7 @@ export default function OnboardingTutorial() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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 */}
<button

View File

@@ -76,7 +76,7 @@ export default function PasswordChangeForm() {
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
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
type="button"
@@ -98,7 +98,7 @@ export default function PasswordChangeForm() {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
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>

View File

@@ -129,7 +129,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
return (
<div className="space-y-6">
{/* 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">
<button
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
? 'border-orange-500/30'
: 'border-zinc-800 opacity-60'
} shadow-sm relative`}
} shadow-xs relative`}
>
{!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
</div>
)}
@@ -210,7 +210,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
{/* Edit/Create Modal */}
{(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="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-white uppercase tracking-tighter">
@@ -233,7 +233,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
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>
@@ -243,7 +243,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
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>
@@ -255,7 +255,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
type="number"
value={formData.monthly_credits}
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>
@@ -265,7 +265,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
step="0.01"
value={formData.price}
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>
@@ -277,7 +277,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Brief description of the plan"
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>
@@ -288,7 +288,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
type="number"
value={formData.sort_order}
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 className="flex items-end">
@@ -297,7 +297,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
type="checkbox"
checked={formData.is_active}
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>
</label>

View File

@@ -78,7 +78,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
value={username}
onChange={(e) => setUsername(e.target.value)}
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>

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"
>
{/* 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 */}
<div className="absolute inset-0">
{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-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>
{/* Content Overlay */}

View File

@@ -354,12 +354,12 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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 */}
<button
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} />
</button>
@@ -520,7 +520,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
<motion.div
initial={{ opacity: 0 }}
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" />
<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

@@ -76,7 +76,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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 */}
@@ -85,7 +85,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
animate={{ y: 0 }}
exit={{ y: '100%' }}
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 */}
<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)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateSession()}
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
onClick={handleCreateSession}

View File

@@ -170,7 +170,7 @@ export default function SessionList() {
value={newName}
onChange={(e) => setNewName(e.target.value)}
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
type="submit"
@@ -201,7 +201,7 @@ export default function SessionList() {
<div
key={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-500/[0.03] border-orange-500/40 shadow-[0_0_40px_rgba(234,88,12,0.1)]'
? 'bg-orange-500/3 border-orange-500/40 shadow-[0_0_40px_rgba(234,88,12,0.1)]'
: 'bg-zinc-950/50 border-white/5 hover:border-white/10'
}`}
>
@@ -289,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 -space-x-1.5 overflow-hidden">
{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()}
</div>
))}
{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}
</div>
)}

View File

@@ -67,11 +67,11 @@ export default function SessionTimeline({ tastings, sessionStart, isBlind, isRev
return (
<div key={tasting.id} className="relative group">
{/* 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 && showDetails ? '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 && showDetails && <Droplets size={8} className="text-white fill-white" />}
</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="min-w-0">
<div className="flex items-center gap-2 mb-1">
@@ -92,7 +92,7 @@ export default function SessionTimeline({ tastings, sessionStart, isBlind, isRev
{displayName}
</Link>
) : (
<div className="text-sm font-bold text-zinc-100 bg-zinc-800/30 blur-[4px] px-2 py-0.5 rounded-md select-none">
<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>
)}

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} />
{split.amountCl}cl
</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}
</span>
</>

View File

@@ -75,23 +75,23 @@ export default function SplitProgressBar({
{showLabels && (
<div className="flex flex-wrap gap-3 text-[10px] font-bold">
<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>
</div>
{taken > 0 && (
<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>
</div>
)}
{reserved > 0 && (
<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>
</div>
)}
<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>
</div>
</div>

View File

@@ -90,7 +90,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
value={search}
onChange={(e) => setSearch(e.target.value)}
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 && (
<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}
type="button"
onClick={() => onToggleTag(tag.id)}
className="px-3 py-1.5 rounded-xl bg-orange-950/20 text-orange-500 text-[10px] font-black uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-orange-600/20 flex items-center gap-1.5 shadow-sm"
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} />
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}

View File

@@ -376,7 +376,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleName}
onChange={(e) => setBottleName(e.target.value)}
placeholder="e.g. 12 Year Old"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none 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'
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>
@@ -398,7 +398,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleDistillery}
onChange={(e) => setBottleDistillery(e.target.value)}
placeholder="e.g. Lagavulin"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none 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'
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>
@@ -420,7 +420,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleAbv}
onChange={(e) => setBottleAbv(e.target.value)}
placeholder="43.0"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none 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'
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>
@@ -439,7 +439,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleAge}
onChange={(e) => setBottleAge(e.target.value)}
placeholder="12"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none 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'
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>
@@ -455,7 +455,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleCategory}
onChange={(e) => setBottleCategory(e.target.value)}
placeholder="e.g. Single Malt"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
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>
{/* Cask Type */}
@@ -475,7 +475,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleCaskType}
onChange={(e) => setBottleCaskType(e.target.value)}
placeholder="e.g. Oloroso Sherry Cask"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none 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'
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>
@@ -492,7 +492,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleVintage}
onChange={(e) => setBottleVintage(e.target.value)}
placeholder="e.g. 2007"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
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>
@@ -506,7 +506,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleBottler}
onChange={(e) => setBottleBottler(e.target.value)}
placeholder="e.g. Independent Bottler"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
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>
@@ -520,7 +520,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleDistilledAt}
onChange={(e) => setBottleDistilledAt(e.target.value)}
placeholder="e.g. 2007"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
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>
@@ -534,7 +534,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleBottledAt}
onChange={(e) => setBottleBottledAt(e.target.value)}
placeholder="e.g. 2024"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
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>
@@ -548,7 +548,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleBatchInfo}
onChange={(e) => setBottleBatchInfo(e.target.value)}
placeholder="e.g. Oloroso Sherry Cask"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
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>
@@ -562,7 +562,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleCode}
onChange={(e) => setBottleCode(e.target.value)}
placeholder="e.g. WB271235"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-none focus:border-orange-600 transition-colors"
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>
@@ -575,7 +575,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
<select
value={status}
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="open">Offen</option>
@@ -672,7 +672,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
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-none transition-all"
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">
@@ -682,7 +682,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
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-none transition-all"
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">
@@ -692,7 +692,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
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-none transition-all"
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>
@@ -729,7 +729,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
</div>
{/* Fixed/Sticky Footer for Save Action */}
<div className="w-full p-6 bg-gradient-to-t from-zinc-950 via-zinc-950/95 to-transparent border-t border-white/5 shrink-0 z-20">
<div className="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">
<button
onClick={handleInternalSave}

View File

@@ -161,7 +161,7 @@ export default function TastingFormBody({
onChange={(e) => setNose(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
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>
@@ -213,7 +213,7 @@ export default function TastingFormBody({
onChange={(e) => setPalate(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
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>
@@ -281,7 +281,7 @@ export default function TastingFormBody({
onChange={(e) => setFinish(e.target.value)}
placeholder={t('tasting.notesPlaceholder')}
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>

View File

@@ -179,7 +179,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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 */}
@@ -188,7 +188,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
animate={{ y: 0 }}
exit={{ y: '100%' }}
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 */}
<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}
onChange={(e) => setNewName(e.target.value)}
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
type="submit"

View File

@@ -149,7 +149,7 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
{sortedTastings.map((note) => (
<div
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-wrap items-center gap-2 sm:gap-3">
@@ -192,7 +192,7 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
<button
onClick={() => note.id && note.bottle_id && handleDelete(note.id, note.bottle_id)}
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"
>
{isDeleting === note.id ? (

View File

@@ -305,7 +305,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
)}
{/* 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
type="submit"
disabled={loading}

View File

@@ -274,7 +274,7 @@ export default function UploadQueue() {
if (totalInQueue === 0) return null;
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">
{!isCollapsed ? (
<motion.div

View File

@@ -166,7 +166,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
return (
<div className="space-y-6">
{/* 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">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={20} />
<input
@@ -174,13 +174,13 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
placeholder="Search users by email or username..."
value={searchTerm}
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>
{/* 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>
<div className="overflow-x-auto -mx-6">
<table className="w-full">
@@ -235,7 +235,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
{/* Edit Modal */}
{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="flex items-center justify-between">
<div>
@@ -278,7 +278,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
value={creditAmount}
onChange={(e) => setCreditAmount(e.target.value)}
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>
@@ -288,7 +288,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
value={reason}
onChange={(e) => setReason(e.target.value)}
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>
@@ -319,7 +319,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
<select
value={selectedPlan}
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>
{plans.map(plan => (
@@ -352,7 +352,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
value={dailyLimit}
onChange={(e) => setDailyLimit(e.target.value)}
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>
@@ -361,7 +361,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
type="number"
value={googleCost}
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>
@@ -370,7 +370,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
type="number"
value={geminiCost}
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>

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,
};
}

View File

@@ -18,19 +18,28 @@ const translations: Record<Locale, TranslationKeys> = { de, en };
const I18nContext = createContext<I18nContextType | undefined>(undefined);
export const I18nProvider = ({ children }: { children: ReactNode }) => {
const [locale, setLocaleState] = useState<Locale>('de');
const [locale, setLocaleState] = useState<Locale>('en');
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
// Only run on client side
if (typeof window === 'undefined') return;
// Check for saved preference first
const savedLocale = localStorage.getItem('locale') as Locale;
if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) {
setLocaleState(savedLocale);
} else {
// Try to detect browser language
const browserLang = navigator.language.split('-')[0];
if (browserLang === 'en') {
setLocaleState('en');
// Auto-detect from browser: default to English, switch to German if detected
const browserLang = navigator.language?.toLowerCase() || 'en';
if (browserLang.startsWith('de')) {
setLocaleState('de');
localStorage.setItem('locale', 'de');
} else {
localStorage.setItem('locale', 'en');
}
}
setIsInitialized(true);
}, []);
const setLocale = (newLocale: Locale) => {

View File

@@ -199,6 +199,10 @@ export const de: TranslationKeys = {
activity: 'Aktivität',
search: 'Suchen',
profile: 'Profil',
sessions: 'Tastings',
buddies: 'Buddies',
stats: 'Statistik',
wishlist: 'Wunschliste',
},
hub: {
title: 'Activity Hub',

View File

@@ -199,6 +199,10 @@ export const en: TranslationKeys = {
activity: 'Activity',
search: 'Search',
profile: 'Profile',
sessions: 'Tastings',
buddies: 'Buddies',
stats: 'Stats',
wishlist: 'Wishlist',
},
hub: {
title: 'Activity Hub',

View File

@@ -197,6 +197,10 @@ export type TranslationKeys = {
activity: string;
search: string;
profile: string;
sessions: string;
buddies: string;
stats: string;
wishlist: string;
};
hub: {
title: string;

33
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,33 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
const dsn = process.env.GLITCHTIP_DSN || process.env.NEXT_PUBLIC_GLITCHTIP_DSN;
if (!dsn) {
console.log("[Sentry] Instrumentation disabled - no DSN configured");
return;
}
if (process.env.NEXT_RUNTIME === "nodejs") {
// Server-side initialization
Sentry.init({
dsn,
environment: process.env.NODE_ENV,
sampleRate: 1.0,
tracesSampleRate: 0.1,
debug: process.env.NODE_ENV === "development",
});
console.log("[Sentry] Server initialized via instrumentation");
}
if (process.env.NEXT_RUNTIME === "edge") {
// Edge runtime initialization
Sentry.init({
dsn,
environment: process.env.NODE_ENV,
sampleRate: 1.0,
tracesSampleRate: 0.05,
});
console.log("[Sentry] Edge initialized via instrumentation");
}
}

View File

@@ -4,15 +4,19 @@ import type { SupabaseClient } from '@supabase/supabase-js';
let supabaseClient: SupabaseClient | null = null;
export function createClient() {
if (supabaseClient) return supabaseClient;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase URL and Anon Key must be defined');
if (typeof window === 'undefined') {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
return createBrowserClient(supabaseUrl, supabaseAnonKey);
}
supabaseClient = createBrowserClient(supabaseUrl, supabaseAnonKey);
return supabaseClient;
// Singleton for client-side to prevent multiple instances
// Use window object to persist across module reloads in dev
if (!(window as any).supabase) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
(window as any).supabase = createBrowserClient(supabaseUrl, supabaseAnonKey);
}
return (window as any).supabase as SupabaseClient;
}

90
src/middleware.ts Normal file
View File

@@ -0,0 +1,90 @@
import { type NextRequest, NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet: any[]) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({
request: {
headers: request.headers,
},
});
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
// Refresh session if expired - required for Server Components
const {
data: { user },
} = await supabase.auth.getUser();
const path = request.nextUrl.pathname;
// 1. Define Public Routes (Whitelist)
const isPublic =
path === "/" ||
path.startsWith("/auth") || // Auth callbacks
path.startsWith("/api") || // API routes (often handle their own auth or are public)
path === "/manifest.webmanifest" ||
path === "/sw.js" ||
path === "/offline" ||
path.startsWith("/icons") ||
path.startsWith("/_next"); // Static assets
// 2. Specialized Logic for /splits
const isSplitsPublic =
path.startsWith("/splits/") &&
!path.startsWith("/splits/create") &&
!path.startsWith("/splits/manage");
if (isPublic || isSplitsPublic) {
return response;
}
// 3. Protected Routes
// If no user, redirect to Home (which acts as Login)
if (!user) {
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = "/";
// Add redirect param so Client can show Login Modal if needed?
// Or just let them land on Home.
// Ideally we'd persist the return URL:
redirectUrl.searchParams.set("redirect_to", path);
return NextResponse.redirect(redirectUrl);
}
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};

View File

@@ -1,53 +0,0 @@
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function proxy(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({
request: {
headers: request.headers,
},
});
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
const url = new URL(request.url);
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
if (!isStatic) {
const status = user ? `User:${user.id.slice(0, 8)}` : 'No Session';
console.log(`[Proxy] ${request.method} ${url.pathname} | ${status}`);
}
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};

View File

@@ -0,0 +1,191 @@
'use server';
import { Mistral } from '@mistralai/mistralai';
import { getSystemPrompt } from '@/lib/ai-prompts';
import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky';
import { createClient } from '@/lib/supabase/server';
import { createHash } from 'crypto';
import { trackApiUsage } from './track-api-usage';
import { checkCreditBalance, deductCredits } from './credit-service';
// WICHTIG: Wir akzeptieren jetzt FormData statt Strings
export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse & { search_string?: string }> {
if (!process.env.MISTRAL_API_KEY) {
return { success: false, error: 'MISTRAL_API_KEY is not configured.' };
}
let supabase;
try {
// Helper to get value from either FormData or POJO
const getValue = (obj: any, key: string): any => {
if (obj && typeof obj.get === 'function') return obj.get(key);
if (obj && typeof obj[key] !== 'undefined') return obj[key];
return null;
};
// 1. Daten extrahieren
const file = getValue(input, 'file') as File;
const tagsString = getValue(input, 'tags') as string;
const locale = getValue(input, 'locale') || 'de';
if (!file) {
return { success: false, error: 'Kein Bild empfangen.' };
}
const tags = tagsString ? (typeof tagsString === 'string' ? JSON.parse(tagsString) : tagsString) : [];
// 2. Auth & Credits
supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
}
const userId = user.id;
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
return {
success: false,
error: `Nicht genügend Credits. Benötigt: ${creditCheck.cost}, Verfügbar: ${creditCheck.balance}.`
};
}
// 3. Datei in Buffer umwandeln
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 4. Hash für Cache erstellen
const imageHash = createHash('sha256').update(buffer).digest('hex');
// Cache Check
const { data: cachedResult } = await supabase
.from('vision_cache')
.select('result')
.eq('hash', imageHash)
.maybeSingle();
if (cachedResult) {
return {
success: true,
data: cachedResult.result as any,
perf: {
apiDuration: 0,
parseDuration: 0,
uploadSize: buffer.length
}
};
}
// 5. Für Mistral vorbereiten
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
const base64Data = buffer.toString('base64');
const mimeType = file.type || 'image/webp';
const dataUrl = `data:${mimeType};base64,${base64Data}`;
const uploadSize = buffer.length;
const prompt = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale);
try {
const startApi = performance.now();
const chatResponse = await client.chat.complete({
model: 'mistral-large-latest',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', imageUrl: dataUrl }
]
}
],
responseFormat: { type: 'json_object' },
temperature: 0.1
});
const endApi = performance.now();
const startParse = performance.now();
const rawContent = chatResponse.choices?.[0].message.content;
if (!rawContent) throw new Error("Keine Antwort von Mistral");
let jsonData;
try {
jsonData = JSON.parse(rawContent as string);
} catch (e) {
const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
jsonData = JSON.parse(cleanedText);
}
if (Array.isArray(jsonData)) jsonData = jsonData[0];
console.log('[Mistral AI] JSON Response:', jsonData);
const searchString = jsonData.search_string;
delete jsonData.search_string;
if (typeof jsonData.abv === 'string') {
// Fix: Global replace to remove all % signs
jsonData.abv = parseFloat(jsonData.abv.replace(/%/g, '').trim());
}
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
const validatedData = BottleMetadataSchema.parse(jsonData);
const endParse = performance.now();
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: true,
provider: 'mistral',
model: 'mistral-large-latest',
responseText: rawContent as string
});
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
await supabase
.from('vision_cache')
.insert({ hash: imageHash, result: validatedData });
return {
success: true,
data: validatedData,
search_string: searchString,
perf: {
apiDuration: endApi - startApi,
parseDuration: endParse - startParse,
uploadSize: uploadSize
},
raw: jsonData
};
} catch (aiError: any) {
console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: false,
errorMessage: aiError.message,
provider: 'mistral',
model: 'mistral-large-latest'
});
return {
success: false,
isAiError: true,
error: aiError.message,
imageHash: imageHash
} as any;
}
} catch (error) {
console.error('Mistral Analysis Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',
};
}
}

View File

@@ -0,0 +1,123 @@
'use server';
import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
export interface Banner {
id: string;
title: string;
image_url: string;
link_target: string | null;
cta_text: string;
is_active: boolean;
created_at: string;
updated_at: string;
}
export async function getBanners(): Promise<{ banners: Banner[]; error: string | null }> {
const supabase = await createClient();
const { data, error } = await supabase
.from('app_banners')
.select('*')
.order('created_at', { ascending: false });
if (error) {
console.error('[getBanners] Error:', error);
return { banners: [], error: error.message };
}
return { banners: data || [], error: null };
}
export async function createBanner(formData: FormData): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient();
const title = formData.get('title') as string;
const image_url = formData.get('image_url') as string;
const link_target = formData.get('link_target') as string || null;
const cta_text = formData.get('cta_text') as string || 'Open';
if (!title || !image_url) {
return { success: false, error: 'Title and image URL are required' };
}
const { error } = await supabase
.from('app_banners')
.insert({ title, image_url, link_target, cta_text, is_active: false });
if (error) {
console.error('[createBanner] Error:', error);
return { success: false, error: error.message };
}
revalidatePath('/admin/banners');
return { success: true };
}
export async function updateBanner(id: string, formData: FormData): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient();
const title = formData.get('title') as string;
const image_url = formData.get('image_url') as string;
const link_target = formData.get('link_target') as string || null;
const cta_text = formData.get('cta_text') as string || 'Open';
const { error } = await supabase
.from('app_banners')
.update({ title, image_url, link_target, cta_text })
.eq('id', id);
if (error) {
console.error('[updateBanner] Error:', error);
return { success: false, error: error.message };
}
revalidatePath('/admin/banners');
revalidatePath('/');
return { success: true };
}
export async function toggleBannerActive(id: string, isActive: boolean): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient();
// If activating, first deactivate all other banners (only one active at a time)
if (isActive) {
await supabase
.from('app_banners')
.update({ is_active: false })
.neq('id', id);
}
const { error } = await supabase
.from('app_banners')
.update({ is_active: isActive })
.eq('id', id);
if (error) {
console.error('[toggleBannerActive] Error:', error);
return { success: false, error: error.message };
}
revalidatePath('/admin/banners');
revalidatePath('/');
return { success: true };
}
export async function deleteBanner(id: string): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient();
const { error } = await supabase
.from('app_banners')
.delete()
.eq('id', id);
if (error) {
console.error('[deleteBanner] Error:', error);
return { success: false, error: error.message };
}
revalidatePath('/admin/banners');
revalidatePath('/');
return { success: true };
}

View File

@@ -1,20 +0,0 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
},
},
plugins: [],
};
export default config;