diff --git a/enable_public_split_access.sql b/enable_public_split_access.sql new file mode 100644 index 0000000..a52f7b0 --- /dev/null +++ b/enable_public_split_access.sql @@ -0,0 +1,33 @@ +-- ============================================ +-- Enable Public Access for Bottle Splits +-- ============================================ +-- This script enables unauthenticated users (guests) to view: +-- 1. Profiles (to see hostnames) +-- 2. Bottle Splits (if is_active = true) +-- 3. Split Participants (to see progress bar data) +-- ============================================ + +-- 1. Profiles: Allow anyone to see usernames and avatars +DROP POLICY IF EXISTS "profiles_select_policy" ON profiles; +CREATE POLICY "profiles_select_policy" ON profiles + FOR SELECT USING (true); + +-- 2. Bottle Splits: Allow guests to see active splits +-- This policy allows anyone to read splits that are marked as active. +DROP POLICY IF EXISTS "bottle_splits_public_select" ON bottle_splits; +CREATE POLICY "bottle_splits_public_select" ON bottle_splits + FOR SELECT USING (is_active = true); + +-- 3. Split Participants: Allow guests to see progress of active splits +-- This is necessary to calculate the "taken" volume in the progress bar. +DROP POLICY IF EXISTS "split_participants_public_select" ON split_participants; +CREATE POLICY "split_participants_public_select" ON split_participants + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM public.bottle_splits + WHERE id = split_participants.split_id AND is_active = true + ) + ); + +-- Note: Ensure "bottles_select_policy" in rls_public_bottle_access.sql +-- is also applied to allow guests to see bottle details for active splits. diff --git a/fix_rls_recursion.sql b/fix_rls_recursion.sql new file mode 100644 index 0000000..2e781b2 --- /dev/null +++ b/fix_rls_recursion.sql @@ -0,0 +1,54 @@ +-- ============================================ +-- Fix RLS Infinite Recursion +-- ============================================ +-- This script breaks the circular dependency between bottle_splits +-- and split_participants RLS policies using SECURITY DEFINER functions. +-- ============================================ + +-- 1. Create Security Definer functions to bypass RLS +CREATE OR REPLACE FUNCTION public.check_is_split_host(check_split_id UUID, check_user_id UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.bottle_splits + WHERE id = check_split_id AND host_id = check_user_id + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION public.check_is_split_participant(check_split_id UUID, check_user_id UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.split_participants + WHERE split_id = check_split_id AND user_id = check_user_id + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 2. Update bottle_splits policies +DROP POLICY IF EXISTS "bottle_splits_participant_view" ON bottle_splits; +DROP POLICY IF EXISTS "bottle_splits_host_policy" ON bottle_splits; + +CREATE POLICY "bottle_splits_host_policy" ON bottle_splits + FOR ALL USING ((SELECT auth.uid()) = host_id); + +CREATE POLICY "bottle_splits_participant_view" ON bottle_splits + FOR SELECT USING ( + check_is_split_participant(id, (SELECT auth.uid())) + ); + +-- 3. Update split_participants policies +DROP POLICY IF EXISTS "split_participants_host_policy" ON split_participants; +DROP POLICY IF EXISTS "split_participants_own_policy" ON split_participants; + +CREATE POLICY "split_participants_own_policy" ON split_participants + FOR ALL USING ((SELECT auth.uid()) = user_id); + +CREATE POLICY "split_participants_host_policy" ON split_participants + FOR ALL USING ( + check_is_split_host(split_id, (SELECT auth.uid())) + ); + +-- Note: bottle_splits_public_view and split_participants_public_view +-- don't cause recursion as they don't cross-reference tables in a cycle. diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/rls_public_bottle_access.sql b/rls_public_bottle_access.sql new file mode 100644 index 0000000..df9ceba --- /dev/null +++ b/rls_public_bottle_access.sql @@ -0,0 +1,54 @@ +-- ============================================ +-- Restore Public/Buddy Bottle Read Access +-- ============================================ +-- This script fixes the issue where non-owners cannot see bottle data +-- for public splits or shared tastings. +-- ============================================ + +-- Drop the overly restrictive performance-fix policy +DROP POLICY IF EXISTS "bottles_policy" ON bottles; + +-- 1. Unified SELECT policy: owner OR active split viewer OR session participant +CREATE POLICY "bottles_select_policy" ON bottles +FOR SELECT USING ( + -- Owner access + (SELECT auth.uid()) = user_id OR + + -- Public split access (anyone can see bottle info if the split is active) + EXISTS ( + SELECT 1 FROM bottle_splits + WHERE bottle_id = bottles.id AND is_active = true + ) OR + + -- Participant access (user is already part of this split) + EXISTS ( + SELECT 1 FROM split_participants sp + JOIN bottle_splits bs ON bs.id = sp.split_id + WHERE bs.bottle_id = bottles.id AND sp.user_id = (SELECT auth.uid()) + ) OR + + -- Buddy/Session access (user is a buddy in a session involving this bottle) + id IN ( + SELECT t.bottle_id + FROM tastings t + JOIN tasting_sessions ts ON ts.id = t.session_id + JOIN session_participants sp ON sp.session_id = ts.id + JOIN buddies b ON b.id = sp.buddy_id + WHERE b.buddy_profile_id = (SELECT auth.uid()) + ) +); + +-- 2. Owner-only for modifications (No change needed from security perspective) +CREATE POLICY "bottles_insert_policy" ON bottles +FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id); + +CREATE POLICY "bottles_update_policy" ON bottles +FOR UPDATE USING ((SELECT auth.uid()) = user_id); + +CREATE POLICY "bottles_delete_policy" ON bottles +FOR DELETE USING ((SELECT auth.uid()) = user_id); + +-- ============================================ +-- Verification query +-- ============================================ +-- SELECT * FROM pg_policies WHERE tablename = 'bottles'; diff --git a/rls_split_participant_access.sql b/rls_split_participant_access.sql new file mode 100644 index 0000000..f6d8b5c --- /dev/null +++ b/rls_split_participant_access.sql @@ -0,0 +1,9 @@ +-- Allow participants to see the split record even if it's not active anymore +DROP POLICY IF EXISTS "bottle_splits_participant_view" ON bottle_splits; +CREATE POLICY "bottle_splits_participant_view" ON bottle_splits +FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM split_participants + WHERE split_id = bottle_splits.id AND user_id = (SELECT auth.uid()) + ) +); diff --git a/rls_unified_consolidation.sql b/rls_unified_consolidation.sql new file mode 100644 index 0000000..f4bd875 --- /dev/null +++ b/rls_unified_consolidation.sql @@ -0,0 +1,173 @@ +-- ============================================ +-- UNIFIED RLS CONSOLIDATION & TABLE FIXES +-- ============================================ +-- 1. Fix Table Mismatch (Rename to match services) +-- 2. Consolidate RLS for Bottles, Tastings, Sessions +-- 3. Resolve Naming Conflicts (tasting_tags vs tasting_buddies) +-- ============================================ + +-- 1. FIX TABLE NAMES (Ensuring consistency across project) +DO $$ +BEGIN + -- If 'tasting_tags' currently contains buddy_id (old schema), rename it + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tasting_tags' AND column_name = 'buddy_id' + ) THEN + DROP TABLE IF EXISTS tasting_buddies CASCADE; + ALTER TABLE tasting_tags RENAME TO tasting_buddies; + END IF; + + -- Ensure 'tasting_tags' exists for Aroma Tags (Aroma Filter) + IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'tasting_tags') THEN + CREATE TABLE tasting_tags ( + tasting_id UUID REFERENCES tastings(id) ON DELETE CASCADE NOT NULL, + tag_id UUID REFERENCES tags(id) ON DELETE CASCADE NOT NULL, + user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL, + PRIMARY KEY (tasting_id, tag_id) + ); + END IF; +END $$; + +-- Enable RLS on all relevant tables +ALTER TABLE bottles ENABLE ROW LEVEL SECURITY; +ALTER TABLE tastings ENABLE ROW LEVEL SECURITY; +ALTER TABLE tasting_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE tasting_buddies ENABLE ROW LEVEL SECURITY; +ALTER TABLE tasting_tags ENABLE ROW LEVEL SECURITY; + +-- 2. UNIFIED BOTTLES POLICIES +DROP POLICY IF EXISTS "bottles_policy" ON bottles; +DROP POLICY IF EXISTS "bottles_select_policy" ON bottles; +DROP POLICY IF EXISTS "bottles_insert_policy" ON bottles; +DROP POLICY IF EXISTS "bottles_update_policy" ON bottles; +DROP POLICY IF EXISTS "bottles_delete_policy" ON bottles; + +-- SELECT: owner OR split participant OR session buddy +CREATE POLICY "bottles_select_policy" ON bottles +FOR SELECT USING ( + (SELECT auth.uid()) = user_id OR + EXISTS (SELECT 1 FROM bottle_splits WHERE bottle_id = bottles.id AND is_active = true) OR + EXISTS ( + SELECT 1 FROM tasting_sessions ts + JOIN session_participants sp ON sp.session_id = ts.id + JOIN buddies b ON b.id = sp.buddy_id + JOIN tastings t ON t.session_id = ts.id + WHERE t.bottle_id = bottles.id AND b.buddy_profile_id = (SELECT auth.uid()) + ) +); + +CREATE POLICY "bottles_insert_policy" ON bottles +FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id); + +CREATE POLICY "bottles_update_policy" ON bottles +FOR UPDATE USING ((SELECT auth.uid()) = user_id); + +CREATE POLICY "bottles_delete_policy" ON bottles +FOR DELETE USING ((SELECT auth.uid()) = user_id); + +-- 3. UNIFIED TASTINGS POLICIES +DROP POLICY IF EXISTS "tastings_policy" ON tastings; +DROP POLICY IF EXISTS "tastings_select_policy" ON tastings; +DROP POLICY IF EXISTS "tastings_insert_policy" ON tastings; +DROP POLICY IF EXISTS "tastings_update_policy" ON tastings; +DROP POLICY IF EXISTS "tastings_delete_policy" ON tastings; +DROP POLICY IF EXISTS "tastings_modify_policy" ON tastings; + +-- SELECT: owner OR buddy in the same tasting/session +CREATE POLICY "tastings_select_policy" ON tastings +FOR SELECT USING ( + (SELECT auth.uid()) = user_id OR + EXISTS ( + SELECT 1 FROM tasting_buddies tb + JOIN buddies b ON b.id = tb.buddy_id + WHERE tb.tasting_id = tastings.id AND b.buddy_profile_id = (SELECT auth.uid()) + ) +); + +-- INSERT: Anyone can insert if they are the user_id (the owner of the note) +CREATE POLICY "tastings_insert_policy" ON tastings +FOR INSERT WITH CHECK ((SELECT auth.uid()) = user_id); + +-- UPDATE/DELETE: strictly owner +CREATE POLICY "tastings_modify_policy" ON tastings +FOR ALL USING ((SELECT auth.uid()) = user_id); + +-- 4. UNIFIED JOIN TABLES POLICIES (Buddies & Aroma Tags) +DROP POLICY IF EXISTS "tasting_buddies_policy" ON tasting_buddies; +DROP POLICY IF EXISTS "tasting_tags_policy" ON tasting_tags; + +CREATE POLICY "tasting_buddies_policy" ON tasting_buddies +FOR ALL USING ((SELECT auth.uid()) = user_id); + +CREATE POLICY "tasting_tags_policy" ON tasting_tags +FOR ALL USING ((SELECT auth.uid()) = user_id); + +-- 5. UNIFIED SESSIONS POLICIES +DROP POLICY IF EXISTS "tasting_sessions_policy" ON tasting_sessions; +DROP POLICY IF EXISTS "sessions_access_policy" ON tasting_sessions; + +CREATE POLICY "tasting_sessions_policy" ON tasting_sessions +FOR ALL USING ( + (SELECT auth.uid()) = user_id OR + id IN ( + SELECT sp.session_id + FROM session_participants sp + JOIN buddies b ON b.id = sp.buddy_id + WHERE b.buddy_profile_id = (SELECT auth.uid()) + ) +); + +-- 6. BOTTLE SPLITS & RECURSION FIXES +-- Breaks loop between bottle_splits and split_participants +CREATE OR REPLACE FUNCTION public.check_is_split_host(check_split_id UUID, check_user_id UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.bottle_splits + WHERE id = check_split_id AND host_id = check_user_id + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION public.check_is_split_participant(check_split_id UUID, check_user_id UUID) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN EXISTS ( + SELECT 1 FROM public.split_participants + WHERE split_id = check_split_id AND user_id = check_user_id + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP POLICY IF EXISTS "bottle_splits_host_policy" ON bottle_splits; +DROP POLICY IF EXISTS "bottle_splits_participant_view" ON bottle_splits; +DROP POLICY IF EXISTS "bottle_splits_public_select" ON bottle_splits; + +CREATE POLICY "bottle_splits_host_policy" ON bottle_splits + FOR ALL USING ((SELECT auth.uid()) = host_id); + +CREATE POLICY "bottle_splits_participant_view" ON bottle_splits + FOR SELECT USING (check_is_split_participant(id, (SELECT auth.uid()))); + +CREATE POLICY "bottle_splits_public_select" ON bottle_splits + FOR SELECT USING (is_active = true); + +-- 7. SPLIT PARTICIPANTS +DROP POLICY IF EXISTS "split_participants_own_policy" ON split_participants; +DROP POLICY IF EXISTS "split_participants_host_policy" ON split_participants; +DROP POLICY IF EXISTS "split_participants_public_select" ON split_participants; + +CREATE POLICY "split_participants_own_policy" ON split_participants + FOR ALL USING ((SELECT auth.uid()) = user_id); + +CREATE POLICY "split_participants_host_policy" ON split_participants + FOR ALL USING (check_is_split_host(split_id, (SELECT auth.uid()))); + +CREATE POLICY "split_participants_public_select" ON split_participants + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM public.bottle_splits + WHERE id = split_participants.split_id AND is_active = true + ) + ); diff --git a/src/app/page.tsx b/src/app/page.tsx index b40013d..9a4a61e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,10 +13,13 @@ import LanguageSwitcher from "@/components/LanguageSwitcher"; import OfflineIndicator from "@/components/OfflineIndicator"; import { useI18n } from "@/i18n/I18nContext"; import { useSession } from "@/context/SessionContext"; +import TastingHub from "@/components/TastingHub"; import { Sparkles, X, Loader2 } 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'; export default function Home() { const supabase = createClient(); @@ -28,8 +31,10 @@ export default function Home() { const { t } = useI18n(); const { activeSession } = useSession(); const [isFlowOpen, setIsFlowOpen] = useState(false); + const [isTastingHubOpen, setIsTastingHubOpen] = useState(false); const [capturedFile, setCapturedFile] = useState(null); const [hasMounted, setHasMounted] = useState(false); + const [publicSplits, setPublicSplits] = useState([]); useEffect(() => { setHasMounted(true); @@ -74,6 +79,13 @@ export default function Home() { checkUser(); + // Fetch public splits if guest + getActiveSplits().then(res => { + if (res.success && res.splits) { + setPublicSplits(res.splits); + } + }); + // Listen for visibility change (wake up from sleep) const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { @@ -153,19 +165,33 @@ export default function Home() { setBottles(processedBottles); } catch (err: any) { - // Silently skip if offline + // 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 const isNetworkError = !navigator.onLine || - err.message?.includes('Failed to fetch') || - err.message?.includes('NetworkError') || - err.message?.includes('ERR_INTERNET_DISCONNECTED') || - (err && Object.keys(err).length === 0); // Empty error object from Supabase when offline + 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); if (isNetworkError) { - console.log('[fetchCollection] Skipping due to offline mode'); + console.log('[fetchCollection] Skipping due to offline mode or network error'); setFetchError(null); } else { console.error('Detailed fetch error:', err); - setFetchError(err.message || JSON.stringify(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 { setIsLoading(false); @@ -192,13 +218,33 @@ export default function Home() { DRAMLOG

