- Update I18nContext to auto-detect browser language - Default to English, switch to German if browser is German - Remove LanguageSwitcher from guest and authenticated views - Remove DramOfTheDay from header - Cleaner, mobile-friendly header layout
319 lines
14 KiB
TypeScript
319 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { createClient } from '@/lib/supabase/client';
|
|
import BottleGrid from "@/components/BottleGrid";
|
|
import AuthForm from "@/components/AuthForm";
|
|
import OfflineIndicator from "@/components/OfflineIndicator";
|
|
import { useI18n } from "@/i18n/I18nContext";
|
|
import { useAuth } from "@/context/AuthContext";
|
|
import { useSession } from "@/context/SessionContext";
|
|
import TastingHub from "@/components/TastingHub";
|
|
import { Sparkles, Loader2, Search, SlidersHorizontal } from "lucide-react";
|
|
import { BottomNavigation } from '@/components/BottomNavigation';
|
|
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
|
|
import UserStatusBadge from '@/components/UserStatusBadge';
|
|
import { getActiveSplits } from '@/services/split-actions';
|
|
import SplitCard from '@/components/SplitCard';
|
|
import HeroBanner from '@/components/HeroBanner';
|
|
import QuickActionsGrid from '@/components/QuickActionsGrid';
|
|
import { checkIsAdmin } from '@/services/track-api-usage';
|
|
|
|
export default function Home() {
|
|
const supabase = createClient();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const [bottles, setBottles] = useState<any[]>([]);
|
|
const { user, isLoading: isAuthLoading } = useAuth();
|
|
const [isInternalLoading, setIsInternalLoading] = useState(false);
|
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
|
const { t } = useI18n();
|
|
const { activeSession } = useSession();
|
|
const [isFlowOpen, setIsFlowOpen] = useState(false);
|
|
const [isTastingHubOpen, setIsTastingHubOpen] = useState(false);
|
|
const [capturedFile, setCapturedFile] = useState<File | null>(null);
|
|
const [hasMounted, setHasMounted] = useState(false);
|
|
const [publicSplits, setPublicSplits] = useState<any[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
useEffect(() => {
|
|
setHasMounted(true);
|
|
}, []);
|
|
|
|
const handleImageSelected = (file: File) => {
|
|
setCapturedFile(file);
|
|
setIsFlowOpen(true);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isAuthLoading && user) {
|
|
fetchCollection();
|
|
} else if (!isAuthLoading && !user) {
|
|
setBottles([]);
|
|
}
|
|
}, [user, isAuthLoading]);
|
|
|
|
useEffect(() => {
|
|
getActiveSplits().then(res => {
|
|
if (res.success && res.splits) {
|
|
setPublicSplits(res.splits);
|
|
}
|
|
});
|
|
|
|
const handleCollectionUpdated = () => {
|
|
console.log('[Home] Collection update event received, refreshing...');
|
|
fetchCollection();
|
|
};
|
|
window.addEventListener('collection-updated', handleCollectionUpdated);
|
|
|
|
return () => {
|
|
window.removeEventListener('collection-updated', handleCollectionUpdated);
|
|
};
|
|
}, []);
|
|
|
|
const fetchCollection = async () => {
|
|
setIsInternalLoading(true);
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('bottles')
|
|
.select(`
|
|
*,
|
|
tastings (
|
|
created_at,
|
|
rating
|
|
)
|
|
`)
|
|
.order('created_at', { ascending: false });
|
|
|
|
if (error) throw error;
|
|
|
|
console.log(`Fetched ${data?.length || 0} bottles from Supabase`);
|
|
|
|
const processedBottles = (data || []).map(bottle => {
|
|
const lastTasted = bottle.tastings && bottle.tastings.length > 0
|
|
? bottle.tastings.reduce((latest: string, current: any) =>
|
|
new Date(current.created_at) > new Date(latest) ? current.created_at : latest,
|
|
bottle.tastings[0].created_at
|
|
)
|
|
: null;
|
|
|
|
return { ...bottle, last_tasted: lastTasted };
|
|
});
|
|
|
|
setBottles(processedBottles);
|
|
} catch (err: any) {
|
|
console.warn('[Home] Fetch collection error:', err?.message);
|
|
const isNetworkError = !navigator.onLine ||
|
|
err?.name === 'TypeError' ||
|
|
err?.message?.includes('Failed to fetch');
|
|
|
|
if (!isNetworkError) {
|
|
setFetchError(err?.message || 'Unknown error');
|
|
}
|
|
} finally {
|
|
setIsInternalLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
await supabase.auth.signOut();
|
|
};
|
|
|
|
// Filter bottles by search query
|
|
const filteredBottles = bottles.filter(bottle => {
|
|
if (!searchQuery.trim()) return true;
|
|
const query = searchQuery.toLowerCase();
|
|
return (
|
|
bottle.name?.toLowerCase().includes(query) ||
|
|
bottle.distillery?.toLowerCase().includes(query) ||
|
|
bottle.category?.toLowerCase().includes(query)
|
|
);
|
|
});
|
|
|
|
if (!hasMounted) {
|
|
return (
|
|
<main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950">
|
|
<Loader2 className="animate-spin text-orange-600" size={40} />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// Guest / Login View
|
|
if (!user) {
|
|
return (
|
|
<main className="flex min-h-screen flex-col items-center justify-center p-6 bg-zinc-950">
|
|
<div className="mb-12 text-center animate-in fade-in zoom-in duration-1000">
|
|
<h1 className="text-6xl font-bold text-zinc-50 tracking-tighter mb-4">
|
|
DRAM<span className="text-orange-600">LOG</span>
|
|
</h1>
|
|
<p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide">
|
|
{t('home.tagline')}
|
|
</p>
|
|
</div>
|
|
<AuthForm />
|
|
|
|
{publicSplits.length > 0 && (
|
|
<div className="mt-16 w-full max-w-lg space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-300">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<h2 className="text-[10px] font-black uppercase tracking-[0.4em] text-orange-600/60">
|
|
{t('splits.publicExplore')}
|
|
</h2>
|
|
<div className="h-px w-8 bg-orange-600/20" />
|
|
</div>
|
|
<div className="space-y-4">
|
|
{publicSplits.map((split) => (
|
|
<SplitCard
|
|
key={split.id}
|
|
split={split}
|
|
onSelect={() => router.push(`/splits/${split.slug}`)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
const isLoading = isAuthLoading || isInternalLoading;
|
|
|
|
// Authenticated Home View - New Layout
|
|
return (
|
|
<div className="flex flex-col min-h-screen bg-(--background) relative">
|
|
{/* Scrollable Content Area */}
|
|
<div className="flex-1 overflow-y-auto pb-24">
|
|
{/* 1. Header */}
|
|
<header className="px-4 pt-4 pb-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex flex-col shrink-0">
|
|
<h1 className="text-2xl font-bold text-zinc-50 tracking-tighter">
|
|
DRAM<span className="text-orange-600">LOG</span>
|
|
</h1>
|
|
{activeSession && (
|
|
<div className="flex items-center gap-2 mt-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" />
|
|
<span className="hidden sm:inline">Live:</span> {activeSession.name}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<UserStatusBadge />
|
|
<OfflineIndicator />
|
|
<button
|
|
onClick={handleLogout}
|
|
className="text-[9px] font-bold uppercase tracking-widest text-zinc-600 hover:text-white transition-colors whitespace-nowrap"
|
|
>
|
|
{t('home.logout')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* 2. Hero Banner (optional) */}
|
|
<div className="px-4 mt-2 mb-4">
|
|
<HeroBanner />
|
|
</div>
|
|
|
|
|
|
{/* 3. Quick Actions Grid */}
|
|
<div className="px-4 mb-4">
|
|
<QuickActionsGrid />
|
|
</div>
|
|
|
|
{/* 4. Sticky Search Bar */}
|
|
<div className="sticky top-0 z-20 px-4 py-3 bg-zinc-950/95 backdrop-blur-md border-b border-zinc-900">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 relative">
|
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
|
<input
|
|
type="text"
|
|
placeholder={t('home.searchPlaceholder') || 'Search collection...'}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-9 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600/50 focus:ring-1 focus:ring-orange-600/20"
|
|
/>
|
|
</div>
|
|
<button className="p-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-500 hover:text-white hover:border-zinc-700 transition-colors">
|
|
<SlidersHorizontal size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 5. Collection */}
|
|
<div className="px-4 mt-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-bold text-zinc-50">
|
|
{t('home.collection')}
|
|
</h2>
|
|
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">
|
|
{filteredBottles.length} {t('home.bottleCount')}
|
|
</span>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-20">
|
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-orange-600"></div>
|
|
</div>
|
|
) : fetchError ? (
|
|
<div className="p-8 bg-zinc-900 border border-zinc-800 rounded-2xl text-center">
|
|
<p className="text-zinc-50 font-bold mb-2">{t('common.error')}</p>
|
|
<p className="text-zinc-500 text-xs mb-6">{fetchError}</p>
|
|
<button
|
|
onClick={fetchCollection}
|
|
className="px-6 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-xl text-xs font-bold uppercase tracking-widest transition-all"
|
|
>
|
|
{t('home.reTry')}
|
|
</button>
|
|
</div>
|
|
) : filteredBottles.length > 0 ? (
|
|
<BottleGrid bottles={filteredBottles} />
|
|
) : bottles.length > 0 ? (
|
|
<div className="text-center py-12 text-zinc-500">
|
|
<p className="text-sm">No bottles match your search</p>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<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>
|
|
|
|
{/* Bottom Navigation with FAB */}
|
|
<BottomNavigation
|
|
onHome={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
|
onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })}
|
|
onTastings={() => setIsTastingHubOpen(true)}
|
|
onProfile={() => router.push('/settings')}
|
|
onScan={handleImageSelected}
|
|
/>
|
|
|
|
<TastingHub
|
|
isOpen={isTastingHubOpen}
|
|
onClose={() => setIsTastingHubOpen(false)}
|
|
/>
|
|
|
|
<ScanAndTasteFlow
|
|
isOpen={isFlowOpen}
|
|
onClose={() => setIsFlowOpen(false)}
|
|
imageFile={capturedFile}
|
|
onBottleSaved={() => fetchCollection()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|