fix: Fix navigation links and restore LanguageSwitcher
- Add missing nav keys to types.ts, de.ts, en.ts (sessions, buddies, stats, wishlist) - Add LanguageSwitcher back to authenticated header - Create /sessions page with SessionList - Create /buddies page with BuddyList - Create /stats page with StatsDashboard - Create /wishlist placeholder page
This commit is contained in:
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();
|
||||||
38
src/app/buddies/page.tsx
Normal file
38
src/app/buddies/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Users } from 'lucide-react';
|
||||||
|
import BuddyList from '@/components/BuddyList';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
|
export default function BuddiesPage() {
|
||||||
|
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('buddy.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Your tasting companions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buddy List */}
|
||||||
|
<BuddyList />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
src/app/page.tsx
229
src/app/page.tsx
@@ -5,22 +5,21 @@ import { useRouter } 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 } 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';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -36,6 +35,7 @@ export default function Home() {
|
|||||||
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
||||||
const [hasMounted, setHasMounted] = useState(false);
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
const [publicSplits, setPublicSplits] = useState<any[]>([]);
|
const [publicSplits, setPublicSplits] = useState<any[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasMounted(true);
|
setHasMounted(true);
|
||||||
@@ -47,7 +47,6 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch if auth is ready and user exists
|
|
||||||
if (!isAuthLoading && user) {
|
if (!isAuthLoading && user) {
|
||||||
fetchCollection();
|
fetchCollection();
|
||||||
} else if (!isAuthLoading && !user) {
|
} else if (!isAuthLoading && !user) {
|
||||||
@@ -56,14 +55,12 @@ export default function Home() {
|
|||||||
}, [user, isAuthLoading]);
|
}, [user, isAuthLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch public splits if guest
|
|
||||||
getActiveSplits().then(res => {
|
getActiveSplits().then(res => {
|
||||||
if (res.success && res.splits) {
|
if (res.success && res.splits) {
|
||||||
setPublicSplits(res.splits);
|
setPublicSplits(res.splits);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for collection updates (e.g., after offline sync completes)
|
|
||||||
const handleCollectionUpdated = () => {
|
const handleCollectionUpdated = () => {
|
||||||
console.log('[Home] Collection update event received, refreshing...');
|
console.log('[Home] Collection update event received, refreshing...');
|
||||||
fetchCollection();
|
fetchCollection();
|
||||||
@@ -78,25 +75,21 @@ 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(`
|
||||||
*,
|
*,
|
||||||
tastings (
|
tastings (
|
||||||
created_at,
|
created_at,
|
||||||
rating
|
rating
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) throw error;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Fetched ${data?.length || 0} bottles from Supabase`);
|
console.log(`Fetched ${data?.length || 0} bottles from Supabase`);
|
||||||
|
|
||||||
// Process data to get the absolute latest tasting date for each bottle
|
|
||||||
const processedBottles = (data || []).map(bottle => {
|
const processedBottles = (data || []).map(bottle => {
|
||||||
const lastTasted = bottle.tastings && bottle.tastings.length > 0
|
const lastTasted = bottle.tastings && bottle.tastings.length > 0
|
||||||
? bottle.tastings.reduce((latest: string, current: any) =>
|
? bottle.tastings.reduce((latest: string, current: any) =>
|
||||||
@@ -105,41 +98,18 @@ export default function Home() {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return { ...bottle, last_tasted: lastTasted };
|
||||||
...bottle,
|
|
||||||
last_tasted: lastTasted
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setBottles(processedBottles);
|
setBottles(processedBottles);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Enhanced logging for empty-looking error objects
|
console.warn('[Home] Fetch collection error:', err?.message);
|
||||||
console.warn('[Home] Fetch collection error caught:', {
|
|
||||||
name: err?.name,
|
|
||||||
message: err?.message,
|
|
||||||
keys: err ? Object.keys(err) : [],
|
|
||||||
allProps: err ? Object.getOwnPropertyNames(err) : [],
|
|
||||||
stack: err?.stack,
|
|
||||||
online: navigator.onLine
|
|
||||||
});
|
|
||||||
|
|
||||||
// Silently skip if offline or common network failure
|
|
||||||
const isNetworkError = !navigator.onLine ||
|
const isNetworkError = !navigator.onLine ||
|
||||||
err?.name === 'TypeError' ||
|
err?.name === 'TypeError' ||
|
||||||
err?.message?.includes('Failed to fetch') ||
|
err?.message?.includes('Failed to fetch');
|
||||||
err?.message?.includes('NetworkError') ||
|
|
||||||
err?.message?.includes('ERR_INTERNET_DISCONNECTED') ||
|
|
||||||
(err && typeof err === 'object' && !err.message && Object.keys(err).length === 0);
|
|
||||||
|
|
||||||
if (isNetworkError) {
|
if (!isNetworkError) {
|
||||||
console.log('[fetchCollection] Skipping due to offline mode or network error');
|
setFetchError(err?.message || 'Unknown error');
|
||||||
setFetchError(null);
|
|
||||||
} else {
|
|
||||||
console.error('Detailed fetch error:', err);
|
|
||||||
// Safe stringification for Error objects
|
|
||||||
const errorMessage = err?.message ||
|
|
||||||
(err && typeof err === 'object' ? JSON.stringify(err, Object.getOwnPropertyNames(err)) : String(err));
|
|
||||||
setFetchError(errorMessage);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsInternalLoading(false);
|
setIsInternalLoading(false);
|
||||||
@@ -150,6 +120,17 @@ export default function Home() {
|
|||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filter bottles by search query
|
||||||
|
const filteredBottles = bottles.filter(bottle => {
|
||||||
|
if (!searchQuery.trim()) return true;
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
bottle.name?.toLowerCase().includes(query) ||
|
||||||
|
bottle.distillery?.toLowerCase().includes(query) ||
|
||||||
|
bottle.category?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (!hasMounted) {
|
if (!hasMounted) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950">
|
<main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950">
|
||||||
@@ -158,6 +139,7 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guest / Login View
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950">
|
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950">
|
||||||
@@ -174,7 +156,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,61 +181,83 @@ 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">
|
||||||
DRAM<span className="text-orange-600">LOG</span>
|
<div className="flex items-center justify-between">
|
||||||
</h1>
|
<div className="flex flex-col">
|
||||||
{activeSession && (
|
<h1 className="text-2xl font-bold text-zinc-50 tracking-tighter">
|
||||||
<div className="flex items-center gap-2 mt-1 animate-in fade-in slide-in-from-left-2 duration-700">
|
DRAM<span className="text-orange-600">LOG</span>
|
||||||
<div className="relative flex h-2 w-2">
|
</h1>
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span>
|
{activeSession && (
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span>
|
<div className="flex items-center gap-2 mt-0.5 animate-in fade-in slide-in-from-left-2 duration-700">
|
||||||
|
<div className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-widest text-orange-600 flex items-center gap-1">
|
||||||
|
<Sparkles size={10} className="animate-pulse" />
|
||||||
|
Live: {activeSession.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-orange-600 flex items-center gap-1">
|
)}
|
||||||
<Sparkles size={10} className="animate-pulse" />
|
</div>
|
||||||
Live: {activeSession.name}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<UserStatusBadge />
|
||||||
</div>
|
<OfflineIndicator />
|
||||||
)}
|
<LanguageSwitcher />
|
||||||
</div>
|
<DramOfTheDay bottles={bottles} />
|
||||||
<div className="flex flex-wrap items-center justify-center sm:justify-end gap-3 md:gap-4">
|
<button
|
||||||
<UserStatusBadge />
|
onClick={handleLogout}
|
||||||
<OfflineIndicator />
|
className="text-[9px] font-bold uppercase tracking-widest text-zinc-600 hover:text-white transition-colors"
|
||||||
<LanguageSwitcher />
|
>
|
||||||
<DramOfTheDay bottles={bottles} />
|
{t('home.logout')}
|
||||||
<button
|
</button>
|
||||||
onClick={handleLogout}
|
</div>
|
||||||
className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
{t('home.logout')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="w-full">
|
{/* 2. Hero Banner (optional) */}
|
||||||
<StatsDashboard bottles={bottles} />
|
<div className="px-4 mt-2 mb-4">
|
||||||
|
<HeroBanner />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 w-full max-w-5xl">
|
{/* 3. Quick Actions Grid */}
|
||||||
<div className="flex flex-col gap-8">
|
<div className="px-4 mb-4">
|
||||||
<SessionList />
|
<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,33 +266,38 @@ 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>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="pb-28 pt-8 text-center">
|
||||||
|
<div className="flex justify-center gap-4 text-xs text-zinc-600">
|
||||||
|
<a href="/impressum" className="hover:text-orange-500 transition-colors">{t('home.imprint')}</a>
|
||||||
|
<span>•</span>
|
||||||
|
<a href="/privacy" className="hover:text-orange-500 transition-colors">{t('home.privacy')}</a>
|
||||||
|
<span>•</span>
|
||||||
|
<a href="/settings" className="hover:text-orange-500 transition-colors">{t('home.settings')}</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Bottom Navigation with FAB */}
|
||||||
<footer className="pb-28 pt-8 text-center">
|
|
||||||
<div className="flex justify-center gap-4 text-xs text-zinc-600">
|
|
||||||
<a href="/impressum" className="hover:text-orange-500 transition-colors">{t('home.imprint')}</a>
|
|
||||||
<span>•</span>
|
|
||||||
<a href="/privacy" className="hover:text-orange-500 transition-colors">{t('home.privacy')}</a>
|
|
||||||
<span>•</span>
|
|
||||||
<a href="/settings" className="hover:text-orange-500 transition-colors">{t('home.settings')}</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<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 +317,6 @@ export default function Home() {
|
|||||||
imageFile={capturedFile}
|
imageFile={capturedFile}
|
||||||
onBottleSaved={() => fetchCollection()}
|
onBottleSaved={() => fetchCollection()}
|
||||||
/>
|
/>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
38
src/app/sessions/page.tsx
Normal file
38
src/app/sessions/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Calendar, Plus } from 'lucide-react';
|
||||||
|
import SessionList from '@/components/SessionList';
|
||||||
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
|
||||||
|
export default function SessionsPage() {
|
||||||
|
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('session.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Manage your tasting events
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session List */}
|
||||||
|
<SessionList />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/stats/page.tsx
Normal file
52
src/app/stats/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, BarChart3 } from 'lucide-react';
|
||||||
|
import StatsDashboard from '@/components/StatsDashboard';
|
||||||
|
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 supabase = createClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchBottles = async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.select('*');
|
||||||
|
setBottles(data || []);
|
||||||
|
};
|
||||||
|
fetchBottles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-zinc-950 p-4 pb-24">
|
||||||
|
<div className="max-w-4xl 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">
|
||||||
|
Your collection analytics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Dashboard */}
|
||||||
|
<StatsDashboard 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -199,6 +199,10 @@ export const de: TranslationKeys = {
|
|||||||
activity: 'Aktivität',
|
activity: 'Aktivität',
|
||||||
search: 'Suchen',
|
search: 'Suchen',
|
||||||
profile: 'Profil',
|
profile: 'Profil',
|
||||||
|
sessions: 'Events',
|
||||||
|
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: 'Events',
|
||||||
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user