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} */
|
/** @type {import('next').Config} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
productionBrowserSourceMaps: false,
|
// Enable source maps for Sentry stack traces in production
|
||||||
|
productionBrowserSourceMaps: !!process.env.GLITCHTIP_DSN,
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '10mb',
|
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",
|
"@ai-sdk/google": "^2.0.51",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@mistralai/mistralai": "^1.11.0",
|
"@mistralai/mistralai": "^1.11.0",
|
||||||
|
"@sentry/nextjs": "^10.34.0",
|
||||||
"@supabase/ssr": "^0.5.2",
|
"@supabase/ssr": "^0.5.2",
|
||||||
"@supabase/supabase-js": "^2.47.10",
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
"@tanstack/react-query": "^5.62.7",
|
"@tanstack/react-query": "^5.62.7",
|
||||||
|
|||||||
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)
|
// CONFIG: Assets - Only essential files, no heavy OCR (~2MB instead of ~50MB)
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
@@ -189,22 +189,33 @@ self.addEventListener('fetch', (event) => {
|
|||||||
if (isNavigation || isAsset) {
|
if (isNavigation || isAsset) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(event.request).then(async (cachedResponse) => {
|
caches.match(event.request).then(async (cachedResponse) => {
|
||||||
const fetchPromise = fetchWithTimeout(event.request, 10000)
|
// Try network first
|
||||||
.then(async (networkResponse) => {
|
try {
|
||||||
|
const networkResponse = await fetchWithTimeout(event.request, 10000);
|
||||||
if (networkResponse && networkResponse.status === 200) {
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
const cache = await caches.open(CACHE_NAME);
|
const cache = await caches.open(CACHE_NAME);
|
||||||
cache.put(event.request, networkResponse.clone());
|
cache.put(event.request, networkResponse.clone());
|
||||||
}
|
}
|
||||||
return networkResponse;
|
return networkResponse;
|
||||||
}).catch(() => { });
|
} catch (networkError) {
|
||||||
|
// Network failed, fall back to cache
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For navigation, try to serve the app shell
|
||||||
if (isNavigation) {
|
if (isNavigation) {
|
||||||
if (cachedResponse) return cachedResponse;
|
|
||||||
const shell = await caches.match('/');
|
const shell = await caches.match('/');
|
||||||
if (shell) return shell;
|
if (shell) return shell;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedResponse || fetchPromise || fetch(event.request);
|
// Last resort: return a proper error response
|
||||||
|
return new Response('Offline - Resource not available', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable',
|
||||||
|
headers: { 'Content-Type': 'text/plain' }
|
||||||
|
});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
Manage Users
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/banners"
|
||||||
|
className="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
Manage Banners
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/bottles"
|
||||||
|
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Bottles
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/splits"
|
||||||
|
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Splits
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/tastings"
|
||||||
|
className="px-4 py-2 bg-pink-600 hover:bg-pink-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Tastings
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/sessions"
|
||||||
|
className="px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-xl font-bold transition-colors"
|
||||||
|
>
|
||||||
|
All Sessions
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
className="px-4 py-2 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl font-bold hover:bg-zinc-800 transition-colors"
|
||||||
|
|||||||
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 { useEffect } from 'react';
|
||||||
import { AlertTriangle, RefreshCcw } from 'lucide-react';
|
import { AlertTriangle, RefreshCcw } from 'lucide-react';
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
export default function Error({
|
export default function Error({
|
||||||
error,
|
error,
|
||||||
@@ -12,8 +13,11 @@ export default function Error({
|
|||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error('App Crash Error:', error);
|
console.error('App Crash Error:', error);
|
||||||
|
// Report error to Sentry/GlitchTip
|
||||||
|
Sentry.captureException(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-50 dark:bg-black text-center">
|
<div className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-50 dark:bg-black text-center">
|
||||||
<div className="bg-white dark:bg-zinc-900 p-8 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xl max-w-md w-full space-y-6">
|
<div className="bg-white dark:bg-zinc-900 p-8 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xl max-w-md w-full space-y-6">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { RefreshCcw } from 'lucide-react';
|
import { RefreshCcw } from 'lucide-react';
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function GlobalError({
|
export default function GlobalError({
|
||||||
error,
|
error,
|
||||||
@@ -9,6 +11,11 @@ export default function GlobalError({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Report error to Sentry/GlitchTip
|
||||||
|
Sentry.captureException(error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import SyncHandler from "@/components/SyncHandler";
|
|||||||
import CookieBanner from "@/components/CookieBanner";
|
import CookieBanner from "@/components/CookieBanner";
|
||||||
import OnboardingTutorial from "@/components/OnboardingTutorial";
|
import OnboardingTutorial from "@/components/OnboardingTutorial";
|
||||||
import BackgroundRemovalHandler from "@/components/BackgroundRemovalHandler";
|
import BackgroundRemovalHandler from "@/components/BackgroundRemovalHandler";
|
||||||
|
import SentryInit from "@/components/SentryInit";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
|
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
|
||||||
|
|
||||||
@@ -49,7 +50,9 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="de" suppressHydrationWarning={true}>
|
<html lang="de" suppressHydrationWarning={true}>
|
||||||
<body className={`${inter.variable} font-sans`}>
|
<body className={`${inter.variable} font-sans`}>
|
||||||
|
<SentryInit />
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ActiveSessionBanner />
|
<ActiveSessionBanner />
|
||||||
|
|||||||
154
src/app/page.tsx
154
src/app/page.tsx
@@ -1,30 +1,31 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import BottleGrid from "@/components/BottleGrid";
|
import BottleGrid from "@/components/BottleGrid";
|
||||||
import AuthForm from "@/components/AuthForm";
|
import AuthForm from "@/components/AuthForm";
|
||||||
import BuddyList from "@/components/BuddyList";
|
|
||||||
import SessionList from "@/components/SessionList";
|
|
||||||
import StatsDashboard from "@/components/StatsDashboard";
|
|
||||||
import DramOfTheDay from "@/components/DramOfTheDay";
|
|
||||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||||
import OfflineIndicator from "@/components/OfflineIndicator";
|
import OfflineIndicator from "@/components/OfflineIndicator";
|
||||||
import { useI18n } from "@/i18n/I18nContext";
|
import { useI18n } from "@/i18n/I18nContext";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { useSession } from "@/context/SessionContext";
|
import { useSession } from "@/context/SessionContext";
|
||||||
import TastingHub from "@/components/TastingHub";
|
import TastingHub from "@/components/TastingHub";
|
||||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
import { Sparkles, Loader2, Search, SlidersHorizontal, Settings, CircleUser } from "lucide-react";
|
||||||
import { BottomNavigation } from '@/components/BottomNavigation';
|
import { BottomNavigation } from '@/components/BottomNavigation';
|
||||||
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
||||||
import UserStatusBadge from '@/components/UserStatusBadge';
|
import UserStatusBadge from '@/components/UserStatusBadge';
|
||||||
import { getActiveSplits } from '@/services/split-actions';
|
import { getActiveSplits } from '@/services/split-actions';
|
||||||
import SplitCard from '@/components/SplitCard';
|
import SplitCard from '@/components/SplitCard';
|
||||||
|
import HeroBanner from '@/components/HeroBanner';
|
||||||
|
import QuickActionsGrid from '@/components/QuickActionsGrid';
|
||||||
|
import DramOfTheDay from '@/components/DramOfTheDay';
|
||||||
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [bottles, setBottles] = useState<any[]>([]);
|
const [bottles, setBottles] = useState<any[]>([]);
|
||||||
const { user, isLoading: isAuthLoading } = useAuth();
|
const { user, isLoading: isAuthLoading } = useAuth();
|
||||||
const [isInternalLoading, setIsInternalLoading] = useState(false);
|
const [isInternalLoading, setIsInternalLoading] = useState(false);
|
||||||
@@ -36,6 +37,7 @@ export default function Home() {
|
|||||||
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
||||||
const [hasMounted, setHasMounted] = useState(false);
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
const [publicSplits, setPublicSplits] = useState<any[]>([]);
|
const [publicSplits, setPublicSplits] = useState<any[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasMounted(true);
|
setHasMounted(true);
|
||||||
@@ -47,7 +49,6 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if auth is ready and user exists
|
|
||||||
if (!isAuthLoading && user) {
|
if (!isAuthLoading && user) {
|
||||||
fetchCollection();
|
fetchCollection();
|
||||||
} else if (!isAuthLoading && !user) {
|
} else if (!isAuthLoading && !user) {
|
||||||
@@ -56,14 +57,12 @@ export default function Home() {
|
|||||||
}, [user, isAuthLoading]);
|
}, [user, isAuthLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch public splits if guest
|
|
||||||
getActiveSplits().then(res => {
|
getActiveSplits().then(res => {
|
||||||
if (res.success && res.splits) {
|
if (res.success && res.splits) {
|
||||||
setPublicSplits(res.splits);
|
setPublicSplits(res.splits);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for collection updates (e.g., after offline sync completes)
|
|
||||||
const handleCollectionUpdated = () => {
|
const handleCollectionUpdated = () => {
|
||||||
console.log('[Home] Collection update event received, refreshing...');
|
console.log('[Home] Collection update event received, refreshing...');
|
||||||
fetchCollection();
|
fetchCollection();
|
||||||
@@ -78,7 +77,6 @@ export default function Home() {
|
|||||||
const fetchCollection = async () => {
|
const fetchCollection = async () => {
|
||||||
setIsInternalLoading(true);
|
setIsInternalLoading(true);
|
||||||
try {
|
try {
|
||||||
// Fetch bottles with their latest tasting date
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('bottles')
|
.from('bottles')
|
||||||
.select(`
|
.select(`
|
||||||
@@ -90,13 +88,10 @@ export default function Home() {
|
|||||||
`)
|
`)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) throw error;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Fetched ${data?.length || 0} bottles from Supabase`);
|
console.log(`Fetched ${data?.length || 0} bottles from Supabase`);
|
||||||
|
|
||||||
// Process data to get the absolute latest tasting date for each bottle
|
|
||||||
const processedBottles = (data || []).map(bottle => {
|
const processedBottles = (data || []).map(bottle => {
|
||||||
const lastTasted = bottle.tastings && bottle.tastings.length > 0
|
const lastTasted = bottle.tastings && bottle.tastings.length > 0
|
||||||
? bottle.tastings.reduce((latest: string, current: any) =>
|
? bottle.tastings.reduce((latest: string, current: any) =>
|
||||||
@@ -105,41 +100,18 @@ export default function Home() {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return { ...bottle, last_tasted: lastTasted };
|
||||||
...bottle,
|
|
||||||
last_tasted: lastTasted
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setBottles(processedBottles);
|
setBottles(processedBottles);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Enhanced logging for empty-looking error objects
|
console.warn('[Home] Fetch collection error:', err?.message);
|
||||||
console.warn('[Home] Fetch collection error caught:', {
|
|
||||||
name: err?.name,
|
|
||||||
message: err?.message,
|
|
||||||
keys: err ? Object.keys(err) : [],
|
|
||||||
allProps: err ? Object.getOwnPropertyNames(err) : [],
|
|
||||||
stack: err?.stack,
|
|
||||||
online: navigator.onLine
|
|
||||||
});
|
|
||||||
|
|
||||||
// Silently skip if offline or common network failure
|
|
||||||
const isNetworkError = !navigator.onLine ||
|
const isNetworkError = !navigator.onLine ||
|
||||||
err?.name === 'TypeError' ||
|
err?.name === 'TypeError' ||
|
||||||
err?.message?.includes('Failed to fetch') ||
|
err?.message?.includes('Failed to fetch');
|
||||||
err?.message?.includes('NetworkError') ||
|
|
||||||
err?.message?.includes('ERR_INTERNET_DISCONNECTED') ||
|
|
||||||
(err && typeof err === 'object' && !err.message && Object.keys(err).length === 0);
|
|
||||||
|
|
||||||
if (isNetworkError) {
|
if (!isNetworkError) {
|
||||||
console.log('[fetchCollection] Skipping due to offline mode or network error');
|
setFetchError(err?.message || 'Unknown error');
|
||||||
setFetchError(null);
|
|
||||||
} else {
|
|
||||||
console.error('Detailed fetch error:', err);
|
|
||||||
// Safe stringification for Error objects
|
|
||||||
const errorMessage = err?.message ||
|
|
||||||
(err && typeof err === 'object' ? JSON.stringify(err, Object.getOwnPropertyNames(err)) : String(err));
|
|
||||||
setFetchError(errorMessage);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsInternalLoading(false);
|
setIsInternalLoading(false);
|
||||||
@@ -150,6 +122,17 @@ export default function Home() {
|
|||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter bottles by search query
|
||||||
|
const filteredBottles = bottles.filter(bottle => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
bottle.name?.toLowerCase().includes(query) ||
|
||||||
|
bottle.distillery?.toLowerCase().includes(query) ||
|
||||||
|
bottle.category?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (!hasMounted) {
|
if (!hasMounted) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950">
|
<main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950">
|
||||||
@@ -158,6 +141,7 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guest / Login View
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950">
|
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950">
|
||||||
@@ -174,7 +158,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<AuthForm />
|
<AuthForm />
|
||||||
|
|
||||||
{!user && publicSplits.length > 0 && (
|
{publicSplits.length > 0 && (
|
||||||
<div className="mt-16 w-full max-w-lg space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-300">
|
<div className="mt-16 w-full max-w-lg space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-300">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<h2 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600/60">
|
<h2 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600/60">
|
||||||
@@ -199,16 +183,20 @@ export default function Home() {
|
|||||||
|
|
||||||
const isLoading = isAuthLoading || isInternalLoading;
|
const isLoading = isAuthLoading || isInternalLoading;
|
||||||
|
|
||||||
|
// Authenticated Home View - New Layout
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center gap-6 md:gap-12 p-4 md:p-24 bg-[var(--background)] pb-32">
|
<div className="flex flex-col min-h-screen bg-[var(--background)] relative">
|
||||||
<div className="z-10 max-w-5xl w-full flex flex-col items-center gap-12">
|
{/* Scrollable Content Area */}
|
||||||
<header className="w-full flex flex-col sm:flex-row justify-between items-center gap-4 sm:gap-0">
|
<div className="flex-1 overflow-y-auto pb-24">
|
||||||
<div className="flex flex-col items-center sm:items-start group">
|
{/* 1. Header */}
|
||||||
<h1 className="text-4xl font-bold text-zinc-50 tracking-tighter">
|
<header className="px-4 pt-4 pb-2">
|
||||||
|
<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>
|
DRAM<span className="text-orange-600">LOG</span>
|
||||||
</h1>
|
</h1>
|
||||||
{activeSession && (
|
{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">
|
<div className="relative flex h-2 w-2">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span>
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span>
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span>
|
||||||
@@ -220,40 +208,59 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-3 md:gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<UserStatusBadge />
|
<UserStatusBadge />
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<DramOfTheDay bottles={bottles} />
|
<DramOfTheDay bottles={bottles} />
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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')}
|
{t('home.logout')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="w-full">
|
{/* 2. Hero Banner (optional) */}
|
||||||
<StatsDashboard bottles={bottles} />
|
<div className="px-4 mt-2 mb-4">
|
||||||
|
<HeroBanner />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 w-full max-w-5xl">
|
|
||||||
<div className="flex flex-col gap-8">
|
{/* 3. Quick Actions Grid */}
|
||||||
<SessionList />
|
<div className="px-4 mb-4">
|
||||||
|
<QuickActionsGrid />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<BuddyList />
|
{/* 4. Sticky Search Bar */}
|
||||||
|
<div className="sticky top-0 z-20 px-4 py-3 bg-zinc-950/95 backdrop-blur-md border-b border-zinc-900">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('home.searchPlaceholder') || 'Search collection...'}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-600 focus:outline-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>
|
</div>
|
||||||
|
|
||||||
<div className="w-full mt-4" id="collection">
|
{/* 5. Collection */}
|
||||||
<div className="flex items-end justify-between mb-8">
|
<div className="px-4 mt-4">
|
||||||
<h2 className="text-3xl font-bold text-zinc-50 uppercase tracking-tight">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-50">
|
||||||
{t('home.collection')}
|
{t('home.collection')}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest pb-1">
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
|
||||||
{bottles.length} {t('home.bottleCount')}
|
{filteredBottles.length} {t('home.bottleCount')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -262,20 +269,23 @@ export default function Home() {
|
|||||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-orange-600"></div>
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-orange-600"></div>
|
||||||
</div>
|
</div>
|
||||||
) : fetchError ? (
|
) : fetchError ? (
|
||||||
<div className="p-12 bg-zinc-900 border border-zinc-800 rounded-3xl text-center">
|
<div className="p-8 bg-zinc-900 border border-zinc-800 rounded-2xl text-center">
|
||||||
<p className="text-zinc-50 font-bold text-xl mb-2">{t('common.error')}</p>
|
<p className="text-zinc-50 font-bold mb-2">{t('common.error')}</p>
|
||||||
<p className="text-zinc-500 text-xs italic mb-8 mx-auto max-w-xs">{fetchError}</p>
|
<p className="text-zinc-500 text-xs mb-6">{fetchError}</p>
|
||||||
<button
|
<button
|
||||||
onClick={fetchCollection}
|
onClick={fetchCollection}
|
||||||
className="px-10 py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl text-[10px] font-bold uppercase tracking-widest transition-all shadow-lg shadow-orange-950/20"
|
className="px-6 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-xl text-xs font-bold uppercase tracking-widest transition-all"
|
||||||
>
|
>
|
||||||
{t('home.reTry')}
|
{t('home.reTry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : filteredBottles.length > 0 ? (
|
||||||
bottles.length > 0 && <BottleGrid bottles={bottles} />
|
<BottleGrid bottles={filteredBottles} />
|
||||||
)}
|
) : bottles.length > 0 ? (
|
||||||
|
<div className="text-center py-12 text-zinc-500">
|
||||||
|
<p className="text-sm">No bottles match your search</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@@ -288,7 +298,9 @@ export default function Home() {
|
|||||||
<a href="/settings" className="hover:text-orange-500 transition-colors">{t('home.settings')}</a>
|
<a href="/settings" className="hover:text-orange-500 transition-colors">{t('home.settings')}</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Navigation with FAB */}
|
||||||
<BottomNavigation
|
<BottomNavigation
|
||||||
onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })}
|
onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
@@ -308,6 +320,6 @@ export default function Home() {
|
|||||||
imageFile={capturedFile}
|
imageFile={capturedFile}
|
||||||
onBottleSaved={() => fetchCollection()}
|
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',
|
activity: 'Aktivität',
|
||||||
search: 'Suchen',
|
search: 'Suchen',
|
||||||
profile: 'Profil',
|
profile: 'Profil',
|
||||||
|
sessions: 'Tastings',
|
||||||
|
buddies: 'Buddies',
|
||||||
|
stats: 'Statistik',
|
||||||
|
wishlist: 'Wunschliste',
|
||||||
},
|
},
|
||||||
hub: {
|
hub: {
|
||||||
title: 'Activity Hub',
|
title: 'Activity Hub',
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ export const en: TranslationKeys = {
|
|||||||
activity: 'Activity',
|
activity: 'Activity',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
|
sessions: 'Tastings',
|
||||||
|
buddies: 'Buddies',
|
||||||
|
stats: 'Stats',
|
||||||
|
wishlist: 'Wishlist',
|
||||||
},
|
},
|
||||||
hub: {
|
hub: {
|
||||||
title: 'Activity Hub',
|
title: 'Activity Hub',
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ export type TranslationKeys = {
|
|||||||
activity: string;
|
activity: string;
|
||||||
search: string;
|
search: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
|
sessions: string;
|
||||||
|
buddies: string;
|
||||||
|
stats: string;
|
||||||
|
wishlist: string;
|
||||||
};
|
};
|
||||||
hub: {
|
hub: {
|
||||||
title: string;
|
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: {
|
theme: {
|
||||||
extend: {
|
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: {
|
backgroundImage: {
|
||||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||||
"gradient-conic":
|
"gradient-conic":
|
||||||
|
|||||||
Reference in New Issue
Block a user