feat: Improve Bottle Split UX

- Add 'Split starten' button to bottle detail page
- Support flexible sample sizes (1-20cl) instead of fixed 5/10cl
- Preselect bottle when coming from bottle page
- Save sample sizes/shipping defaults to localStorage
- Update schema: sample_sizes JSONB array
- Update server actions and UI for dynamic sizes
This commit is contained in:
2025-12-25 22:46:58 +01:00
parent 0c7786db90
commit 2286867447
6 changed files with 187 additions and 120 deletions

View File

@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Link from 'next/link'; 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 } from 'lucide-react';
import { getSplitBySlug, requestSlot, SplitDetails, ShippingOption } from '@/services/split-actions'; import { getSplitBySlug, requestSlot, SplitDetails, SampleSize, ShippingOption } from '@/services/split-actions';
import SplitProgressBar from '@/components/SplitProgressBar'; import SplitProgressBar from '@/components/SplitProgressBar';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
@@ -15,8 +15,7 @@ export default function SplitPublicPage() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Request form const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
const [selectedAmount, setSelectedAmount] = useState<5 | 10>(5);
const [selectedShipping, setSelectedShipping] = useState<string>(''); const [selectedShipping, setSelectedShipping] = useState<string>('');
const [isRequesting, setIsRequesting] = useState(false); const [isRequesting, setIsRequesting] = useState(false);
const [requestSuccess, setRequestSuccess] = useState(false); const [requestSuccess, setRequestSuccess] = useState(false);
@@ -41,6 +40,9 @@ export default function SplitPublicPage() {
if (result.data.shippingOptions.length > 0) { if (result.data.shippingOptions.length > 0) {
setSelectedShipping(result.data.shippingOptions[0].name); setSelectedShipping(result.data.shippingOptions[0].name);
} }
if (result.data.sampleSizes.length > 0) {
setSelectedAmount(result.data.sampleSizes[0].cl);
}
} else { } else {
setError(result.error || 'Split nicht gefunden'); setError(result.error || 'Split nicht gefunden');
} }
@@ -52,7 +54,8 @@ export default function SplitPublicPage() {
const pricePerCl = split.priceBottle / split.totalVolume; const pricePerCl = split.priceBottle / split.totalVolume;
const whisky = pricePerCl * amountCl; const whisky = pricePerCl * amountCl;
const glass = amountCl === 5 ? split.costGlass5cl : split.costGlass10cl; const sizeOption = split.sampleSizes.find(s => s.cl === amountCl);
const glass = sizeOption?.glassCost || 0;
const shippingOption = split.shippingOptions.find(s => s.name === selectedShipping); const shippingOption = split.shippingOptions.find(s => s.name === selectedShipping);
const shipping = shippingOption?.price || 0; const shipping = shippingOption?.price || 0;
@@ -65,7 +68,7 @@ export default function SplitPublicPage() {
}; };
const handleRequest = async () => { const handleRequest = async () => {
if (!selectedShipping) return; if (!selectedShipping || !selectedAmount) return;
setIsRequesting(true); setIsRequesting(true);
setRequestError(null); setRequestError(null);
@@ -74,7 +77,7 @@ export default function SplitPublicPage() {
if (result.success) { if (result.success) {
setRequestSuccess(true); setRequestSuccess(true);
fetchSplit(); // Refresh data fetchSplit();
} else { } else {
setRequestError(result.error || 'Anfrage fehlgeschlagen'); setRequestError(result.error || 'Anfrage fehlgeschlagen');
} }
@@ -84,7 +87,7 @@ export default function SplitPublicPage() {
const userParticipation = split?.participants.find(p => p.userId === currentUserId); const userParticipation = split?.participants.find(p => p.userId === currentUserId);
const canRequest = !userParticipation && currentUserId && currentUserId !== split?.hostId; const canRequest = !userParticipation && currentUserId && currentUserId !== split?.hostId;
const isWaitlist = split && split.remaining < selectedAmount; const isWaitlist = split && selectedAmount && split.remaining < selectedAmount;
if (isLoading) { if (isLoading) {
return ( return (
@@ -104,12 +107,11 @@ export default function SplitPublicPage() {
); );
} }
const price = calculatePrice(selectedAmount); const price = selectedAmount ? calculatePrice(selectedAmount) : { whisky: 0, glass: 0, shipping: 0, total: 0 };
return ( return (
<main className="min-h-screen bg-zinc-950 p-4 md:p-8 lg:p-12"> <main className="min-h-screen bg-zinc-950 p-4 md:p-8 lg:p-12">
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
{/* Back */}
<Link <Link
href="/" href="/"
className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]" className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
@@ -120,7 +122,6 @@ export default function SplitPublicPage() {
{/* Hero */} {/* Hero */}
<div className="bg-zinc-900 rounded-3xl overflow-hidden border border-zinc-800 shadow-xl"> <div className="bg-zinc-900 rounded-3xl overflow-hidden border border-zinc-800 shadow-xl">
{/* Bottle Image */}
{split.bottle.imageUrl && ( {split.bottle.imageUrl && (
<div className="h-48 md:h-64 bg-zinc-800 relative"> <div className="h-48 md:h-64 bg-zinc-800 relative">
<img <img
@@ -145,7 +146,6 @@ export default function SplitPublicPage() {
)} )}
</div> </div>
{/* Stats Row */}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{split.bottle.abv && ( {split.bottle.abv && (
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300"> <span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
@@ -162,7 +162,6 @@ export default function SplitPublicPage() {
</span> </span>
</div> </div>
{/* Progress Bar */}
<div className="pt-4"> <div className="pt-4">
<SplitProgressBar <SplitProgressBar
totalVolume={split.totalVolume} totalVolume={split.totalVolume}
@@ -185,20 +184,17 @@ export default function SplitPublicPage() {
{/* Amount Selection */} {/* Amount Selection */}
<div className="space-y-3"> <div className="space-y-3">
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest">Menge</label> <label className="text-xs font-bold text-zinc-500 uppercase tracking-widest">Menge</label>
<div className="grid grid-cols-2 gap-3"> <div className="flex flex-wrap gap-2">
{[5, 10].map(amount => ( {split.sampleSizes.map(size => (
<button <button
key={amount} key={size.cl}
onClick={() => setSelectedAmount(amount as 5 | 10)} onClick={() => setSelectedAmount(size.cl)}
className={`p-4 rounded-2xl border-2 transition-all ${selectedAmount === amount className={`px-4 py-3 rounded-xl border-2 transition-all ${selectedAmount === size.cl
? 'border-orange-500 bg-orange-500/10' ? 'border-orange-500 bg-orange-500/10'
: 'border-zinc-700 hover:border-zinc-600' : 'border-zinc-700 hover:border-zinc-600'
}`} }`}
> >
<span className="text-2xl font-black text-white">{amount}cl</span> <span className="text-lg font-black text-white">{size.cl}cl</span>
<p className="text-xs text-zinc-500 mt-1">
{amount === 5 ? 'Kleiner Taster' : 'Ordentliche Portion'}
</p>
</button> </button>
))} ))}
</div> </div>
@@ -210,12 +206,12 @@ export default function SplitPublicPage() {
<Truck size={14} /> <Truck size={14} />
Versand Versand
</label> </label>
<div className="grid grid-cols-2 gap-3"> <div className="flex flex-wrap gap-2">
{split.shippingOptions.map(option => ( {split.shippingOptions.map(option => (
<button <button
key={option.name} key={option.name}
onClick={() => setSelectedShipping(option.name)} onClick={() => setSelectedShipping(option.name)}
className={`p-4 rounded-2xl border-2 transition-all text-left ${selectedShipping === option.name className={`px-4 py-3 rounded-xl border-2 transition-all text-left ${selectedShipping === option.name
? 'border-orange-500 bg-orange-500/10' ? 'border-orange-500 bg-orange-500/10'
: 'border-zinc-700 hover:border-zinc-600' : 'border-zinc-700 hover:border-zinc-600'
}`} }`}
@@ -228,6 +224,7 @@ export default function SplitPublicPage() {
</div> </div>
{/* Price Breakdown */} {/* Price Breakdown */}
{selectedAmount && (
<div className="bg-zinc-950 rounded-2xl p-4 space-y-2"> <div className="bg-zinc-950 rounded-2xl p-4 space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-zinc-500">Whisky ({selectedAmount}cl)</span> <span className="text-zinc-500">Whisky ({selectedAmount}cl)</span>
@@ -246,18 +243,17 @@ export default function SplitPublicPage() {
<span className="font-black text-xl text-orange-500">{price.total.toFixed(2)}</span> <span className="font-black text-xl text-orange-500">{price.total.toFixed(2)}</span>
</div> </div>
</div> </div>
)}
{/* Error */}
{requestError && ( {requestError && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400"> <div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{requestError} {requestError}
</div> </div>
)} )}
{/* Submit Button */}
<button <button
onClick={handleRequest} onClick={handleRequest}
disabled={isRequesting || !selectedShipping} disabled={isRequesting || !selectedShipping || !selectedAmount}
className={`w-full py-4 rounded-2xl font-bold text-white transition-all flex items-center justify-center gap-2 ${isWaitlist className={`w-full py-4 rounded-2xl font-bold text-white transition-all flex items-center justify-center gap-2 ${isWaitlist
? 'bg-yellow-600 hover:bg-yellow-700' ? 'bg-yellow-600 hover:bg-yellow-700'
: 'bg-orange-600 hover:bg-orange-700' : 'bg-orange-600 hover:bg-orange-700'
@@ -280,7 +276,6 @@ export default function SplitPublicPage() {
</div> </div>
)} )}
{/* Success Message */}
{requestSuccess && ( {requestSuccess && (
<div className="bg-green-500/10 border border-green-500/30 rounded-3xl p-6 text-center"> <div className="bg-green-500/10 border border-green-500/30 rounded-3xl p-6 text-center">
<CheckCircle2 size={48} className="mx-auto text-green-500 mb-4" /> <CheckCircle2 size={48} className="mx-auto text-green-500 mb-4" />
@@ -291,11 +286,10 @@ export default function SplitPublicPage() {
</div> </div>
)} )}
{/* Already Participating */}
{userParticipation && ( {userParticipation && (
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800"> <div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${userParticipation.status === 'APPROVED' || userParticipation.status === 'PAID' || userParticipation.status === 'SHIPPED' <div className={`w-12 h-12 rounded-xl flex items-center justify-center ${['APPROVED', 'PAID', 'SHIPPED'].includes(userParticipation.status)
? 'bg-green-500/20 text-green-500' ? 'bg-green-500/20 text-green-500'
: userParticipation.status === 'PENDING' : userParticipation.status === 'PENDING'
? 'bg-yellow-500/20 text-yellow-500' ? 'bg-yellow-500/20 text-yellow-500'
@@ -316,7 +310,6 @@ export default function SplitPublicPage() {
</div> </div>
)} )}
{/* Not logged in */}
{!currentUserId && ( {!currentUserId && (
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 text-center"> <div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 text-center">
<User size={32} className="mx-auto text-zinc-500 mb-3" /> <User size={32} className="mx-auto text-zinc-500 mb-3" />
@@ -330,7 +323,6 @@ export default function SplitPublicPage() {
</div> </div>
)} )}
{/* Host View */}
{currentUserId === split.hostId && ( {currentUserId === split.hostId && (
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800"> <div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
<p className="text-sm text-zinc-500 mb-4">Du bist der Host dieses Splits</p> <p className="text-sm text-zinc-500 mb-4">Du bist der Host dieses Splits</p>
@@ -343,7 +335,6 @@ export default function SplitPublicPage() {
</div> </div>
)} )}
{/* Share Button */}
<button <button
onClick={() => navigator.share?.({ url: window.location.href })} onClick={() => navigator.share?.({ url: window.location.href })}
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" 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"

View File

@@ -1,10 +1,10 @@
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { ChevronLeft, ChevronRight, Package, Truck, Plus, X, Loader2, Check, Wine } from 'lucide-react'; import { ChevronLeft, ChevronRight, Package, Truck, Plus, X, Loader2, Check, Wine } from 'lucide-react';
import { createSplit, ShippingOption } from '@/services/split-actions'; import { createSplit, ShippingOption, SampleSize } from '@/services/split-actions';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
interface Bottle { interface Bottle {
@@ -14,10 +14,19 @@ interface Bottle {
image_url?: string; image_url?: string;
} }
const DEFAULT_SIZES: SampleSize[] = [
{ cl: 2, glassCost: 0.50 },
{ cl: 5, glassCost: 0.80 },
{ cl: 10, glassCost: 1.50 },
];
export default function CreateSplitPage() { export default function CreateSplitPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const supabase = createClient(); const supabase = createClient();
const [step, setStep] = useState(1); const preselectedBottleId = searchParams?.get('bottle');
const [step, setStep] = useState(preselectedBottleId ? 2 : 1);
const [bottles, setBottles] = useState<Bottle[]>([]); const [bottles, setBottles] = useState<Bottle[]>([]);
const [isLoadingBottles, setIsLoadingBottles] = useState(true); const [isLoadingBottles, setIsLoadingBottles] = useState(true);
@@ -26,8 +35,9 @@ export default function CreateSplitPage() {
const [totalVolume, setTotalVolume] = useState(70); const [totalVolume, setTotalVolume] = useState(70);
const [hostShare, setHostShare] = useState(10); const [hostShare, setHostShare] = useState(10);
const [priceBottle, setPriceBottle] = useState(''); const [priceBottle, setPriceBottle] = useState('');
const [costGlass5cl, setCostGlass5cl] = useState('0.80'); const [sampleSizes, setSampleSizes] = useState<SampleSize[]>(DEFAULT_SIZES);
const [costGlass10cl, setCostGlass10cl] = useState('1.50'); const [newSizeCl, setNewSizeCl] = useState('');
const [newSizeCost, setNewSizeCost] = useState('');
const [shippingOptions, setShippingOptions] = useState<ShippingOption[]>([ const [shippingOptions, setShippingOptions] = useState<ShippingOption[]>([
{ name: 'DHL', price: 5.50 }, { name: 'DHL', price: 5.50 },
{ name: 'Hermes', price: 4.90 }, { name: 'Hermes', price: 4.90 },
@@ -43,12 +53,22 @@ export default function CreateSplitPage() {
loadSavedDefaults(); loadSavedDefaults();
}, []); }, []);
useEffect(() => {
if (preselectedBottleId && bottles.length > 0) {
const bottle = bottles.find(b => b.id === preselectedBottleId);
if (bottle) {
setSelectedBottle(bottle);
}
}
}, [preselectedBottleId, bottles]);
const loadSavedDefaults = () => { const loadSavedDefaults = () => {
const saved = localStorage.getItem('split-defaults'); const saved = localStorage.getItem('split-defaults');
if (saved) { if (saved) {
const defaults = JSON.parse(saved); const defaults = JSON.parse(saved);
setCostGlass5cl(defaults.costGlass5cl || '0.80'); if (defaults.sampleSizes) {
setCostGlass10cl(defaults.costGlass10cl || '1.50'); setSampleSizes(defaults.sampleSizes);
}
if (defaults.shippingOptions) { if (defaults.shippingOptions) {
setShippingOptions(defaults.shippingOptions); setShippingOptions(defaults.shippingOptions);
} }
@@ -57,8 +77,7 @@ export default function CreateSplitPage() {
const saveDefaults = () => { const saveDefaults = () => {
localStorage.setItem('split-defaults', JSON.stringify({ localStorage.setItem('split-defaults', JSON.stringify({
costGlass5cl, sampleSizes,
costGlass10cl,
shippingOptions, shippingOptions,
})); }));
}; };
@@ -81,6 +100,19 @@ export default function CreateSplitPage() {
setIsLoadingBottles(false); setIsLoadingBottles(false);
}; };
const addSampleSize = () => {
if (!newSizeCl || !newSizeCost) return;
const cl = parseInt(newSizeCl);
if (sampleSizes.some(s => s.cl === cl)) return;
setSampleSizes([...sampleSizes, { cl, glassCost: parseFloat(newSizeCost) }].sort((a, b) => a.cl - b.cl));
setNewSizeCl('');
setNewSizeCost('');
};
const removeSampleSize = (cl: number) => {
setSampleSizes(sampleSizes.filter(s => s.cl !== cl));
};
const addShippingOption = () => { const addShippingOption = () => {
if (!newShippingName || !newShippingPrice) return; if (!newShippingName || !newShippingPrice) return;
setShippingOptions([...shippingOptions, { setShippingOptions([...shippingOptions, {
@@ -96,7 +128,7 @@ export default function CreateSplitPage() {
}; };
const handleCreate = async () => { const handleCreate = async () => {
if (!selectedBottle || !priceBottle) return; if (!selectedBottle || !priceBottle || sampleSizes.length === 0) return;
setIsCreating(true); setIsCreating(true);
setError(null); setError(null);
@@ -107,8 +139,7 @@ export default function CreateSplitPage() {
totalVolume, totalVolume,
hostShare, hostShare,
priceBottle: parseFloat(priceBottle), priceBottle: parseFloat(priceBottle),
costGlass5cl: parseFloat(costGlass5cl), sampleSizes,
costGlass10cl: parseFloat(costGlass10cl),
shippingOptions, shippingOptions,
}); });
@@ -121,8 +152,6 @@ export default function CreateSplitPage() {
}; };
const pricePerCl = priceBottle ? parseFloat(priceBottle) / totalVolume : 0; const pricePerCl = priceBottle ? parseFloat(priceBottle) / totalVolume : 0;
const price5cl = pricePerCl * 5 + parseFloat(costGlass5cl || '0');
const price10cl = pricePerCl * 10 + parseFloat(costGlass10cl || '0');
return ( return (
<main className="min-h-screen bg-zinc-950 p-4 md:p-8 lg:p-12"> <main className="min-h-screen bg-zinc-950 p-4 md:p-8 lg:p-12">
@@ -130,7 +159,7 @@ export default function CreateSplitPage() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link <Link
href="/" href={preselectedBottleId ? `/bottles/${preselectedBottleId}` : '/'}
className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]" className="inline-flex items-center gap-2 text-zinc-500 hover:text-orange-600 transition-colors font-bold uppercase text-[10px] tracking-[0.2em]"
> >
<ChevronLeft size={16} /> <ChevronLeft size={16} />
@@ -214,11 +243,11 @@ export default function CreateSplitPage() {
</div> </div>
)} )}
{/* Step 2: Pricing */} {/* Step 2: Pricing & Sizes */}
{step === 2 && ( {step === 2 && (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-black text-white">Preise festlegen</h1> <h1 className="text-2xl font-black text-white">Preise & Größen</h1>
<p className="text-zinc-500 text-sm">{selectedBottle?.name}</p> <p className="text-zinc-500 text-sm">{selectedBottle?.name}</p>
</div> </div>
@@ -256,40 +285,65 @@ export default function CreateSplitPage() {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> {/* Sample Sizes */}
<div> <div className="space-y-3">
<label className="text-xs font-bold text-zinc-500 block mb-2">Flasche 5cl ()</label> <label className="text-xs font-bold text-zinc-500 uppercase tracking-widest">Verfügbare Größen</label>
<input <div className="flex flex-wrap gap-2">
type="number" {sampleSizes.map(size => (
step="0.01" <div
value={costGlass5cl} key={size.cl}
onChange={e => setCostGlass5cl(e.target.value)} className="flex items-center gap-2 px-3 py-2 bg-zinc-800 rounded-xl"
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white" >
/> <span className="font-bold text-white">{size.cl}cl</span>
<span className="text-zinc-500 text-xs">+{size.glassCost.toFixed(2)}</span>
<button
onClick={() => removeSampleSize(size.cl)}
className="text-red-500 hover:text-red-400 ml-1"
>
<X size={14} />
</button>
</div> </div>
<div> ))}
<label className="text-xs font-bold text-zinc-500 block mb-2">Flasche 10cl ()</label> </div>
<div className="flex gap-2">
<input
type="number"
value={newSizeCl}
onChange={e => setNewSizeCl(e.target.value)}
placeholder="cl"
className="w-20 bg-zinc-800 border border-zinc-700 rounded-xl px-3 py-2 text-white text-sm"
/>
<input <input
type="number" type="number"
step="0.01" step="0.01"
value={costGlass10cl} value={newSizeCost}
onChange={e => setCostGlass10cl(e.target.value)} onChange={e => setNewSizeCost(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white" placeholder="Glaskosten €"
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-3 py-2 text-white text-sm"
/> />
<button
onClick={addSampleSize}
disabled={!newSizeCl || !newSizeCost}
className="p-2 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-xl transition-colors"
>
<Plus size={20} className="text-white" />
</button>
</div> </div>
</div> </div>
{/* Preview */} {/* Preview */}
{priceBottle && ( {priceBottle && sampleSizes.length > 0 && (
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800"> <div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<p className="text-xs font-bold text-zinc-500 mb-2">Vorschau (inkl. Flasche)</p> <p className="text-xs font-bold text-zinc-500 mb-3">Vorschau (inkl. Flasche)</p>
<div className="flex justify-between"> <div className="grid grid-cols-2 gap-2">
<span className="text-white">5cl Preis:</span> {sampleSizes.map(size => (
<span className="font-bold text-orange-500">{price5cl.toFixed(2)}</span> <div key={size.cl} className="flex justify-between text-sm">
<span className="text-zinc-400">{size.cl}cl:</span>
<span className="font-bold text-orange-500">
{(pricePerCl * size.cl + size.glassCost).toFixed(2)}
</span>
</div> </div>
<div className="flex justify-between"> ))}
<span className="text-white">10cl Preis:</span>
<span className="font-bold text-orange-500">{price10cl.toFixed(2)}</span>
</div> </div>
</div> </div>
)} )}
@@ -304,7 +358,7 @@ export default function CreateSplitPage() {
</button> </button>
<button <button
onClick={() => setStep(3)} onClick={() => setStep(3)}
disabled={!priceBottle} disabled={!priceBottle || sampleSizes.length === 0}
className="flex-1 py-4 bg-orange-600 hover:bg-orange-700 disabled:bg-zinc-800 disabled:text-zinc-600 text-white rounded-2xl font-bold flex items-center justify-center gap-2 transition-colors" className="flex-1 py-4 bg-orange-600 hover:bg-orange-700 disabled:bg-zinc-800 disabled:text-zinc-600 text-white rounded-2xl font-bold flex items-center justify-center gap-2 transition-colors"
> >
Weiter Weiter
@@ -344,7 +398,6 @@ export default function CreateSplitPage() {
</div> </div>
))} ))}
{/* Add new */}
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"

View File

@@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus } from 'lucide-react'; import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff, CircleDollarSign, Wine, CheckCircle2, Circle, ChevronDown, Plus, Share2 } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { updateBottle } from '@/services/update-bottle'; import { updateBottle } from '@/services/update-bottle';
import { getStorageUrl } from '@/lib/supabase'; import { getStorageUrl } from '@/lib/supabase';
@@ -248,6 +248,13 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
) : ( ) : (
<> <>
<EditBottleForm bottle={bottle as any} /> <EditBottleForm bottle={bottle as any} />
<Link
href={`/splits/create?bottle=${bottle.id}`}
className="px-5 py-3 bg-zinc-800 hover:bg-zinc-700 text-white rounded-2xl text-xs font-black uppercase tracking-widest flex items-center gap-2 transition-all border border-zinc-700 hover:border-orange-600/50"
>
<Share2 size={16} className="text-orange-500" />
Split starten
</Link>
<DeleteBottleButton bottleId={bottle.id} /> <DeleteBottleButton bottleId={bottle.id} />
</> </>
)} )}

View File

@@ -11,13 +11,17 @@ export interface ShippingOption {
price: number; price: number;
} }
export interface SampleSize {
cl: number;
glassCost: number;
}
export interface CreateSplitData { export interface CreateSplitData {
bottleId: string; bottleId: string;
totalVolume?: number; totalVolume?: number;
hostShare?: number; hostShare?: number;
priceBottle: number; priceBottle: number;
costGlass5cl?: number; sampleSizes: SampleSize[];
costGlass10cl?: number;
shippingOptions: ShippingOption[]; shippingOptions: ShippingOption[];
} }
@@ -29,8 +33,7 @@ export interface SplitDetails {
totalVolume: number; totalVolume: number;
hostShare: number; hostShare: number;
priceBottle: number; priceBottle: number;
costGlass5cl: number; sampleSizes: SampleSize[];
costGlass10cl: number;
shippingOptions: ShippingOption[]; shippingOptions: ShippingOption[];
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
@@ -42,7 +45,6 @@ export interface SplitDetails {
abv?: number; abv?: number;
age?: number; age?: number;
}; };
// Calculated availability
available: number; available: number;
taken: number; taken: number;
reserved: number; reserved: number;
@@ -101,6 +103,12 @@ export async function createSplit(data: CreateSplitData): Promise<{
const slug = generateSlug(); const slug = generateSlug();
// Convert sample sizes to DB format
const sampleSizesDb = data.sampleSizes.map(s => ({
cl: s.cl,
glass_cost: s.glassCost,
}));
const { data: split, error } = await supabase const { data: split, error } = await supabase
.from('bottle_splits') .from('bottle_splits')
.insert({ .insert({
@@ -109,8 +117,7 @@ export async function createSplit(data: CreateSplitData): Promise<{
total_volume: data.totalVolume || 70, total_volume: data.totalVolume || 70,
host_share: data.hostShare || 10, host_share: data.hostShare || 10,
price_bottle: data.priceBottle, price_bottle: data.priceBottle,
cost_glass_5cl: data.costGlass5cl || 0.80, sample_sizes: sampleSizesDb,
cost_glass_10cl: data.costGlass10cl || 1.50,
shipping_options: data.shippingOptions, shipping_options: data.shippingOptions,
public_slug: slug, public_slug: slug,
}) })
@@ -133,7 +140,7 @@ export async function createSplit(data: CreateSplitData): Promise<{
} }
/** /**
* Get split details by public slug (for public page) * Get split details by public slug
*/ */
export async function getSplitBySlug(slug: string): Promise<{ export async function getSplitBySlug(slug: string): Promise<{
success: boolean; success: boolean;
@@ -153,8 +160,7 @@ export async function getSplitBySlug(slug: string): Promise<{
total_volume, total_volume,
host_share, host_share,
price_bottle, price_bottle,
cost_glass_5cl, sample_sizes,
cost_glass_10cl,
shipping_options, shipping_options,
is_active, is_active,
created_at, created_at,
@@ -186,7 +192,6 @@ export async function getSplitBySlug(slug: string): Promise<{
// Calculate availability // Calculate availability
const available = split.total_volume - split.host_share; const available = split.total_volume - split.host_share;
let taken = 0; let taken = 0;
let reserved = 0; let reserved = 0;
@@ -199,9 +204,14 @@ export async function getSplitBySlug(slug: string): Promise<{
}); });
const remaining = available - taken - reserved; const remaining = available - taken - reserved;
const bottle = split.bottles as any; const bottle = split.bottles as any;
// Convert sample sizes from DB format
const sampleSizes = ((split.sample_sizes as any[]) || []).map(s => ({
cl: s.cl,
glassCost: s.glass_cost,
}));
return { return {
success: true, success: true,
data: { data: {
@@ -212,8 +222,7 @@ export async function getSplitBySlug(slug: string): Promise<{
totalVolume: split.total_volume, totalVolume: split.total_volume,
hostShare: split.host_share, hostShare: split.host_share,
priceBottle: split.price_bottle, priceBottle: split.price_bottle,
costGlass5cl: split.cost_glass_5cl, sampleSizes,
costGlass10cl: split.cost_glass_10cl,
shippingOptions: split.shipping_options as ShippingOption[], shippingOptions: split.shipping_options as ShippingOption[],
isActive: split.is_active, isActive: split.is_active,
createdAt: split.created_at, createdAt: split.created_at,
@@ -252,7 +261,7 @@ export async function getSplitBySlug(slug: string): Promise<{
*/ */
export async function requestSlot( export async function requestSlot(
splitId: string, splitId: string,
amountCl: 5 | 10, amountCl: number,
shippingMethod: string shippingMethod: string
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient(); const supabase = await createClient();
@@ -275,7 +284,6 @@ export async function requestSlot(
return { success: false, error: 'Split nicht gefunden' }; return { success: false, error: 'Split nicht gefunden' };
} }
// Can't join your own split
if (split.host_id === user.id) { if (split.host_id === user.id) {
return { success: false, error: 'Du kannst nicht an deinem eigenen Split teilnehmen' }; return { success: false, error: 'Du kannst nicht an deinem eigenen Split teilnehmen' };
} }
@@ -292,6 +300,13 @@ export async function requestSlot(
return { success: false, error: 'Du nimmst bereits an diesem Split teil' }; return { success: false, error: 'Du nimmst bereits an diesem Split teil' };
} }
// Find the glass cost for this size
const sampleSizes = (split.sample_sizes as any[]) || [];
const sizeOption = sampleSizes.find(s => s.cl === amountCl);
if (!sizeOption) {
return { success: false, error: 'Ungültige Größe' };
}
// Calculate availability // Calculate availability
const { data: participants } = await supabase const { data: participants } = await supabase
.from('split_participants') .from('split_participants')
@@ -311,16 +326,14 @@ export async function requestSlot(
// Calculate total cost // Calculate total cost
const pricePerCl = split.price_bottle / split.total_volume; const pricePerCl = split.price_bottle / split.total_volume;
const glassCost = amountCl === 5 ? split.cost_glass_5cl : split.cost_glass_10cl; const glassCost = sizeOption.glass_cost;
const shippingOption = (split.shipping_options as ShippingOption[]) const shippingOption = (split.shipping_options as ShippingOption[])
.find(s => s.name === shippingMethod); .find(s => s.name === shippingMethod);
const shippingCost = shippingOption?.price || 0; const shippingCost = shippingOption?.price || 0;
const totalCost = (pricePerCl * amountCl) + glassCost + shippingCost; const totalCost = (pricePerCl * amountCl) + glassCost + shippingCost;
// Determine status
const status = remaining >= amountCl ? 'PENDING' : 'WAITLIST'; const status = remaining >= amountCl ? 'PENDING' : 'WAITLIST';
// Insert or update (if was rejected)
if (existing) { if (existing) {
const { error } = await supabase const { error } = await supabase
.from('split_participants') .from('split_participants')
@@ -378,7 +391,6 @@ export async function updateParticipantStatus(
return { success: false, error: 'Nicht autorisiert' }; return { success: false, error: 'Nicht autorisiert' };
} }
// Verify host owns the split
const { data: participant, error: findError } = await supabase const { data: participant, error: findError } = await supabase
.from('split_participants') .from('split_participants')
.select(` .select(`
@@ -525,14 +537,19 @@ export async function generateForumExport(splitId: string): Promise<{
const bottle = split.bottles as any; const bottle = split.bottles as any;
const participants = split.split_participants as any[] || []; const participants = split.split_participants as any[] || [];
const shippingOptions = split.shipping_options as ShippingOption[]; const shippingOptions = split.shipping_options as ShippingOption[];
const sampleSizes = (split.sample_sizes as any[]) || [];
const pricePerCl = split.price_bottle / split.total_volume; const pricePerCl = split.price_bottle / split.total_volume;
const price5cl = (pricePerCl * 5 + split.cost_glass_5cl).toFixed(2);
const price10cl = (pricePerCl * 10 + split.cost_glass_10cl).toFixed(2); // Build price list for all sizes
const priceList = sampleSizes.map(s => {
const price = (pricePerCl * s.cl + s.glass_cost).toFixed(2);
return `${s.cl}cl: ${price}`;
}).join(' | ');
let text = `[b]${bottle.name}${bottle.distillery ? ` (${bottle.distillery})` : ''} Split[/b]\n\n`; let text = `[b]${bottle.name}${bottle.distillery ? ` (${bottle.distillery})` : ''} Split[/b]\n\n`;
text += `Flaschenpreis: ${split.price_bottle.toFixed(2)}\n`; text += `Flaschenpreis: ${split.price_bottle.toFixed(2)}\n`;
text += `5cl: ${price5cl}€ | 10cl: ${price10cl}\n`; text += `${priceList}\n`;
text += `Versand: ${shippingOptions.map(s => `${s.name} (${s.price.toFixed(2)}€)`).join(', ')}\n\n`; text += `Versand: ${shippingOptions.map(s => `${s.name} (${s.price.toFixed(2)}€)`).join(', ')}\n\n`;
text += `[b]Teilnehmer:[/b]\n`; text += `[b]Teilnehmer:[/b]\n`;
text += `1. Host (${split.host_share}cl)\n`; text += `1. Host (${split.host_share}cl)\n`;

View File

@@ -456,8 +456,7 @@ CREATE TABLE IF NOT EXISTS bottle_splits (
total_volume INTEGER DEFAULT 70, -- in cl total_volume INTEGER DEFAULT 70, -- in cl
host_share INTEGER DEFAULT 10, -- what the host keeps, in cl host_share INTEGER DEFAULT 10, -- what the host keeps, in cl
price_bottle DECIMAL(10, 2) NOT NULL, price_bottle DECIMAL(10, 2) NOT NULL,
cost_glass_5cl DECIMAL(10, 2) DEFAULT 0.80, sample_sizes JSONB DEFAULT '[{"cl": 5, "glass_cost": 0.80}, {"cl": 10, "glass_cost": 1.50}]'::jsonb,
cost_glass_10cl DECIMAL(10, 2) DEFAULT 1.50,
shipping_options JSONB DEFAULT '[]'::jsonb, shipping_options JSONB DEFAULT '[]'::jsonb,
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,
public_slug TEXT UNIQUE NOT NULL, public_slug TEXT UNIQUE NOT NULL,
@@ -486,7 +485,7 @@ CREATE TABLE IF NOT EXISTS split_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
split_id UUID REFERENCES bottle_splits(id) ON DELETE CASCADE NOT NULL, split_id UUID REFERENCES bottle_splits(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,
amount_cl INTEGER NOT NULL CHECK (amount_cl IN (5, 10)), amount_cl INTEGER NOT NULL CHECK (amount_cl > 0),
shipping_method TEXT NOT NULL, shipping_method TEXT NOT NULL,
total_cost DECIMAL(10, 2) NOT NULL, total_cost DECIMAL(10, 2) NOT NULL,
status TEXT DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'APPROVED', 'PAID', 'SHIPPED', 'REJECTED', 'WAITLIST')), status TEXT DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'APPROVED', 'PAID', 'SHIPPED', 'REJECTED', 'WAITLIST')),

File diff suppressed because one or more lines are too long