Compare commits
15 Commits
d109dfad0e
...
2bf0ac0f3e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bf0ac0f3e | |||
| bb9a78f755 | |||
| 45f562e2ce | |||
| 5914ef2ac8 | |||
| 948c70c7f2 | |||
| 3c02d33531 | |||
| 6320cb14e5 | |||
| f9192f2228 | |||
| ef64c89e9b | |||
| c047966b43 | |||
| 169fa0ad63 | |||
| 886e5c121f | |||
| ef2b9dfabf | |||
| 489b975911 | |||
| 1d02079df3 |
@@ -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;
|
||||
|
||||
@@ -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
1700
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
23
public/sw.js
23
public/sw.js
@@ -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
36
sentry.client.config.ts
Normal 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
21
sentry.edge.config.ts
Normal 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
26
sentry.server.config.ts
Normal 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");
|
||||
}
|
||||
49
sql/create_app_banners.sql
Normal file
49
sql/create_app_banners.sql
Normal 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();
|
||||
396
src/app/admin/banners/BannerManager.tsx
Normal file
396
src/app/admin/banners/BannerManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/app/admin/banners/page.tsx
Normal file
56
src/app/admin/banners/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
275
src/app/admin/bottles/AdminBottlesList.tsx
Normal file
275
src/app/admin/bottles/AdminBottlesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
src/app/admin/bottles/page.tsx
Normal file
161
src/app/admin/bottles/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
220
src/app/admin/sessions/AdminSessionsList.tsx
Normal file
220
src/app/admin/sessions/AdminSessionsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/app/admin/sessions/page.tsx
Normal file
133
src/app/admin/sessions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
src/app/admin/splits/AdminSplitsList.tsx
Normal file
222
src/app/admin/splits/AdminSplitsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
src/app/admin/splits/page.tsx
Normal file
139
src/app/admin/splits/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
src/app/admin/tastings/AdminTastingsList.tsx
Normal file
226
src/app/admin/tastings/AdminTastingsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/app/admin/tastings/page.tsx
Normal file
141
src/app/admin/tastings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/app/api/glitchtip-tunnel/route.ts
Normal file
85
src/app/api/glitchtip-tunnel/route.ts
Normal 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
259
src/app/buddies/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
154
src/app/page.tsx
154
src/app/page.tsx
@@ -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
288
src/app/sessions/page.tsx
Normal 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
71
src/app/stats/page.tsx
Normal 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
47
src/app/wishlist/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
406
src/components/AnalyticsDashboard.tsx
Normal file
406
src/components/AnalyticsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/components/HeroBanner.tsx
Normal file
85
src/components/HeroBanner.tsx
Normal 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;
|
||||
}
|
||||
32
src/components/NavButton.tsx
Normal file
32
src/components/NavButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/QuickActionsGrid.tsx
Normal file
34
src/components/QuickActionsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/SentryInit.tsx
Normal file
48
src/components/SentryInit.tsx
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
33
src/instrumentation.ts
Normal 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
97
src/middleware.ts
Normal 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).*)",
|
||||
],
|
||||
};
|
||||
123
src/services/banner-actions.ts
Normal file
123
src/services/banner-actions.ts
Normal 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 };
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user