feat: implement buddies and tasting sessions features

This commit is contained in:
2025-12-18 10:56:41 +01:00
parent 314967b31b
commit d07af05b66
9 changed files with 771 additions and 14 deletions

View File

@@ -9,7 +9,14 @@ import StatusSwitcher from '@/components/StatusSwitcher';
import TastingList from '@/components/TastingList'; import TastingList from '@/components/TastingList';
import DeleteBottleButton from '@/components/DeleteBottleButton'; import DeleteBottleButton from '@/components/DeleteBottleButton';
export default async function BottlePage({ params }: { params: { id: string } }) { export default async function BottlePage({
params,
searchParams
}: {
params: { id: string },
searchParams: { session_id?: string }
}) {
const sessionId = searchParams.session_id;
const supabase = createServerComponentClient({ cookies }); const supabase = createServerComponentClient({ cookies });
const { data: bottle } = await supabase const { data: bottle } = await supabase
@@ -24,7 +31,15 @@ export default async function BottlePage({ params }: { params: { id: string } })
const { data: tastings } = await supabase const { data: tastings } = await supabase
.from('tastings') .from('tastings')
.select('*') .select(`
*,
tasting_tags (
buddies (
id,
name
)
)
`)
.eq('bottle_id', params.id) .eq('bottle_id', params.id)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
@@ -127,9 +142,9 @@ export default async function BottlePage({ params }: { params: { id: string } })
{/* Form */} {/* Form */}
<div className="lg:col-span-1 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-6 bg-white dark:bg-zinc-900/50 md:sticky md:top-24"> <div className="lg:col-span-1 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-6 bg-white dark:bg-zinc-900/50 md:sticky md:top-24">
<h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-amber-600"> <h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-amber-600">
<Droplets size={20} /> Neu Verkosten <Droplets size={20} /> {sessionId ? 'Session-Notiz' : 'Neu Verkosten'}
</h3> </h3>
<TastingNoteForm bottleId={bottle.id} /> <TastingNoteForm bottleId={bottle.id} sessionId={sessionId} />
</div> </div>
{/* List */} {/* List */}

View File

@@ -5,6 +5,8 @@ import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import CameraCapture from "@/components/CameraCapture"; import CameraCapture from "@/components/CameraCapture";
import BottleGrid from "@/components/BottleGrid"; import BottleGrid from "@/components/BottleGrid";
import AuthForm from "@/components/AuthForm"; import AuthForm from "@/components/AuthForm";
import BuddyList from "@/components/BuddyList";
import SessionList from "@/components/SessionList";
export default function Home() { export default function Home() {
const supabase = createClientComponentClient(); const supabase = createClientComponentClient();
@@ -116,7 +118,15 @@ export default function Home() {
</button> </button>
</header> </header>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-5xl">
<div className="flex flex-col gap-8">
<CameraCapture onSaveComplete={fetchCollection} /> <CameraCapture onSaveComplete={fetchCollection} />
<SessionList />
</div>
<div>
<BuddyList />
</div>
</div>
<div className="w-full mt-12"> <div className="w-full mt-12">
<h2 className="text-2xl font-bold mb-6 text-zinc-800 dark:text-zinc-100 flex items-center gap-3"> <h2 className="text-2xl font-bold mb-6 text-zinc-800 dark:text-zinc-100 flex items-center gap-3">

View File

@@ -0,0 +1,275 @@
'use client';
import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { ChevronLeft, Users, Calendar, GlassWater, Plus, Trash2, Loader2, Sparkles, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
interface Buddy {
id: string;
name: string;
}
interface Participant {
buddy_id: string;
buddies: {
name: string;
};
}
interface Session {
id: string;
name: string;
scheduled_at: string;
}
interface SessionTasting {
id: string;
rating: number;
bottles: {
id: string;
name: string;
distillery: string;
};
}
export default function SessionDetailPage() {
const { id } = useParams();
const router = useRouter();
const supabase = createClientComponentClient();
const [session, setSession] = useState<Session | null>(null);
const [participants, setParticipants] = useState<Participant[]>([]);
const [tastings, setTastings] = useState<SessionTasting[]>([]);
const [allBuddies, setAllBuddies] = useState<Buddy[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isAddingParticipant, setIsAddingParticipant] = useState(false);
useEffect(() => {
fetchSessionData();
}, [id]);
const fetchSessionData = async () => {
setIsLoading(true);
// Fetch Session
const { data: sessionData } = await supabase
.from('tasting_sessions')
.select('*')
.eq('id', id)
.single();
if (sessionData) {
setSession(sessionData);
// Fetch Participants
const { data: partData } = await supabase
.from('session_participants')
.select('buddy_id, buddies(name)')
.eq('session_id', id);
setParticipants((partData as any)?.map((p: any) => ({
buddy_id: p.buddy_id,
buddies: p.buddies
})) || []);
// Fetch Tastings in this session
const { data: tastingData } = await supabase
.from('tastings')
.select('id, rating, bottles(id, name, distillery)')
.eq('session_id', id);
setTastings((tastingData as any)?.map((t: any) => ({
id: t.id,
rating: t.rating,
bottles: t.bottles
})) || []);
// Fetch all buddies for the picker
const { data: buddies } = await supabase
.from('buddies')
.select('id, name')
.order('name');
setAllBuddies(buddies || []);
}
setIsLoading(false);
};
const handleAddParticipant = async (buddyId: string) => {
if (participants.some(p => p.buddy_id === buddyId)) return;
const { error } = await supabase
.from('session_participants')
.insert([{ session_id: id, buddy_id: buddyId }]);
if (!error) {
fetchSessionData();
}
};
const handleRemoveParticipant = async (buddyId: string) => {
const { error } = await supabase
.from('session_participants')
.delete()
.eq('session_id', id)
.eq('buddy_id', buddyId);
if (!error) {
fetchSessionData();
}
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-50 dark:bg-black">
<Loader2 size={48} className="animate-spin text-amber-600" />
</div>
);
}
if (!session) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-zinc-50 dark:bg-black p-6">
<h1 className="text-2xl font-bold">Session nicht gefunden</h1>
<Link href="/" className="text-amber-600 font-bold">Zurück zum Start</Link>
</div>
);
}
return (
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12 lg:p-24">
<div className="max-w-4xl mx-auto space-y-8">
{/* Back Button */}
<Link
href="/"
className="inline-flex items-center gap-2 text-zinc-400 hover:text-amber-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
>
<ChevronLeft size={16} />
Alle Sessions
</Link>
{/* Hero */}
<header className="bg-white dark:bg-zinc-900 rounded-3xl p-8 border border-zinc-200 dark:border-zinc-800 shadow-xl relative overflow-hidden">
<div className="absolute top-0 right-0 p-8 opacity-5">
<GlassWater size={120} />
</div>
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start md:items-end gap-6">
<div className="space-y-2">
<div className="flex items-center gap-2 text-amber-600 font-black uppercase text-[10px] tracking-widest">
<Sparkles size={14} />
Tasting Session
</div>
<h1 className="text-4xl md:text-5xl font-black text-zinc-900 dark:text-white tracking-tighter">
{session.name}
</h1>
<div className="flex items-center gap-4 text-zinc-500 font-bold text-sm">
<span className="flex items-center gap-1.5">
<Calendar size={16} className="text-zinc-400" />
{new Date(session.scheduled_at).toLocaleDateString('de-DE')}
</span>
</div>
</div>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Sidebar: Participants */}
<aside className="md:col-span-1 space-y-6">
<div className="bg-white dark:bg-zinc-900 rounded-3xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-lg">
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-400 mb-6 flex items-center gap-2">
<Users size={16} className="text-amber-600" />
Teilnehmer
</h3>
<div className="space-y-3 mb-6">
{participants.length === 0 ? (
<p className="text-xs text-zinc-500 italic">Noch keine Teilnehmer...</p>
) : (
participants.map((p) => (
<div key={p.buddy_id} className="flex items-center justify-between group">
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300">{p.buddies.name}</span>
<button
onClick={() => handleRemoveParticipant(p.buddy_id)}
className="text-zinc-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
<div className="border-t border-zinc-100 dark:border-zinc-800 pt-6">
<label className="text-[10px] font-black uppercase tracking-widest text-zinc-400 block mb-3">Buddy hinzufügen</label>
<select
onChange={(e) => {
if (e.target.value) handleAddParticipant(e.target.value);
e.target.value = "";
}}
className="w-full bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-xs font-bold outline-none focus:ring-2 focus:ring-amber-500/50"
>
<option value="">Auswählen...</option>
{allBuddies
.filter(b => !participants.some(p => p.buddy_id === b.id))
.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))
}
</select>
</div>
</div>
</aside>
{/* Main Content: Bottle List */}
<section className="md:col-span-2 space-y-6">
<div className="bg-white dark:bg-zinc-900 rounded-3xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-lg">
<div className="flex justify-between items-center mb-8">
<h3 className="text-sm font-black uppercase tracking-widest text-zinc-400 flex items-center gap-2">
<GlassWater size={16} className="text-amber-600" />
Verkostete Flaschen
</h3>
<Link
href={`/?session_id=${id}`} // Redirect to home with context
className="bg-amber-600 hover:bg-amber-700 text-white px-4 py-2 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-amber-600/20"
>
<Plus size={16} />
Flasche hinzufügen
</Link>
</div>
{tastings.length === 0 ? (
<div className="text-center py-12 text-zinc-500 italic text-sm">
Noch keine Flaschen in dieser Session verkostet. 🥃
</div>
) : (
<div className="space-y-4">
{tastings.map((t) => (
<div key={t.id} className="flex items-center justify-between p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-100 dark:border-zinc-800">
<div>
<div className="text-[9px] font-black text-amber-600 uppercase tracking-widest">{t.bottles.distillery}</div>
<div className="font-bold text-zinc-800 dark:text-zinc-100">{t.bottles.name}</div>
</div>
<div className="flex items-center gap-4">
<div className="bg-amber-100 dark:bg-amber-900/30 text-amber-700 px-3 py-1 rounded-xl text-xs font-black">
{t.rating}/100
</div>
<Link
href={`/bottles/${t.bottles.id}`}
className="p-2 text-zinc-300 hover:text-amber-600 transition-colors"
>
<ChevronRight size={20} />
</Link>
</div>
</div>
))}
</div>
)}
</div>
</section>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { Users, UserPlus, Trash2, User, Loader2 } from 'lucide-react';
interface Buddy {
id: string;
name: string;
buddy_profile_id: string | null;
}
export default function BuddyList() {
const supabase = createClientComponentClient();
const [buddies, setBuddies] = useState<Buddy[]>([]);
const [newName, setNewName] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isAdding, setIsAdding] = useState(false);
useEffect(() => {
fetchBuddies();
}, []);
const fetchBuddies = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data, error } = await supabase
.from('buddies')
.select('*')
.order('name');
if (error) {
console.error('Error fetching buddies:', error);
} else {
setBuddies(data || []);
}
setIsLoading(false);
};
const handleAddBuddy = async (e: React.FormEvent) => {
e.preventDefault();
if (!newName.trim()) return;
setIsAdding(true);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const { data, error } = await supabase
.from('buddies')
.insert([{ name: newName.trim(), user_id: user.id }])
.select();
if (error) {
console.error('Error adding buddy:', error);
} else {
setBuddies(prev => [...(data || []), ...prev].sort((a, b) => a.name.localeCompare(b.name)));
setNewName('');
}
setIsAdding(false);
};
const handleDeleteBuddy = async (id: string) => {
const { error } = await supabase
.from('buddies')
.delete()
.eq('id', id);
if (error) {
console.error('Error deleting buddy:', error);
} else {
setBuddies(prev => prev.filter(b => b.id !== id));
}
};
return (
<div className="bg-white dark:bg-zinc-900 rounded-3xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xl">
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-zinc-800 dark:text-zinc-100 italic">
<Users size={24} className="text-amber-600" />
Deine Buddies
</h3>
<form onSubmit={handleAddBuddy} className="flex gap-2 mb-6">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Buddy Name..."
className="flex-1 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
<button
type="submit"
disabled={isAdding || !newName.trim()}
className="bg-amber-600 hover:bg-amber-700 text-white p-2 rounded-xl transition-all disabled:opacity-50"
>
{isAdding ? <Loader2 size={20} className="animate-spin" /> : <UserPlus size={20} />}
</button>
</form>
{isLoading ? (
<div className="flex justify-center py-8 text-zinc-400">
<Loader2 size={24} className="animate-spin" />
</div>
) : buddies.length === 0 ? (
<div className="text-center py-8 text-zinc-500 text-sm">
Noch keine Buddies hinzugefügt.
</div>
) : (
<div className="space-y-2 max-h-[300px] overflow-y-auto scrollbar-hide">
{buddies.map((buddy) => (
<div
key={buddy.id}
className="flex items-center justify-between p-3 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-100 dark:border-zinc-800 group hover:border-amber-500/30 transition-all"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-amber-700 dark:text-amber-500">
<User size={16} />
</div>
<div>
<span className="font-semibold text-zinc-800 dark:text-zinc-200 text-sm">{buddy.name}</span>
{buddy.buddy_profile_id && (
<span className="ml-2 inline-block px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-500 text-[8px] font-black uppercase rounded">verknüpft</span>
)}
</div>
</div>
<button
onClick={() => handleDeleteBuddy(buddy.id)}
className="text-zinc-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all p-1"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import React, { useState, useEffect } from 'react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { Calendar, Plus, GlassWater, Loader2, ChevronRight, Users } from 'lucide-react';
import Link from 'next/link';
interface Session {
id: string;
name: string;
scheduled_at: string;
participant_count?: number;
}
export default function SessionList() {
const supabase = createClientComponentClient();
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [newName, setNewName] = useState('');
useEffect(() => {
fetchSessions();
}, []);
const fetchSessions = async () => {
const { data, error } = await supabase
.from('tasting_sessions')
.select(`
*,
session_participants (count)
`)
.order('scheduled_at', { ascending: false });
if (error) {
console.error('Error fetching sessions:', error);
} else {
setSessions(data.map(s => ({
...s,
participant_count: s.session_participants[0]?.count || 0
})) || []);
}
setIsLoading(false);
};
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 {
setSessions(prev => [data, ...prev]);
setNewName('');
}
setIsCreating(false);
};
return (
<div className="bg-white dark:bg-zinc-900 rounded-3xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xl">
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-zinc-800 dark:text-zinc-100 italic">
<GlassWater size={24} className="text-amber-600" />
Tasting Sessions
</h3>
<form onSubmit={handleCreateSession} className="flex gap-2 mb-6">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="Event Name (z.B. Islay Night)..."
className="flex-1 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
<button
type="submit"
disabled={isCreating || !newName.trim()}
className="bg-amber-600 hover:bg-amber-700 text-white p-2 rounded-xl transition-all disabled:opacity-50"
>
{isCreating ? <Loader2 size={20} className="animate-spin" /> : <Plus size={20} />}
</button>
</form>
{isLoading ? (
<div className="flex justify-center py-8 text-zinc-400">
<Loader2 size={24} className="animate-spin" />
</div>
) : sessions.length === 0 ? (
<div className="text-center py-8 text-zinc-500 text-sm">
Noch keine Sessions geplant.
</div>
) : (
<div className="space-y-3">
{sessions.map((session) => (
<Link
key={session.id}
href={`/sessions/${session.id}`}
className="flex items-center justify-between p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-100 dark:border-zinc-800 group hover:border-amber-500/30 transition-all"
>
<div className="space-y-1">
<div className="font-bold text-zinc-800 dark:text-zinc-100">{session.name}</div>
<div className="flex items-center gap-4 text-[10px] font-black uppercase tracking-widest text-zinc-400">
<span className="flex items-center gap-1">
<Calendar size={12} />
{new Date(session.scheduled_at).toLocaleDateString('de-DE')}
</span>
{session.participant_count! > 0 && (
<span className="flex items-center gap-1">
<Users size={12} />
{session.participant_count} Teilnehmer
</span>
)}
</div>
</div>
<ChevronRight size={20} className="text-zinc-300 group-hover:text-amber-500 transition-colors" />
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Calendar, Star, ArrowUpDown, Clock, Trash2, Loader2 } from 'lucide-react'; import { Calendar, Star, ArrowUpDown, Clock, Trash2, Loader2, Users } from 'lucide-react';
import { deleteTasting } from '@/services/delete-tasting'; import { deleteTasting } from '@/services/delete-tasting';
interface Tasting { interface Tasting {
@@ -13,6 +13,12 @@ interface Tasting {
is_sample?: boolean; is_sample?: boolean;
bottle_id: string; bottle_id: string;
created_at: string; created_at: string;
tasting_tags?: {
buddies: {
id: string;
name: string;
}
}[];
} }
interface TastingListProps { interface TastingListProps {
@@ -156,6 +162,20 @@ export default function TastingList({ initialTastings }: TastingListProps) {
</div> </div>
)} )}
</div> </div>
{note.tasting_tags && note.tasting_tags.length > 0 && (
<div className="pt-3 flex flex-wrap gap-2 border-t border-zinc-100 dark:border-zinc-800">
<span className="text-[10px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-1.5 mr-1">
<Users size={12} className="text-amber-500" />
Gekostet mit:
</span>
{note.tasting_tags.map((tag) => (
<span key={tag.buddies.id} className="text-[10px] font-bold text-zinc-600 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800/80 px-2 py-0.5 rounded-full">
{tag.buddies.name}
</span>
))}
</div>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,14 +1,22 @@
'use client'; 'use client';
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { saveTasting } from '@/services/save-tasting'; import { saveTasting } from '@/services/save-tasting';
import { Loader2, Send, Star } from 'lucide-react'; import { Loader2, Send, Star, Users, Check } from 'lucide-react';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
interface Buddy {
id: string;
name: string;
}
interface TastingNoteFormProps { interface TastingNoteFormProps {
bottleId: string; bottleId: string;
sessionId?: string;
} }
export default function TastingNoteForm({ bottleId }: TastingNoteFormProps) { export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteFormProps) {
const supabase = createClientComponentClient();
const [rating, setRating] = useState(85); const [rating, setRating] = useState(85);
const [nose, setNose] = useState(''); const [nose, setNose] = useState('');
const [palate, setPalate] = useState(''); const [palate, setPalate] = useState('');
@@ -16,6 +24,35 @@ export default function TastingNoteForm({ bottleId }: TastingNoteFormProps) {
const [isSample, setIsSample] = useState(false); const [isSample, setIsSample] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [buddies, setBuddies] = useState<Buddy[]>([]);
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
useEffect(() => {
const fetchData = async () => {
// Fetch All Buddies
const { data: buddiesData } = await supabase.from('buddies').select('id, name').order('name');
setBuddies(buddiesData || []);
// If Session ID, fetch session participants and pre-select them
if (sessionId) {
const { data: participants } = await supabase
.from('session_participants')
.select('buddy_id')
.eq('session_id', sessionId);
if (participants) {
setSelectedBuddyIds(participants.map(p => p.buddy_id));
}
}
};
fetchData();
}, [sessionId]);
const toggleBuddy = (id: string) => {
setSelectedBuddyIds(prev =>
prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]
);
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -25,17 +62,20 @@ export default function TastingNoteForm({ bottleId }: TastingNoteFormProps) {
try { try {
const result = await saveTasting({ const result = await saveTasting({
bottle_id: bottleId, bottle_id: bottleId,
session_id: sessionId,
rating, rating,
nose_notes: nose, nose_notes: nose,
palate_notes: palate, palate_notes: palate,
finish_notes: finish, finish_notes: finish,
is_sample: isSample, is_sample: isSample,
buddy_ids: selectedBuddyIds,
}); });
if (result.success) { if (result.success) {
setNose(''); setNose('');
setPalate(''); setPalate('');
setFinish(''); setFinish('');
setSelectedBuddyIds([]);
// We don't need to manually refresh because of revalidatePath in the server action // We don't need to manually refresh because of revalidatePath in the server action
} else { } else {
setError(result.error || 'Fehler beim Speichern'); setError(result.error || 'Fehler beim Speichern');
@@ -131,6 +171,31 @@ export default function TastingNoteForm({ bottleId }: TastingNoteFormProps) {
/> />
</div> </div>
{buddies.length > 0 && (
<div className="space-y-3">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
<Users size={14} className="text-amber-500" />
Gekostet mit (Buddies)
</label>
<div className="flex flex-wrap gap-2">
{buddies.map((buddy) => (
<button
key={buddy.id}
type="button"
onClick={() => toggleBuddy(buddy.id)}
className={`px-3 py-1.5 rounded-full text-[10px] font-black uppercase transition-all flex items-center gap-1.5 border shadow-sm ${selectedBuddyIds.includes(buddy.id)
? 'bg-amber-600 border-amber-600 text-white shadow-amber-600/20'
: 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:border-amber-500/50'
}`}
>
{selectedBuddyIds.includes(buddy.id) && <Check size={10} />}
{buddy.name}
</button>
))}
</div>
</div>
)}
{error && ( {error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs rounded-lg border border-red-100 dark:border-red-900/50"> <div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs rounded-lg border border-red-100 dark:border-red-900/50">
{error} {error}

View File

@@ -6,11 +6,13 @@ import { revalidatePath } from 'next/cache';
export async function saveTasting(data: { export async function saveTasting(data: {
bottle_id: string; bottle_id: string;
session_id?: string;
rating: number; rating: number;
nose_notes?: string; nose_notes?: string;
palate_notes?: string; palate_notes?: string;
finish_notes?: string; finish_notes?: string;
is_sample?: boolean; is_sample?: boolean;
buddy_ids?: string[];
}) { }) {
const supabase = createServerActionClient({ cookies }); const supabase = createServerActionClient({ cookies });
@@ -23,6 +25,7 @@ export async function saveTasting(data: {
.insert({ .insert({
bottle_id: data.bottle_id, bottle_id: data.bottle_id,
user_id: session.user.id, user_id: session.user.id,
session_id: data.session_id,
rating: data.rating, rating: data.rating,
nose_notes: data.nose_notes, nose_notes: data.nose_notes,
palate_notes: data.palate_notes, palate_notes: data.palate_notes,
@@ -34,6 +37,23 @@ export async function saveTasting(data: {
if (error) throw error; if (error) throw error;
// Add buddy tags if any
if (data.buddy_ids && data.buddy_ids.length > 0) {
const tags = data.buddy_ids.map(buddyId => ({
tasting_id: tasting.id,
buddy_id: buddyId
}));
const { error: tagError } = await supabase
.from('tasting_tags')
.insert(tags);
if (tagError) {
console.error('Error adding tasting tags:', tagError);
// We don't throw here to not fail the whole tasting save,
// but in a real app we might want more robust error handling
}
}
revalidatePath(`/bottles/${data.bottle_id}`); revalidatePath(`/bottles/${data.bottle_id}`);
return { success: true, data: tasting }; return { success: true, data: tasting };

View File

@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS profiles (
id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY, id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
username TEXT UNIQUE, username TEXT UNIQUE,
avatar_url TEXT, avatar_url TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
); );
-- Function to handle new user signup -- Function to handle new user signup
@@ -42,21 +42,54 @@ CREATE TABLE IF NOT EXISTS bottles (
image_url TEXT, image_url TEXT,
is_whisky BOOLEAN DEFAULT true, is_whisky BOOLEAN DEFAULT true,
confidence INTEGER DEFAULT 100, confidence INTEGER DEFAULT 100,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()), created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
); );
-- Tastings table -- Buddies table
CREATE TABLE IF NOT EXISTS buddies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL,
buddy_profile_id UUID REFERENCES profiles(id) ON DELETE SET NULL, -- Link to real account
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
-- Tasting Sessions table
CREATE TABLE IF NOT EXISTS tasting_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL,
scheduled_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
-- Session Participants junction
CREATE TABLE IF NOT EXISTS session_participants (
session_id UUID REFERENCES tasting_sessions(id) ON DELETE CASCADE NOT NULL,
buddy_id UUID REFERENCES buddies(id) ON DELETE CASCADE NOT NULL,
PRIMARY KEY (session_id, buddy_id)
);
-- Tastings table (updated with session and buddy tagging)
CREATE TABLE IF NOT EXISTS tastings ( CREATE TABLE IF NOT EXISTS tastings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bottle_id UUID REFERENCES bottles(id) ON DELETE CASCADE NOT NULL, bottle_id UUID REFERENCES bottles(id) ON DELETE CASCADE NOT NULL,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL, user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
session_id UUID REFERENCES tasting_sessions(id) ON DELETE SET NULL,
rating INTEGER CHECK (rating >= 0 AND rating <= 100), rating INTEGER CHECK (rating >= 0 AND rating <= 100),
nose_notes TEXT, nose_notes TEXT,
palate_notes TEXT, palate_notes TEXT,
finish_notes TEXT, finish_notes TEXT,
audio_transcript_url TEXT, audio_transcript_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
);
-- Tasting Tagging (to tag buddies in a tasting)
CREATE TABLE IF NOT EXISTS tasting_tags (
tasting_id UUID REFERENCES tastings(id) ON DELETE CASCADE NOT NULL,
buddy_id UUID REFERENCES buddies(id) ON DELETE CASCADE NOT NULL,
PRIMARY KEY (tasting_id, buddy_id)
); );
-- Enable Row Level Security (RLS) -- Enable Row Level Security (RLS)
@@ -88,6 +121,15 @@ CREATE POLICY "Users can delete their own bottles" ON bottles
CREATE POLICY "Users can view their own tastings" ON tastings CREATE POLICY "Users can view their own tastings" ON tastings
FOR SELECT USING (auth.uid() = user_id); FOR SELECT USING (auth.uid() = user_id);
-- Geteilte Tastings für Buddies sichtbar machen (wenn verknüpft)
CREATE POLICY "Users can view tastings they are tagged in" ON tastings
FOR SELECT USING (
id IN (
SELECT tasting_id FROM tasting_tags
WHERE buddy_id IN (SELECT id FROM buddies WHERE buddy_profile_id = auth.uid())
)
);
CREATE POLICY "Users can insert their own tastings" ON tastings CREATE POLICY "Users can insert their own tastings" ON tastings
FOR INSERT WITH CHECK (auth.uid() = user_id); FOR INSERT WITH CHECK (auth.uid() = user_id);
@@ -97,6 +139,47 @@ CREATE POLICY "Users can update their own tastings" ON tastings
CREATE POLICY "Users can delete their own tastings" ON tastings CREATE POLICY "Users can delete their own tastings" ON tastings
FOR DELETE USING (auth.uid() = user_id); FOR DELETE USING (auth.uid() = user_id);
-- Policies for Buddies
ALTER TABLE buddies ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can manage their own buddies" ON buddies
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users can see buddies linked to their profile" ON buddies
FOR SELECT USING (buddy_profile_id = auth.uid());
-- Policies for Tasting Sessions
ALTER TABLE tasting_sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can manage their own sessions" ON tasting_sessions
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users can see sessions they participate in" ON tasting_sessions
FOR SELECT USING (
id IN (
SELECT session_id FROM session_participants
WHERE buddy_id IN (SELECT id FROM buddies WHERE buddy_profile_id = auth.uid())
)
);
-- Policies for Session Participants
ALTER TABLE session_participants ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can manage participants of their sessions" ON session_participants
FOR ALL USING (
session_id IN (SELECT id FROM tasting_sessions WHERE user_id = auth.uid())
);
CREATE POLICY "Participants can see session membership" ON session_participants
FOR SELECT USING (
buddy_id IN (SELECT id FROM buddies WHERE buddy_profile_id = auth.uid())
);
-- Policies for Tasting Tags
ALTER TABLE tasting_tags ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can manage tags on their tastings" ON tasting_tags
FOR ALL USING (
tasting_id IN (SELECT id FROM tastings WHERE user_id = auth.uid())
);
CREATE POLICY "Tagged users can see the tags" ON tasting_tags
FOR SELECT USING (
buddy_id IN (SELECT id FROM buddies WHERE buddy_profile_id = auth.uid())
);
-- STORAGE SETUP -- STORAGE SETUP
-- Create 'bottles' bucket if it doesn't exist -- Create 'bottles' bucket if it doesn't exist
INSERT INTO storage.buckets (id, name, public) INSERT INTO storage.buckets (id, name, public)