- Modern Minimalist Tasting Tool. + {t('home.tagline')}

+ + {!user && publicSplits.length > 0 && ( +
+
+

+ {t('splits.publicExplore')} +

+
+
+
+ {publicSplits.map((split) => ( + router.push(`/splits/${split.slug}`)} + /> + ))} +
+
+ )} ); } @@ -254,10 +300,10 @@ export default function Home() {

- Collection + {t('home.collection')}

- {bottles.length} Bottles + {bottles.length} {t('home.bottleCount')}
@@ -285,20 +331,25 @@ export default function Home() { {/* Footer */} window.scrollTo({ top: 0, behavior: 'smooth' })} onShelf={() => document.getElementById('collection')?.scrollIntoView({ behavior: 'smooth' })} - onSearch={() => document.getElementById('search-filter')?.scrollIntoView({ behavior: 'smooth' })} + onTastings={() => setIsTastingHubOpen(true)} onProfile={() => router.push('/settings')} + onScan={handleImageSelected} + /> + + setIsTastingHubOpen(false)} /> Loading...
; + } + return ( -
- {/* Header */} -
-
- - - -
- -

Einstellungen

-
-
-
- - {/* Content */} -
- {/* Profile Form */} - - - {/* Password Change Form */} - - - {/* Cookie Settings */} -
-

- - Cookie-Einstellungen -

-

- Diese App verwendet nur technisch notwendige Cookies für die Authentifizierung und funktionale Cookies für UI-Präferenzen. -

-
-
- - Notwendig: Supabase Auth Cookies -
-
- - Funktional: Sprache, UI-Status -
-
-
- - {/* Data & Privacy */} -
-

- - Datenschutz -

-
-

- Deine Daten werden sicher auf EU-Servern gespeichert. -

- - Datenschutzerklärung lesen - -
-
- - {/* Account info */} -
-

- Mitglied seit: {new Date(profile?.created_at || '').toLocaleDateString('de-DE', { - day: '2-digit', - month: 'long', - year: 'numeric' - })} -

-
-
-
+ ); } diff --git a/src/app/splits/[slug]/page.tsx b/src/app/splits/[slug]/page.tsx index dc1f15d..9fcd54c 100644 --- a/src/app/splits/[slug]/page.tsx +++ b/src/app/splits/[slug]/page.tsx @@ -3,13 +3,15 @@ import React, { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; -import { ChevronLeft, Share2, User, Package, Truck, Loader2, CheckCircle2, Clock, AlertCircle } from 'lucide-react'; +import { ChevronLeft, Share2, User, Package, Truck, Loader2, CheckCircle2, Clock, AlertCircle, LogIn } from 'lucide-react'; import { getSplitBySlug, requestSlot, SplitDetails, SampleSize, ShippingOption } from '@/services/split-actions'; import SplitProgressBar from '@/components/SplitProgressBar'; import { createClient } from '@/lib/supabase/client'; +import { useI18n } from '@/i18n/I18nContext'; export default function SplitPublicPage() { const { slug } = useParams(); + const { t } = useI18n(); const supabase = createClient(); const [split, setSplit] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -44,7 +46,7 @@ export default function SplitPublicPage() { setSelectedAmount(result.data.sampleSizes[0].cl); } } else { - setError(result.error || 'Split nicht gefunden'); + setError(result.error || t('splits.noSplitsFound')); } setIsLoading(false); }; @@ -69,6 +71,10 @@ export default function SplitPublicPage() { const handleRequest = async () => { if (!selectedShipping || !selectedAmount) return; + if (!currentUserId) { + window.location.href = `/login?redirect=/splits/${slug}`; + return; + } setIsRequesting(true); setRequestError(null); @@ -86,7 +92,7 @@ export default function SplitPublicPage() { }; const userParticipation = split?.participants.find(p => p.userId === currentUserId); - const canRequest = !userParticipation && currentUserId && currentUserId !== split?.hostId; + const showRequestForm = !userParticipation && currentUserId !== split?.hostId; const isWaitlist = split && selectedAmount && split.remaining < selectedAmount; if (isLoading) { @@ -101,8 +107,8 @@ export default function SplitPublicPage() { return (
-

{error || 'Split nicht gefunden'}

- Zurück zum Start +

{error || t('splits.noSplitsFound')}

+ {t('splits.backToStart')}
); } @@ -117,7 +123,7 @@ export default function SplitPublicPage() { className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]" > - Zurück + {t('common.back')} {/* Hero */} @@ -136,7 +142,7 @@ export default function SplitPublicPage() {

- Flaschenteilung + {t('splits.falscheTeilung')}

{split.bottle.name} @@ -154,11 +160,11 @@ export default function SplitPublicPage() { )} {split.bottle.age && ( - {split.bottle.age} Jahre + {split.bottle.age} {t('splits.jahre')} )} - {split.totalVolume}cl Flasche + {split.totalVolume}{t('splits.clFlasche')}

@@ -175,23 +181,23 @@ export default function SplitPublicPage() {
{/* Request Form */} - {canRequest && !requestSuccess && ( + {showRequestForm && !requestSuccess && (

- Sample bestellen + {t('splits.joinTitle')}

{/* Amount Selection */}
- +
{split.sampleSizes.map(size => ( + } disabled:opacity-50`} + > + {isRequesting ? ( + + ) : isWaitlist ? ( + <> + + {t('splits.waitlist')} + + ) : ( + <> + + {t('splits.sendRequest')} + + )} + + ) : ( +
+
+ +
+

{t('splits.loginToParticipate')}

+

+ {t('splits.loginToParticipateDesc')} +

+
+
+ +
+ )}
)} {requestSuccess && (
-

Anfrage gesendet!

+

{t('splits.requestSent')}

- Der Host wird deine Anfrage prüfen und sich bei dir melden. + {t('splits.requestSentDesc')}

)} @@ -290,17 +317,17 @@ export default function SplitPublicPage() {
{userParticipation.status === 'SHIPPED' ? : userParticipation.status === 'PENDING' ? : }
-

Du nimmst teil

+

{t('splits.youAreParticipating')}

{userParticipation.amountCl}cl · {userParticipation.totalCost.toFixed(2)}€ · Status: {userParticipation.status} @@ -310,19 +337,6 @@ export default function SplitPublicPage() {

)} - {!currentUserId && ( -
- -

Melde dich an, um teilzunehmen

- - Anmelden - -
- )} - {currentUserId === split.hostId && (

Du bist der Host dieses Splits

@@ -340,7 +354,7 @@ export default function SplitPublicPage() { className="w-full py-4 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 rounded-2xl text-zinc-400 font-bold flex items-center justify-center gap-2 transition-colors" > - Link teilen + {t('splits.shareLink')}
diff --git a/src/components/BottleDetails.tsx b/src/components/BottleDetails.tsx index 503e6df..3a760c0 100644 --- a/src/components/BottleDetails.tsx +++ b/src/components/BottleDetails.tsx @@ -27,6 +27,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet const [price, setPrice] = React.useState(''); const [status, setStatus] = React.useState('sealed'); const [isUpdating, setIsUpdating] = React.useState(false); + const [isEditMode, setIsEditMode] = React.useState(false); const [isFormVisible, setIsFormVisible] = React.useState(false); React.useEffect(() => { @@ -122,164 +123,154 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
{/* Content Container */} -
+
{/* Title Section - HIG Large Title Pattern */} -
+
{isOffline && (
-

Offline

+

Offline Mode

)} -

- {bottle.distillery} +

+ {bottle.distillery || 'Unknown Distillery'}

-

+

{bottle.name}

- - {/* Metadata Items - Text based for better readability */} -
-
-

Category

-

{bottle.category || 'Whisky'}

-
-
-

ABV

-

{bottle.abv}%

-
- {bottle.age && ( -
-

Age

-

{bottle.age} Years

-
- )} - {bottle.whiskybase_id && ( - -

Whiskybase

-

- #{bottle.whiskybase_id} -

-
- )} -
- {/* 4. Inventory Section (Cohesive Container) */} -
-
-

Collection Stats

- + {/* Primary Bottle Profile Card */} +
+ {/* Integrated Header/Tabs */} +
+ +
-
- {/* Segmented Control for Status */} -
- -
- {['sealed', 'open', 'empty'].map((s) => ( - - ))} -
-
- -
-
- -
- setPrice(e.target.value)} - onBlur={() => handleQuickUpdate(price)} - placeholder="0.00" - className="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-4 pr-8 py-3 text-sm font-bold text-zinc-100 focus:outline-none focus:border-orange-600" - /> -
-
-
- -
- -
- - {tastings && tastings.length > 0 - ? new Date(tastings[0].created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { day: '2-digit', month: '2-digit' }) - : '-'} -
-
-
-
-
- - {/* 5. Editing Form (Accordion) */} -
- - - - {isFormVisible && ( + + {!isEditMode ? ( -
- setIsFormVisible(false)} - /> + {/* Fact Grid - Integrated Metadata & Stats */} +
+ } /> + } highlight={!bottle.abv} /> + } /> + } />
+ + {/* Status & Last Dram Row */} +
+ {/* Status Switcher */} +
+
+ + Bottle Status +
+
+ {['sealed', 'open', 'empty'].map((s) => ( + + ))} +
+
+ + {/* Last Dram Info */} +
+
+ + Last Enjoyed +
+
+ + {tastings?.length ? new Date(tastings[0].created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { day: '2-digit', month: '2-digit', year: 'numeric' }) : 'No dram yet'} + + {tastings?.length > 0 && } +
+
+
+ + {/* Whiskybase Link - Premium Style */} + {bottle.whiskybase_id && ( + + )} + + ) : ( + + setIsEditMode(false)} + /> )} - - {!isOffline && ( -
- - - Split starten - -
- -
-
- )}
-
+ {/* Secondary Actions */} + {!isOffline && ( +
+ + + Launch Split + + +
+ )} + +
- {/* Tasting Notes Section */}
@@ -335,3 +326,25 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
); } + +// Premium Fact Card Sub-component +interface FactCardProps { + label: string; + value: string; + icon: React.ReactNode; + highlight?: boolean; +} + +function FactCard({ label, value, icon, highlight }: FactCardProps) { + return ( +
+
+
{icon}
+ {label} +
+

+ {value} +

+
+ ); +} diff --git a/src/components/BottleGrid.tsx b/src/components/BottleGrid.tsx index cbabc2a..77737b8 100644 --- a/src/components/BottleGrid.tsx +++ b/src/components/BottleGrid.tsx @@ -36,10 +36,10 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) { return ( {/* Image Layer - Clean Split Top */} -
+
{bottle.name} {/* Info Layer - Clean Split Bottom */} -
+

{bottle.distillery}

-

+

{bottle.name || t('grid.unknownBottle')}

-
- - {shortenCategory(bottle.category)} - - - {bottle.abv}% VOL - -
- - {/* Metadata items */} -
-
- - {new Date(bottle.created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} +
+
+ + {shortenCategory(bottle.category)} + + + {bottle.abv}% VOL +
- {bottle.last_tasted && ( + + {/* Metadata items */} +
- - {new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} + + {new Date(bottle.created_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')}
- )} + {bottle.last_tasted && ( +
+ + {new Date(bottle.last_tasted).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} +
+ )} +
diff --git a/src/components/BottomNavigation.tsx b/src/components/BottomNavigation.tsx index 1669f2a..e06b3d6 100644 --- a/src/components/BottomNavigation.tsx +++ b/src/components/BottomNavigation.tsx @@ -1,13 +1,16 @@ 'use client'; import React from 'react'; -import { Home, Grid, Scan, User, Search } from 'lucide-react'; +import { Home, Library, Camera, UserRound, GlassWater } from 'lucide-react'; +import { motion } from 'framer-motion'; import { usePathname } from 'next/navigation'; +import { useI18n } from '@/i18n/I18nContext'; interface BottomNavigationProps { onHome?: () => void; onShelf?: () => void; onSearch?: () => void; + onTastings?: () => void; onProfile?: () => void; onScan: (file: File) => void; } @@ -17,20 +20,25 @@ interface NavButtonProps { icon: React.ReactNode; label: string; ariaLabel: string; + active?: boolean; } -const NavButton = ({ onClick, icon, label, ariaLabel }: NavButtonProps) => ( +const NavButton = ({ onClick, icon, label, ariaLabel, active }: NavButtonProps) => ( ); -export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan }: BottomNavigationProps) => { +export function BottomNavigation({ onHome, onShelf, onTastings, onProfile, onScan }: BottomNavigationProps) { + const { t } = useI18n(); + const pathname = usePathname(); const fileInputRef = React.useRef(null); const handleScanClick = () => { @@ -44,8 +52,13 @@ export const BottomNavigation = ({ onHome, onShelf, onSearch, onProfile, onScan } }; + // Determine active tab based on path + const isHome = pathname === '/'; + const isShelf = pathname?.includes('/shelf'); + const isProfile = pathname?.includes('/settings') || pathname?.includes('/profile'); + return ( -
+
{/* Hidden Input for Scanning */} -
+
{/* Left Items */} - } - label="Start" - ariaLabel="Home" - /> +
+ } + label={t('nav.home')} + active={isHome} + ariaLabel={t('nav.home')} + /> - } - label="Sammlung" - ariaLabel="Sammlung" - /> + } + label={t('nav.shelf')} + active={isShelf} + ariaLabel={t('nav.shelf')} + /> +
- {/* PRIMARY ACTION - Scan Button */} - + {/* Center FAB */} +
+ +
{/* Right Items */} - } - label="Filter" - ariaLabel="Filter" - /> +
+ } + label={t('nav.activity')} + ariaLabel={t('nav.activity')} + /> - } - label="Profil" - ariaLabel="Profil" - /> + } + label={t('nav.profile')} + active={isProfile} + ariaLabel={t('nav.profile')} + /> +
); -}; +} diff --git a/src/components/EditBottleForm.tsx b/src/components/EditBottleForm.tsx index 6793ff4..572ff75 100644 --- a/src/components/EditBottleForm.tsx +++ b/src/components/EditBottleForm.tsx @@ -103,161 +103,163 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro }; return ( -
-
+
+
{/* Full Width Inputs */} -
- +
+ setFormData({ ...formData, name: e.target.value })} - className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" + className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" />
-
- +
+ setFormData({ ...formData, distillery: e.target.value })} - className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" + className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" />
{/* Compact Row: Category */} -
- +
+ setFormData({ ...formData, category: e.target.value })} - className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" + className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" />
{/* Row A: ABV + Age */} -
-
- +
+
+ setFormData({ ...formData, abv: parseFloat(e.target.value) })} - className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" + className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-orange-500 text-sm font-bold transition-all" />
-
- +
+ setFormData({ ...formData, age: parseInt(e.target.value) })} - className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" + className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all" />
{/* Row B: Distilled + Bottled */} -
-
- +
+
+ setFormData({ ...formData, distilled_at: e.target.value })} - className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" + className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" />
-
- +
+ setFormData({ ...formData, bottled_at: e.target.value })} - className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" + className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" />
{/* Price and WB ID Row */} -
- - setFormData({ ...formData, purchase_price: e.target.value })} - className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 font-bold text-orange-500 text-sm" - /> -
- -
- -
+
+
+ setFormData({ ...formData, whiskybase_id: e.target.value })} - className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-mono" + type="number" + inputMode="decimal" + step="0.01" + placeholder="0.00" + value={formData.purchase_price} + onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })} + className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 font-bold text-orange-500 text-sm transition-all" /> - {discoveryResult && ( -
-

Empfehlung:

-

{discoveryResult.title}

-
- - - - +
+ +
+ +
+ setFormData({ ...formData, whiskybase_id: e.target.value })} + className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-300 text-sm font-mono transition-all" + /> + {discoveryResult && ( +
+

Recommendation:

+

{discoveryResult.title}

+
+ + + + +
-
- )} + )} +
{/* Batch Info */} -
- +
+ setFormData({ ...formData, batch_info: e.target.value })} - className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-200 text-sm font-bold" + className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" />
diff --git a/src/components/OnboardingTutorial.tsx b/src/components/OnboardingTutorial.tsx index 94f2e11..fe72c62 100644 --- a/src/components/OnboardingTutorial.tsx +++ b/src/components/OnboardingTutorial.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { usePathname } from 'next/navigation'; import { motion, AnimatePresence } from 'framer-motion'; import { Scan, GlassWater, Users, Settings, ArrowRight, X, Sparkles } from 'lucide-react'; +import { useI18n } from '@/i18n/I18nContext'; const ONBOARDING_KEY = 'dramlog_onboarding_complete'; @@ -14,40 +15,42 @@ interface OnboardingStep { description: string; } -const STEPS: OnboardingStep[] = [ +const getSteps = (t: (path: string) => string): OnboardingStep[] => [ { id: 'welcome', icon: , - title: 'Willkommen bei DramLog!', - description: 'Dein persönliches Whisky-Tagebuch. Scanne, bewerte und entdecke neue Drams.', + title: t('tutorial.steps.welcome.title'), + description: t('tutorial.steps.welcome.desc'), }, { id: 'scan', icon: , - title: 'Scanne deine Flaschen', - description: 'Fotografiere das Etikett einer Flasche – die KI erkennt automatisch alle Details.', + title: t('tutorial.steps.scan.title'), + description: t('tutorial.steps.scan.desc'), }, { id: 'taste', icon: , - title: 'Bewerte deine Drams', - description: 'Füge Tasting-Notizen hinzu und behalte den Überblick über deine Lieblings-Whiskys.', + title: t('tutorial.steps.taste.title'), + description: t('tutorial.steps.taste.desc'), }, { - id: 'session', + id: 'activity', icon: , - title: 'Tasting-Sessions', - description: 'Organisiere Verkostungen mit Freunden und vergleicht eure Bewertungen.', + title: t('tutorial.steps.activity.title'), + description: t('tutorial.steps.activity.desc'), }, { id: 'ready', icon: , - title: 'Bereit zum Start!', - description: 'Scanne jetzt deine erste Flasche mit dem orangefarbenen Button unten.', + title: t('tutorial.steps.ready.title'), + description: t('tutorial.steps.ready.desc'), }, ]; export default function OnboardingTutorial() { + const { t } = useI18n(); + const STEPS = getSteps(t); const [isOpen, setIsOpen] = useState(false); const [currentStep, setCurrentStep] = useState(0); const pathname = usePathname(); @@ -148,14 +151,14 @@ export default function OnboardingTutorial() { onClick={handleSkip} className="flex-1 py-3 px-4 text-sm font-bold text-zinc-500 hover:text-white transition-colors" > - Überspringen + {t('tutorial.skip')} )}
diff --git a/src/components/PasswordChangeForm.tsx b/src/components/PasswordChangeForm.tsx index 119584e..7d3a27b 100644 --- a/src/components/PasswordChangeForm.tsx +++ b/src/components/PasswordChangeForm.tsx @@ -5,7 +5,10 @@ import { motion } from 'framer-motion'; import { Lock, Eye, EyeOff, Loader2, CheckCircle, AlertCircle } from 'lucide-react'; import { changePassword } from '@/services/profile-actions'; +import { useI18n } from '@/i18n/I18nContext'; + export default function PasswordChangeForm() { + const { t } = useI18n(); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); @@ -20,13 +23,13 @@ export default function PasswordChangeForm() { if (newPassword !== confirmPassword) { setStatus('error'); - setError('Passwörter stimmen nicht überein'); + setError(t('settings.password.mismatch')); return; } if (newPassword.length < 6) { setStatus('error'); - setError('Passwort muss mindestens 6 Zeichen lang sein'); + setError(t('settings.password.tooShort')); return; } @@ -43,7 +46,7 @@ export default function PasswordChangeForm() { setTimeout(() => setStatus('idle'), 3000); } else { setStatus('error'); - setError(result.error || 'Fehler beim Ändern'); + setError(result.error || t('common.error')); } }); }; @@ -58,14 +61,14 @@ export default function PasswordChangeForm() { >

- Passwort ändern + {t('settings.password.title')}

{/* New Password */}
- Passwörter stimmen überein + {t('settings.password.match')} ) : ( <> - Passwörter stimmen nicht überein + {t('settings.password.mismatch')} )}
@@ -122,7 +125,7 @@ export default function PasswordChangeForm() { {status === 'success' && (
- Passwort erfolgreich geändert! + {t('settings.password.success')}
)} {status === 'error' && ( @@ -141,12 +144,12 @@ export default function PasswordChangeForm() { {isPending ? ( <> - Ändern... + {t('common.loading')} ) : ( <> - Passwort ändern + {t('settings.password.change')} )} diff --git a/src/components/ProfileForm.tsx b/src/components/ProfileForm.tsx index ae19499..d215912 100644 --- a/src/components/ProfileForm.tsx +++ b/src/components/ProfileForm.tsx @@ -5,6 +5,8 @@ import { motion } from 'framer-motion'; import { User, Mail, Save, Loader2, CheckCircle, AlertCircle } from 'lucide-react'; import { updateProfile } from '@/services/profile-actions'; +import { useI18n } from '@/i18n/I18nContext'; + interface ProfileFormProps { initialData: { email?: string; @@ -13,6 +15,7 @@ interface ProfileFormProps { } export default function ProfileForm({ initialData }: ProfileFormProps) { + const { t } = useI18n(); const [username, setUsername] = useState(initialData.username || ''); const [isPending, startTransition] = useTransition(); const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle'); @@ -33,7 +36,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) { setTimeout(() => setStatus('idle'), 3000); } else { setStatus('error'); - setError(result.error || 'Fehler beim Speichern'); + setError(result.error || t('common.error')); } }); }; @@ -47,7 +50,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) { >

- Profil + {t('nav.profile')}

@@ -63,19 +66,18 @@ export default function ProfileForm({ initialData }: ProfileFormProps) { disabled className="w-full px-4 py-3 bg-zinc-800/50 border border-zinc-700 rounded-xl text-zinc-500 cursor-not-allowed" /> -

E-Mail kann nicht geändert werden

{/* Username */}
setUsername(e.target.value)} - placeholder="Dein Benutzername" + placeholder={t('bottle.nameLabel')} className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" />
@@ -85,7 +87,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) { {status === 'success' && (
- Profil gespeichert! + {t('common.success')}
)} {status === 'error' && ( @@ -104,12 +106,12 @@ export default function ProfileForm({ initialData }: ProfileFormProps) { {isPending ? ( <> - Speichern... + {t('common.loading')} ) : ( <> - Speichern + {t('common.save')} )} diff --git a/src/components/SettingsHub.tsx b/src/components/SettingsHub.tsx new file mode 100644 index 0000000..fe66f85 --- /dev/null +++ b/src/components/SettingsHub.tsx @@ -0,0 +1,115 @@ +'use client'; + +import React from 'react'; +import { Settings, Cookie, Shield, ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { useI18n } from '@/i18n/I18nContext'; +import LanguageSwitcher from './LanguageSwitcher'; +import ProfileForm from './ProfileForm'; +import PasswordChangeForm from './PasswordChangeForm'; + +interface SettingsHubProps { + profile: { + email?: string; + username?: string | null; + created_at?: string; + }; +} + +export default function SettingsHub({ profile }: SettingsHubProps) { + const { t, locale } = useI18n(); + + return ( +
+ {/* Header */} +
+
+ + + +
+ +

{t('settings.title')}

+
+
+
+ + {/* Content */} +
+ {/* Language Switcher */} +
+

+ + {t('settings.language')} +

+ +
+ + {/* Profile Form */} + + + {/* Password Change Form */} + + + {/* Cookie Settings */} +
+

+ + {t('settings.cookieSettings')} +

+

+ {t('settings.cookieDesc')} +

+
+
+ + {t('settings.cookieNecessary')} +
+
+ + {t('settings.cookieFunctional')} +
+
+
+ + {/* Data & Privacy */} +
+

+ + {t('settings.privacy')} +

+
+

+ {t('settings.privacyDesc')} +

+ + {t('settings.privacyLink')} + +
+
+ + {/* Account info */} +
+

+ {t('settings.memberSince')}: {new Date(profile.created_at || '').toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { + day: '2-digit', + month: 'long', + year: 'numeric' + })} +

+
+
+
+ ); +} diff --git a/src/components/SplitCard.tsx b/src/components/SplitCard.tsx new file mode 100644 index 0000000..008af99 --- /dev/null +++ b/src/components/SplitCard.tsx @@ -0,0 +1,90 @@ +'use client'; + +import React from 'react'; +import { Package, Users, Info, Terminal, ChevronRight } from 'lucide-react'; + +interface Split { + id: string; + slug: string; + bottleName: string; + bottleImage?: string; + distillery?: string; + totalVolume: number; + hostShare: number; + participantCount?: number; + amountCl?: number; // for participating + status?: string; // for participating + isActive: boolean; + hostName?: string; +} + +interface SplitCardProps { + split: Split; + isParticipant?: boolean; + onSelect?: () => void; + showChevron?: boolean; +} + +export default function SplitCard({ split, isParticipant, onSelect, showChevron = true }: SplitCardProps) { + const statusLabels: Record = { + PENDING: 'Waiting', + APPROVED: 'Confirmed', + PAID: 'Paid', + SHIPPED: 'Shipped', + REJECTED: 'Rejected', + WAITLIST: 'Waitlist' + }; + + return ( +
+
+
+

+ {split.bottleName} +

+ {!split.isActive && ( + Closed + )} +
+
+ {isParticipant ? ( + <> + + + {split.amountCl}cl + + + {statusLabels[split.status || ''] || split.status} + + + ) : ( + <> + + + {split.participantCount ?? 0} Confirmed + + + + {split.totalVolume}cl Total + + + )} +
+ {split.hostName && ( +
+ By {split.hostName} +
+ )} +
+ + {showChevron && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/TastingHub.tsx b/src/components/TastingHub.tsx new file mode 100644 index 0000000..896bde3 --- /dev/null +++ b/src/components/TastingHub.tsx @@ -0,0 +1,471 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + X, GlassWater, Plus, Users, Calendar, + ChevronRight, Loader2, Sparkles, Check, + ArrowRight, User, Terminal, Package, Info +} from 'lucide-react'; +import { createClient } from '@/lib/supabase/client'; +import { useI18n } from '@/i18n/I18nContext'; +import { useSession } from '@/context/SessionContext'; +import { getHostSplits, getParticipatingSplits } from '@/services/split-actions'; +import AvatarStack from './AvatarStack'; +import SplitCard from './SplitCard'; + +interface Session { + id: string; + name: string; + scheduled_at: string; + ended_at?: string; + host_name?: string; + participant_count?: number; + whisky_count?: number; + participants?: string[]; + is_host: boolean; +} + +interface Split { + id: string; + slug: string; + bottleName: string; + bottleImage?: string; + totalVolume: number; + hostShare: number; + participantCount: number; + amountCl?: number; // for participating + status?: string; // for participating + isActive: boolean; + hostName?: string; +} + +interface TastingHubProps { + isOpen: boolean; + onClose: () => void; +} + +export default function TastingHub({ isOpen, onClose }: TastingHubProps) { + const { t, locale } = useI18n(); + const supabase = createClient(); + const { activeSession, setActiveSession } = useSession(); + + const [activeTab, setActiveTab] = useState<'tastings' | 'splits'>('tastings'); + const [mySessions, setMySessions] = useState([]); + const [guestSessions, setGuestSessions] = useState([]); + + const [mySplits, setMySplits] = useState([]); + const [participatingSplits, setParticipatingSplits] = useState([]); + + const [isLoading, setIsLoading] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [newName, setNewName] = useState(''); + + useEffect(() => { + if (isOpen) { + fetchAll(); + } + }, [isOpen]); + + const fetchAll = async () => { + setIsLoading(true); + await Promise.all([fetchSessions(), fetchSplits()]); + setIsLoading(false); + }; + + const fetchSessions = async () => { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return; + + // 1. Fetch My Sessions (Host) + const { data: hostData, error: hostError } = await supabase + .from('tasting_sessions') + .select(` + *, + session_participants ( + buddies (name) + ), + tastings (count) + `) + .eq('user_id', user.id) + .order('scheduled_at', { ascending: false }); + + // 2. Fetch Sessions I'm participating in (Guest) + const { data: participantData, error: partError } = await supabase + .from('tasting_sessions') + .select(` + *, + profiles (username), + tastings (count), + session_participants!inner ( + buddy_id, + buddies!inner ( + buddy_profile_id + ) + ) + `) + .eq('session_participants.buddies.buddy_profile_id', user.id) + .order('scheduled_at', { ascending: false }); + + if (hostData) { + setMySessions(hostData.map(s => ({ + ...s, + is_host: true, + participant_count: (s.session_participants as any[])?.length || 0, + participants: (s.session_participants as any[])?.filter(p => p.buddies).map(p => p.buddies.name) || [], + whisky_count: s.tastings[0]?.count || 0 + }))); + } + + if (participantData) { + // Filter out host sessions (though RLS might already separate them, better safe) + setGuestSessions(participantData + .filter(s => s.user_id !== user.id) + .map(s => ({ + ...s, + is_host: false, + host_name: s.profiles?.username || 'Host', + participant_count: 0, + whisky_count: s.tastings[0]?.count || 0 + }))); + } + }; + + const fetchSplits = async () => { + const hostRes = await getHostSplits(); + if (hostRes.success && hostRes.splits) { + setMySplits(hostRes.splits as Split[]); + } + + const partRes = await getParticipatingSplits(); + if (partRes.success && partRes.splits) { + setParticipatingSplits(partRes.splits as Split[]); + } + }; + + const handleCreateSession = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newName.trim()) return; + + setIsCreating(true); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return; + + const { data, error } = await supabase + .from('tasting_sessions') + .insert([{ name: newName.trim(), user_id: user.id }]) + .select() + .single(); + + if (error) { + console.error('Error creating session:', error); + } else { + fetchSessions(); + setNewName(''); + setActiveSession({ id: data.id, name: data.name }); + } + setIsCreating(false); + }; + + return ( + + {isOpen && ( + <> + {/* Backdrop */} + + + {/* Content */} + + {/* Header */} +
+
+
+
+ +
+

{t('hub.title')}

+
+

{t('hub.subtitle')}

+
+ +
+ + {/* Tabs */} +
+
+ + +
+
+ + {/* Scrolling Content */} +
+ {activeTab === 'tastings' ? ( + <> + {/* Create Section */} +
+

+ {t('hub.sections.startSession')} +

+
+ setNewName(e.target.value)} + placeholder={t('hub.placeholders.sessionName')} + className="flex-1 bg-black/40 border border-white/5 rounded-2xl px-6 py-4 text-sm font-bold text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-600 transition-all ring-inset focus:ring-1 focus:ring-orange-600/50" + /> + +
+
+ + {/* Active Session Highlight */} + {activeSession && ( +
+

+ {t('hub.sections.activeNow')} +

+
+
+ +
+
+
+

{t('session.activeSession')}

+

{activeSession.name}

+
+ +
+
+
+ )} + + {/* My Sessions List */} +
+

+ {t('hub.sections.yourSessions')} + {mySessions.length} +

+ + {isLoading ? ( +
+ +
+ ) : mySessions.length === 0 ? ( +
+ +

{t('hub.placeholders.noSessions')}

+
+ ) : ( +
+ {mySessions.map((session) => ( + { + setActiveSession({ id: session.id, name: session.name }); + onClose(); + }} + /> + ))} +
+ )} +
+ + {/* Guest Sessions List */} + {guestSessions.length > 0 && ( +
+

+ {t('hub.sections.participating')} + {guestSessions.length} +

+
+ {guestSessions.map((session) => ( + { + setActiveSession({ id: session.id, name: session.name }); + onClose(); + }} + /> + ))} +
+
+ )} + + ) : ( + <> + {/* Split Section */} +
+

+ {t('hub.sections.startSplit')} +

+ +
+ + {/* My Splits */} +
+

+ {t('hub.sections.yourSplits')} + {mySplits.length} +

+ + {isLoading ? ( +
+ +
+ ) : mySplits.length === 0 ? ( +
+ +

{t('hub.placeholders.noSplits')}

+
+ ) : ( +
+ {mySplits.map((split) => ( + { + onClose(); + window.location.href = '/splits/manage'; + }} + /> + ))} +
+ )} +
+ + {/* Participating Splits */} + {participatingSplits.length > 0 && ( +
+

+ {t('hub.sections.participating')} + {participatingSplits.length} +

+
+ {participatingSplits.map((split) => ( + { + onClose(); + window.location.href = `/splits/${split.slug}`; + }} + /> + ))} +
+
+ )} + + )} +
+
+ + )} +
+ ); +} + + + +function SessionCard({ session, isActive, locale, onSelect }: { session: Session, isActive: boolean, locale: string, onSelect: () => void }) { + return ( +
+
+
+

+ {session.name} +

+ {session.ended_at && ( + Done + )} +
+
+ + + {new Date(session.scheduled_at).toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')} + + {!session.is_host && session.host_name && ( + + + By {session.host_name} + + )} + {session.whisky_count! > 0 && ( + + + {session.whisky_count} + + )} +
+ {session.participants && session.participants.length > 0 && ( +
+ +
+ )} +
+ +
+ {isActive ? : } +
+
+ ); +} diff --git a/src/components/TastingNoteForm.tsx b/src/components/TastingNoteForm.tsx index 2032099..f33122d 100644 --- a/src/components/TastingNoteForm.tsx +++ b/src/components/TastingNoteForm.tsx @@ -45,6 +45,9 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId, const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null); const [showPaletteWarning, setShowPaletteWarning] = useState(false); + const [bottleOwnerId, setBottleOwnerId] = useState(null); + const [currentUserId, setCurrentUserId] = useState(null); + // Section collapse states const [isNoseExpanded, setIsNoseExpanded] = useState(false); const [isPalateExpanded, setIsPalateExpanded] = useState(false); @@ -52,14 +55,22 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId, const effectiveSessionId = sessionId || activeSession?.id; + useEffect(() => { + const getAuth = async () => { + const { data: { user } } = await supabase.auth.getUser(); + if (user) setCurrentUserId(user.id); + }; + getAuth(); + }, [supabase]); + useEffect(() => { const fetchData = async () => { if (!bottleId) return; - // Fetch Bottle Suggestions + // Fetch Bottle Suggestions and Owner const { data: bottleData } = await supabase .from('bottles') - .select('suggested_tags, suggested_custom_tags') + .select('suggested_tags, suggested_custom_tags, user_id') .eq('id', bottleId) .maybeSingle(); @@ -69,6 +80,9 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId, if (bottleData?.suggested_custom_tags) { setSuggestedCustomTags(bottleData.suggested_custom_tags); } + if (bottleData?.user_id) { + setBottleOwnerId(bottleData.user_id); + } // If Session ID, fetch session participants and pre-select them, and fetch last dram if (effectiveSessionId) { @@ -209,8 +223,22 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId, } }; + const isSharedBottle = bottleOwnerId && currentUserId && bottleOwnerId !== currentUserId; + return (
+ {isSharedBottle && !activeSession && ( +
+ +
+

Shared Bottle Ownership Check

+

Diese Flasche gehört einem Buddy.

+

+ Hinweis: Falls kein Session-Sharing aktiv ist, schlägt das Speichern fehl. Starte eine Session um gemeinsam zu bewerten! +

+
+
+ )} {activeSession && (
diff --git a/src/i18n/de.ts b/src/i18n/de.ts index d005916..46da511 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1,6 +1,29 @@ import { TranslationKeys } from './types'; export const de: TranslationKeys = { + splits: { + joinTitle: 'Sample bestellen', + amount: 'Menge', + shipping: 'Versand', + whisky: 'Whisky', + glass: 'Sample-Flasche', + total: 'Gesamt', + requestSent: 'Anfrage gesendet!', + requestSentDesc: 'Der Host wird deine Anfrage prüfen und sich bei dir melden.', + loginToParticipate: 'Anmelden zum Teilnehmen', + loginToParticipateDesc: 'Um an dieser Flaschenteilung teilzunehmen, musst du angemeldet sein.', + publicExplore: 'Aktuelle Flaschenteilungen', + waitlist: 'Auf Warteliste setzen', + sendRequest: 'Anfrage senden', + youAreParticipating: 'Du nimmst teil', + byHost: 'Von', + shareLink: 'Link teilen', + backToStart: 'Zurück zum Start', + noSplitsFound: 'Keine aktiven Teilungen gefunden', + falscheTeilung: 'Flaschenteilung', + clFlasche: 'cl Flasche', + jahre: 'Jahre', + }, common: { save: 'Speichern', cancel: 'Abbrechen', @@ -37,9 +60,14 @@ export const de: TranslationKeys = { }, searchPlaceholder: 'Flaschen oder Noten suchen...', noBottles: 'Keine Flaschen gefunden. Zeit für einen Einkauf! 🥃', - collection: 'Deine Sammlung', - reTry: 'Erneut versuchen', + collection: 'Kollektion', + reTry: 'Nochmal versuchen', all: 'Alle', + tagline: 'Modernes Minimalistisches Tasting-Tool.', + bottleCount: 'Flaschen', + imprint: 'Impressum', + privacy: 'Datenschutz', + settings: 'Einstellungen', }, grid: { searchPlaceholder: 'Suchen nach Name oder Distille...', @@ -165,6 +193,84 @@ export const de: TranslationKeys = { noSessions: 'Noch keine Sessions vorhanden.', expiryWarning: 'Diese Session läuft bald ab.', }, + nav: { + home: 'Home', + shelf: 'Sammlung', + activity: 'Aktivität', + search: 'Suchen', + profile: 'Profil', + }, + hub: { + title: 'Activity Hub', + subtitle: 'Live-Events & Splits', + tabs: { + tastings: 'Tastings', + splits: 'Splits', + }, + sections: { + startSession: 'Neue Session starten', + startSplit: 'Neuen Split starten', + activeNow: 'Gerade aktiv', + yourSessions: 'Deine Sessions', + yourSplits: 'Deine Splits', + participating: 'Teilnahmen', + }, + placeholders: { + sessionName: 'Session-Name (z.B. Islay Nacht)', + noSessions: 'Noch keine Sessions', + noSplits: 'Noch keine Splits erstellt', + openSplitCreator: 'Split-Creator öffnen', + }, + }, + tutorial: { + skip: 'Überspringen', + next: 'Weiter', + finish: 'Los geht\'s!', + steps: { + welcome: { + title: 'Willkommen bei DramLog!', + desc: 'Dein persönliches Whisky-Tagebuch. Scanne, bewerte und entdecke neue Drams.', + }, + scan: { + title: 'Scanne deine Flaschen', + desc: 'Fotografiere das Etikett einer Flasche – die KI erkennt automatisch alle Details.', + }, + taste: { + title: 'Bewerte deine Drams', + desc: 'Füge Tasting-Notizen hinzu und behalte den Überblick über deine Lieblings-Whiskys.', + }, + activity: { + title: 'Aktivitätshub', + desc: 'Organisiere Tasting-Sessions mit Freunden oder nimm an exklusiven Bottle Splits teil.', + }, + ready: { + title: 'Bereit zum Start!', + desc: 'Scanne jetzt deine erste Flasche mit dem orangefarbenen Button unten.', + }, + }, + }, + settings: { + title: 'Einstellungen', + language: 'Sprache', + cookieSettings: 'Cookie-Einstellungen', + cookieDesc: 'Diese App verwendet nur technisch notwendige Cookies für die Authentifizierung und funktionale Cookies für UI-Präferenzen.', + cookieNecessary: 'Notwendig: Supabase Auth Cookies', + cookieFunctional: 'Funktional: Sprache, UI-Status', + privacy: 'Datenschutz', + privacyDesc: 'Deine Daten werden sicher auf EU-Servern gespeichert.', + privacyLink: 'Datenschutzerklärung lesen', + memberSince: 'Mitglied seit', + password: { + title: 'Passwort ändern', + newPassword: 'Neues Passwort', + confirmPassword: 'Passwort bestätigen', + match: 'Passwörter stimmen überein', + mismatch: 'Passwörter stimmen nicht überein', + tooShort: 'Passwort muss mindestens 6 Zeichen lang sein', + success: 'Passwort erfolgreich geändert!', + change: 'Passwort ändern', + }, + }, aroma: { 'Apfel': 'Apfel', 'Grüner Apfel': 'Grüner Apfel', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2659ac4..6ff06e5 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1,6 +1,29 @@ import { TranslationKeys } from './types'; export const en: TranslationKeys = { + splits: { + joinTitle: 'Order Sample', + amount: 'Amount', + shipping: 'Shipping', + whisky: 'Whisky', + glass: 'Sample Bottle', + total: 'Total', + requestSent: 'Request sent!', + requestSentDesc: 'The host will review your request and get back to you.', + loginToParticipate: 'Login to participate', + loginToParticipateDesc: 'You must be logged in to participate in this bottle split.', + publicExplore: 'Active Bottle Splits', + waitlist: 'Join Waitlist', + sendRequest: 'Send Request', + youAreParticipating: 'You are participating', + byHost: 'By', + shareLink: 'Share Link', + backToStart: 'Back to start', + noSplitsFound: 'No active splits found', + falscheTeilung: 'Bottle Split', + clFlasche: 'cl Bottle', + jahre: 'Years', + }, common: { save: 'Save', cancel: 'Cancel', @@ -37,9 +60,14 @@ export const en: TranslationKeys = { }, searchPlaceholder: 'Search bottles or notes...', noBottles: 'No bottles found. Time to go shopping! 🥃', - collection: 'Your Collection', - reTry: 'Retry', + collection: 'Collection', + reTry: 'Try Again', all: 'All', + tagline: 'Modern Minimalist Tasting Tool.', + bottleCount: 'Bottles', + imprint: 'Imprint', + privacy: 'Privacy', + settings: 'Settings', }, grid: { searchPlaceholder: 'Search by name or distillery...', @@ -165,6 +193,84 @@ export const en: TranslationKeys = { noSessions: 'No sessions yet.', expiryWarning: 'This session will expire soon.', }, + nav: { + home: 'Home', + shelf: 'Shelf', + activity: 'Activity', + search: 'Search', + profile: 'Profile', + }, + hub: { + title: 'Activity Hub', + subtitle: 'Live Events & Splits', + tabs: { + tastings: 'Tastings', + splits: 'Splits', + }, + sections: { + startSession: 'Start New Session', + startSplit: 'Start New Split', + activeNow: 'Active Right Now', + yourSessions: 'Your Sessions', + yourSplits: 'Your Splits', + participating: 'Participating', + }, + placeholders: { + sessionName: 'Session Name (e.g. Islay Night)', + noSessions: 'No sessions yet', + noSplits: 'No splits created', + openSplitCreator: 'Open Split Creator', + }, + }, + tutorial: { + skip: 'Skip', + next: 'Next', + finish: 'Let\'s go!', + steps: { + welcome: { + title: 'Welcome to DramLog!', + desc: 'Your personal whisky diary. Scan, rate and discover new drams.', + }, + scan: { + title: 'Scan your bottles', + desc: 'Take a photo of a bottle label – AI automatically recognizes all details.', + }, + taste: { + title: 'Rate your drams', + desc: 'Add tasting notes and keep track of your favorite whiskies.', + }, + activity: { + title: 'Activity Hub', + desc: 'Organize tasting sessions with friends or join exclusive bottle splits.', + }, + ready: { + title: 'Ready to start!', + desc: 'Scan your first bottle now using the orange button below.', + }, + }, + }, + settings: { + title: 'Settings', + language: 'Language', + cookieSettings: 'Cookie Settings', + cookieDesc: 'This app uses only technically necessary cookies for authentication and functional cookies for UI preferences.', + cookieNecessary: 'Necessary: Supabase Auth Cookies', + cookieFunctional: 'Functional: Language, UI Status', + privacy: 'Privacy', + privacyDesc: 'Your data is securely stored on EU servers.', + privacyLink: 'Read Privacy Policy', + memberSince: 'Member since', + password: { + title: 'Change Password', + newPassword: 'New Password', + confirmPassword: 'Confirm Password', + match: 'Passwords match', + mismatch: 'Passwords do not match', + tooShort: 'Password must be at least 6 characters long', + success: 'Password successfully changed!', + change: 'Change Password', + }, + }, aroma: { 'Apfel': 'Apple', 'Grüner Apfel': 'Green Apple', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index d4f3bb3..c947962 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -1,4 +1,27 @@ export type TranslationKeys = { + splits: { + joinTitle: string; + amount: string; + shipping: string; + whisky: string; + glass: string; + total: string; + requestSent: string; + requestSentDesc: string; + loginToParticipate: string; + loginToParticipateDesc: string; + publicExplore: string; + waitlist: string; + sendRequest: string; + youAreParticipating: string; + byHost: string; + shareLink: string; + backToStart: string; + noSplitsFound: string; + falscheTeilung: string; + clFlasche: string; + jahre: string; + }; common: { save: string; cancel: string; @@ -38,6 +61,11 @@ export type TranslationKeys = { collection: string; reTry: string; all: string; + tagline: string; + bottleCount: string; + imprint: string; + privacy: string; + settings: string; }; grid: { searchPlaceholder: string; @@ -163,5 +191,68 @@ export type TranslationKeys = { noSessions: string; expiryWarning: string; }; + nav: { + home: string; + shelf: string; + activity: string; + search: string; + profile: string; + }; + hub: { + title: string; + subtitle: string; + tabs: { + tastings: string; + splits: string; + }; + sections: { + startSession: string; + startSplit: string; + activeNow: string; + yourSessions: string; + yourSplits: string; + participating: string; + }; + placeholders: { + sessionName: string; + noSessions: string; + noSplits: string; + openSplitCreator: string; + }; + }; + tutorial: { + skip: string; + next: string; + finish: string; + steps: { + welcome: { title: string; desc: string }; + scan: { title: string; desc: string }; + taste: { title: string; desc: string }; + activity: { title: string; desc: string }; + ready: { title: string; desc: string }; + }; + }; + settings: { + title: string; + language: string; + cookieSettings: string; + cookieDesc: string; + cookieNecessary: string; + cookieFunctional: string; + privacy: string; + privacyDesc: string; + privacyLink: string; + memberSince: string; + password: { + title: string; + newPassword: string; + confirmPassword: string; + match: string; + mismatch: string; + tooShort: string; + success: string; + change: string; + }; + }; aroma: Record; }; diff --git a/src/services/save-tasting.ts b/src/services/save-tasting.ts index e7f1dea..d614976 100644 --- a/src/services/save-tasting.ts +++ b/src/services/save-tasting.ts @@ -22,7 +22,7 @@ export async function saveTasting(rawData: TastingNoteData) { } } - const { data: tasting, error } = await supabase + const { data: tasting, error: insertError } = await supabase .from('tastings') .insert({ bottle_id: data.bottle_id, @@ -39,7 +39,29 @@ export async function saveTasting(rawData: TastingNoteData) { .select() .single(); - if (error) throw error; + if (insertError) { + console.error('[saveTasting] Insert error:', { + code: insertError.code, + message: insertError.message, + details: insertError.details, + hint: insertError.hint, + data: { + bottle_id: data.bottle_id, + user_id: user.id, + session_id: data.session_id + } + }); + + // Check for RLS violation (42501) + if ((insertError as any).code === '42501') { + return { + success: false, + error: 'Keine Berechtigung zum Speichern (RLS). Prüfe ob du Besitzer der Flasche bist oder in einer aktiven Session teilnimmst.', + code: 'RLS_VIOLATION' + }; + } + throw insertError; + } // Add buddy tags if any if (data.buddy_ids && data.buddy_ids.length > 0) { @@ -78,11 +100,11 @@ export async function saveTasting(rawData: TastingNoteData) { revalidatePath(`/bottles/${data.bottle_id}`); return { success: true, data: tasting }; - } catch (error) { + } catch (error: any) { console.error('Save Tasting Error:', error); return { success: false, - error: error instanceof Error ? error.message : 'Fehler beim Speichern der Tasting Note', + error: error instanceof Error ? error.message : (error?.message || 'Fehler beim Speichern.'), }; } } diff --git a/src/services/split-actions.ts b/src/services/split-actions.ts index 0f0198f..f9b54a2 100644 --- a/src/services/split-actions.ts +++ b/src/services/split-actions.ts @@ -206,6 +206,11 @@ export async function getSplitBySlug(slug: string): Promise<{ const remaining = available - taken - reserved; const bottle = split.bottles as any; + if (!bottle) { + console.error(`Split ${slug} has no associated bottle data.`); + return { success: false, error: 'Flaschendaten für diesen Split fehlen.' }; + } + // Convert sample sizes from DB format const sampleSizes = ((split.sample_sizes as any[]) || []).map(s => ({ cl: s.cl, @@ -228,8 +233,8 @@ export async function getSplitBySlug(slug: string): Promise<{ createdAt: split.created_at, bottle: { id: bottle.id, - name: bottle.name, - distillery: bottle.distillery, + name: bottle.name || 'Unbekannte Flasche', + distillery: bottle.distillery || 'Unbekannte Destillerie', imageUrl: bottle.image_url, abv: bottle.abv, age: bottle.age, @@ -500,8 +505,84 @@ export async function getHostSplits(): Promise<{ } /** - * Generate forum export text + * Get all splits the current user is participating in */ +export async function getParticipatingSplits(): Promise<{ + success: boolean; + splits?: Array<{ + id: string; + slug: string; + bottleName: string; + bottleImage?: string; + totalVolume: number; + hostShare: number; + participantCount: number; + amountCl: number; + status: string; + isActive: boolean; + hostName?: string; + }>; + error?: string; +}> { + const supabase = await createClient(); + + try { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return { success: false, error: 'Nicht autorisiert' }; + } + + const { data: participations, error } = await supabase + .from('split_participants') + .select(` + amount_cl, + status, + bottle_splits!inner ( + id, + public_slug, + total_volume, + host_share, + is_active, + host_id, + bottles (name, image_url), + profiles:host_id (username) + ) + `) + .eq('user_id', user.id) + .order('created_at', { ascending: false }); + + if (error) { + console.error('getParticipatingSplits error:', error); + return { success: false, error: 'Fehler beim Laden' }; + } + + return { + success: true, + splits: (participations || []).map(p => { + const split = p.bottle_splits as any; + const bottle = split.bottles as any; + const hostProfile = split.profiles as any; + + return { + id: split.id, + slug: split.public_slug, + bottleName: bottle?.name || 'Unbekannt', + bottleImage: bottle?.image_url, + totalVolume: split.total_volume, + hostShare: split.host_share, + participantCount: 0, // We could count this but might be overkill for list view + amountCl: p.amount_cl, + status: p.status, + isActive: split.is_active, + hostName: hostProfile?.username || 'Host', + }; + }), + }; + } catch (error) { + console.error('getParticipatingSplits unexpected error:', error); + return { success: false, error: 'Unerwarteter Fehler' }; + } +} export async function generateForumExport(splitId: string): Promise<{ success: boolean; text?: string; @@ -603,3 +684,51 @@ export async function closeSplit(splitId: string): Promise<{ return { success: false, error: 'Unerwarteter Fehler' }; } } +/** + * Get all active splits for public discovery + */ +export async function getActiveSplits() { + const supabase = await createClient(); + + try { + const { data: splits, error } = await supabase + .from('bottle_splits') + .select(` + id, + public_slug, + total_volume, + host_share, + is_active, + bottles (name, image_url, distillery), + profiles:host_id (username) + `) + .eq('is_active', true) + .order('created_at', { ascending: false }); + + if (error) { + console.error('getActiveSplits error:', error); + return { success: false, error: 'Fehler beim Laden' }; + } + + return { + success: true, + splits: (splits || []).map(s => { + const bottle = s.bottles as any; + const hostProfile = s.profiles as any; + return { + id: s.id, + slug: s.public_slug, + bottleName: bottle?.name || 'Unbekannt', + bottleImage: bottle?.image_url, + distillery: bottle?.distillery, + totalVolume: s.total_volume, + hostShare: s.host_share, + hostName: hostProfile?.username || 'Host', + }; + }), + }; + } catch (error) { + console.error('getActiveSplits unexpected error:', error); + return { success: false, error: 'Unerwarteter Fehler' }; + } +}