diff --git a/sql/create_app_banners.sql b/sql/create_app_banners.sql new file mode 100644 index 0000000..15e9a26 --- /dev/null +++ b/sql/create_app_banners.sql @@ -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(); diff --git a/src/app/buddies/page.tsx b/src/app/buddies/page.tsx new file mode 100644 index 0000000..3003f89 --- /dev/null +++ b/src/app/buddies/page.tsx @@ -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 ( +
+
+ {/* Header */} +
+ +
+

+ {t('buddy.title')} +

+

+ Your tasting companions +

+
+
+ + {/* Buddy List */} + +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 43ee207..150c4db 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,22 +5,21 @@ import { useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; import BottleGrid from "@/components/BottleGrid"; import AuthForm from "@/components/AuthForm"; -import BuddyList from "@/components/BuddyList"; -import SessionList from "@/components/SessionList"; -import StatsDashboard from "@/components/StatsDashboard"; -import DramOfTheDay from "@/components/DramOfTheDay"; import LanguageSwitcher from "@/components/LanguageSwitcher"; import OfflineIndicator from "@/components/OfflineIndicator"; import { useI18n } from "@/i18n/I18nContext"; import { useAuth } from "@/context/AuthContext"; import { useSession } from "@/context/SessionContext"; import TastingHub from "@/components/TastingHub"; -import { Sparkles, X, Loader2 } from "lucide-react"; +import { Sparkles, Loader2, Search, SlidersHorizontal } from "lucide-react"; import { BottomNavigation } from '@/components/BottomNavigation'; import ScanAndTasteFlow from '@/components/ScanAndTasteFlow'; import UserStatusBadge from '@/components/UserStatusBadge'; import { getActiveSplits } from '@/services/split-actions'; import SplitCard from '@/components/SplitCard'; +import HeroBanner from '@/components/HeroBanner'; +import QuickActionsGrid from '@/components/QuickActionsGrid'; +import DramOfTheDay from '@/components/DramOfTheDay'; export default function Home() { const supabase = createClient(); @@ -36,6 +35,7 @@ export default function Home() { const [capturedFile, setCapturedFile] = useState(null); const [hasMounted, setHasMounted] = useState(false); const [publicSplits, setPublicSplits] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); useEffect(() => { setHasMounted(true); @@ -47,7 +47,6 @@ export default function Home() { }; useEffect(() => { - // Only fetch if auth is ready and user exists if (!isAuthLoading && user) { fetchCollection(); } else if (!isAuthLoading && !user) { @@ -56,14 +55,12 @@ export default function Home() { }, [user, isAuthLoading]); useEffect(() => { - // Fetch public splits if guest getActiveSplits().then(res => { if (res.success && res.splits) { setPublicSplits(res.splits); } }); - // Listen for collection updates (e.g., after offline sync completes) const handleCollectionUpdated = () => { console.log('[Home] Collection update event received, refreshing...'); fetchCollection(); @@ -78,25 +75,21 @@ export default function Home() { const fetchCollection = async () => { setIsInternalLoading(true); try { - // Fetch bottles with their latest tasting date const { data, error } = await supabase .from('bottles') .select(` - *, - tastings ( - created_at, - rating - ) - `) + *, + tastings ( + created_at, + rating + ) + `) .order('created_at', { ascending: false }); - if (error) { - throw error; - } + if (error) throw error; console.log(`Fetched ${data?.length || 0} bottles from Supabase`); - // Process data to get the absolute latest tasting date for each bottle const processedBottles = (data || []).map(bottle => { const lastTasted = bottle.tastings && bottle.tastings.length > 0 ? bottle.tastings.reduce((latest: string, current: any) => @@ -105,41 +98,18 @@ export default function Home() { ) : null; - return { - ...bottle, - last_tasted: lastTasted - }; + return { ...bottle, last_tasted: lastTasted }; }); setBottles(processedBottles); } catch (err: any) { - // Enhanced logging for empty-looking error objects - console.warn('[Home] Fetch collection error caught:', { - name: err?.name, - message: err?.message, - keys: err ? Object.keys(err) : [], - allProps: err ? Object.getOwnPropertyNames(err) : [], - stack: err?.stack, - online: navigator.onLine - }); - - // Silently skip if offline or common network failure + console.warn('[Home] Fetch collection error:', err?.message); const isNetworkError = !navigator.onLine || err?.name === 'TypeError' || - err?.message?.includes('Failed to fetch') || - err?.message?.includes('NetworkError') || - err?.message?.includes('ERR_INTERNET_DISCONNECTED') || - (err && typeof err === 'object' && !err.message && Object.keys(err).length === 0); + err?.message?.includes('Failed to fetch'); - if (isNetworkError) { - console.log('[fetchCollection] Skipping due to offline mode or network error'); - setFetchError(null); - } else { - console.error('Detailed fetch error:', err); - // Safe stringification for Error objects - const errorMessage = err?.message || - (err && typeof err === 'object' ? JSON.stringify(err, Object.getOwnPropertyNames(err)) : String(err)); - setFetchError(errorMessage); + if (!isNetworkError) { + setFetchError(err?.message || 'Unknown error'); } } finally { setIsInternalLoading(false); @@ -150,6 +120,17 @@ export default function Home() { await supabase.auth.signOut(); }; + // Filter bottles by search query + const filteredBottles = bottles.filter(bottle => { + if (!searchQuery.trim()) return true; + const query = searchQuery.toLowerCase(); + return ( + bottle.name?.toLowerCase().includes(query) || + bottle.distillery?.toLowerCase().includes(query) || + bottle.category?.toLowerCase().includes(query) + ); + }); + if (!hasMounted) { return (
@@ -158,6 +139,7 @@ export default function Home() { ); } + // Guest / Login View if (!user) { return (
@@ -174,7 +156,7 @@ export default function Home() { - {!user && publicSplits.length > 0 && ( + {publicSplits.length > 0 && (

@@ -199,61 +181,83 @@ export default function Home() { const isLoading = isAuthLoading || isInternalLoading; + // Authenticated Home View - New Layout return ( -
-
-
-
-

- DRAMLOG -

- {activeSession && ( -
-
- - +
+ {/* Scrollable Content Area */} +
+ {/* 1. Header */} +
+
+
+

+ DRAMLOG +

+ {activeSession && ( +
+
+ + +
+ + + Live: {activeSession.name} +
- - - Live: {activeSession.name} - -
- )} -
-
- - - - - + )} +
+
+ + + + + +
-
- + {/* 2. Hero Banner (optional) */} +
+
-
-
- -
-
- + {/* 3. Quick Actions Grid */} +
+ +
+ + {/* 4. Sticky Search Bar */} +
+
+
+ + 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" + /> +
+
-
-
-

+ {/* 5. Collection */} +
+
+

{t('home.collection')}

- - {bottles.length} {t('home.bottleCount')} + + {filteredBottles.length} {t('home.bottleCount')}
@@ -262,33 +266,38 @@ export default function Home() {
) : fetchError ? ( -
-

{t('common.error')}

-

{fetchError}

+
+

{t('common.error')}

+

{fetchError}

- ) : ( - bottles.length > 0 && - )} + ) : filteredBottles.length > 0 ? ( + + ) : bottles.length > 0 ? ( +
+

No bottles match your search

+
+ ) : null}
+ + {/* Footer */} +

- {/* Footer */} - - + {/* Bottom Navigation with FAB */} window.scrollTo({ top: 0, behavior: 'smooth' })} onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })} @@ -308,6 +317,6 @@ export default function Home() { imageFile={capturedFile} onBottleSaved={() => fetchCollection()} /> -
+

); } diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx new file mode 100644 index 0000000..432ad83 --- /dev/null +++ b/src/app/sessions/page.tsx @@ -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 ( +
+
+ {/* Header */} +
+ +
+

+ {t('session.title')} +

+

+ Manage your tasting events +

+
+
+ + {/* Session List */} + +
+
+ ); +} diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx new file mode 100644 index 0000000..7506c11 --- /dev/null +++ b/src/app/stats/page.tsx @@ -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([]); + const supabase = createClient(); + + useEffect(() => { + const fetchBottles = async () => { + const { data } = await supabase + .from('bottles') + .select('*'); + setBottles(data || []); + }; + fetchBottles(); + }, []); + + return ( +
+
+ {/* Header */} +
+ +
+

+ {t('home.stats.title')} +

+

+ Your collection analytics +

+
+
+ + {/* Stats Dashboard */} + +
+
+ ); +} diff --git a/src/app/wishlist/page.tsx b/src/app/wishlist/page.tsx new file mode 100644 index 0000000..7fb84a9 --- /dev/null +++ b/src/app/wishlist/page.tsx @@ -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 ( +
+
+ {/* Header */} +
+ +
+

+ {t('nav.wishlist')} +

+

+ Bottles you want to try +

+
+
+ + {/* Empty State */} +
+
+ +
+

+ Coming Soon +

+

+ Your wishlist will appear here. You'll be able to save bottles you want to try in the future. +

+
+
+
+ ); +} diff --git a/src/components/HeroBanner.tsx b/src/components/HeroBanner.tsx new file mode 100644 index 0000000..5ec89f7 --- /dev/null +++ b/src/components/HeroBanner.tsx @@ -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(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 = ( +
+ {/* Overlay gradient */} +
+ + {/* Content */} +
+

+ {banner.title} +

+ {banner.link_target && ( +
+ {banner.cta_text} + +
+ )} +
+
+ ); + + if (banner.link_target) { + return ( + + {content} + + ); + } + + return content; +} diff --git a/src/components/NavButton.tsx b/src/components/NavButton.tsx new file mode 100644 index 0000000..85937f4 --- /dev/null +++ b/src/components/NavButton.tsx @@ -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 ( + +
+ {icon} + {badge !== undefined && badge > 0 && ( + + {badge > 9 ? '9+' : badge} + + )} +
+ + {label} + + + ); +} diff --git a/src/components/QuickActionsGrid.tsx b/src/components/QuickActionsGrid.tsx new file mode 100644 index 0000000..a3a3afe --- /dev/null +++ b/src/components/QuickActionsGrid.tsx @@ -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 ( +
+ } + label={t('nav.sessions') || 'Events'} + href="/sessions" + /> + } + label={t('nav.buddies') || 'Buddies'} + href="/buddies" + /> + } + label={t('nav.stats') || 'Stats'} + href="/stats" + /> + } + label={t('nav.wishlist') || 'Wishlist'} + href="/wishlist" + /> +
+ ); +} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 9ea16ca..1f16efa 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -199,6 +199,10 @@ export const de: TranslationKeys = { activity: 'Aktivität', search: 'Suchen', profile: 'Profil', + sessions: 'Events', + buddies: 'Buddies', + stats: 'Statistik', + wishlist: 'Wunschliste', }, hub: { title: 'Activity Hub', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6ff06e5..541643a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -199,6 +199,10 @@ export const en: TranslationKeys = { activity: 'Activity', search: 'Search', profile: 'Profile', + sessions: 'Events', + buddies: 'Buddies', + stats: 'Stats', + wishlist: 'Wishlist', }, hub: { title: 'Activity Hub', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index c947962..659f1fc 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -197,6 +197,10 @@ export type TranslationKeys = { activity: string; search: string; profile: string; + sessions: string; + buddies: string; + stats: string; + wishlist: string; }; hub: { title: string;