Compare commits

..

15 Commits

Author SHA1 Message Date
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
40 changed files with 5656 additions and 138 deletions

View File

@@ -1,7 +1,10 @@
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,
experimental: {
serverActions: {
bodySizeLimit: '10mb',
@@ -9,4 +12,33 @@ const nextConfig = {
},
};
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",

1700
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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-none 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-none 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-none 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-none 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 flex-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,56 @@
export const dynamic = 'force-dynamic';
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-none 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-none 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-none 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-none 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-sm 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-sm 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,161 @@
export const dynamic = 'force-dynamic';
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

@@ -117,6 +117,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"

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-none 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-none"
>
<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-none"
>
<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,133 @@
export const dynamic = 'force-dynamic';
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-none 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-none"
>
<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-none"
>
<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-[16/9] 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-sm 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-gradient-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,139 @@
export const dynamic = 'force-dynamic';
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

@@ -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-none 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-none"
>
<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-none"
>
<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 flex-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="flex-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,141 @@
export const dynamic = 'force-dynamic';
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

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

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-none 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-none 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

@@ -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

@@ -1,30 +1,31 @@
'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, Settings, CircleUser } 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 DramOfTheDay from '@/components/DramOfTheDay';
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 +37,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 +49,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 +57,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 +77,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 +88,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 +100,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 +122,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 +141,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">
@@ -174,7 +158,7 @@ export default function Home() {
</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 +183,20 @@ 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-[var(--background)] relative">
{/* Scrollable Content Area */}
<div className="flex-1 overflow-y-auto pb-24">
{/* 1. Header */}
<header className="px-4 pt-4 pb-2">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<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">
<div className="flex items-center gap-2 mt-0.5 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>
@@ -220,40 +208,59 @@ export default function Home() {
</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"
className="text-[9px] font-bold uppercase tracking-widest text-zinc-600 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-none 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 +269,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 +298,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 +320,6 @@ export default function Home() {
imageFile={capturedFile}
onBottleSaved={() => fetchCollection()}
/>
</main>
</div>
);
}

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-none 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-gradient-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>
);
}

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

@@ -0,0 +1,71 @@
'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';
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="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin"></div>
</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

@@ -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

@@ -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-gradient-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

@@ -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

@@ -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

@@ -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

@@ -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");
}
}

97
src/middleware.ts Normal file
View File

@@ -0,0 +1,97 @@
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
// - Public: /splits/[slug]
// - Protected: /splits/create, /splits/manage
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);
}
// 4. Admin Protection (Optional Layer in Middleware, but Page also checks)
// We rely on Page component for Admin role check to avoid DB hit in middleware if possible,
// OR we can trust getUser() + Page logic.
// Middleware mainly ensures *Authentication*. Authorization is Page level.
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

@@ -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

@@ -8,6 +8,23 @@ const config: Config = {
],
theme: {
extend: {
// High-contrast zinc scale for better readability
// Original zinc-500 (#71717a) is now brighter for better contrast on dark backgrounds
colors: {
zinc: {
50: '#fafafa',
100: '#f4f4f5',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#a8a8b3', // Brighter (was #a1a1aa)
500: '#8a8a95', // Brighter (was #71717a) - main secondary text
600: '#6b6b75', // Brighter (was #52525b) - subtle text
700: '#4a4a52', // Brighter (was #3f3f46)
800: '#2a2a2e', // Slightly adjusted
900: '#1a1a1e', // Dark background
950: '#0d0d0f', // Darkest
},
},
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":