feat: Bottle Split System (Flaschenteilung)

- Add bottle_splits and split_participants tables with RLS
- Implement soft-lock: pending requests count as reserved
- Create /splits/create wizard (3 steps: bottle, pricing, shipping)
- Create /splits/[slug] public page with price calculator
- Create /splits/manage host dashboard with participant workflow
- Add SplitProgressBar component for visual volume display
- Status workflow: PENDING -> APPROVED -> PAID -> SHIPPED
- Forum export (BBCode format)
- Saved defaults in localStorage for glass/shipping costs
This commit is contained in:
2025-12-25 22:36:38 +01:00
parent 75461d7c30
commit 0c7786db90
7 changed files with 1871 additions and 1 deletions

View File

@@ -0,0 +1,357 @@
'use client';
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 { getSplitBySlug, requestSlot, SplitDetails, ShippingOption } from '@/services/split-actions';
import SplitProgressBar from '@/components/SplitProgressBar';
import { createClient } from '@/lib/supabase/client';
export default function SplitPublicPage() {
const { slug } = useParams();
const supabase = createClient();
const [split, setSplit] = useState<SplitDetails | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Request form
const [selectedAmount, setSelectedAmount] = useState<5 | 10>(5);
const [selectedShipping, setSelectedShipping] = useState<string>('');
const [isRequesting, setIsRequesting] = useState(false);
const [requestSuccess, setRequestSuccess] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
useEffect(() => {
fetchSplit();
checkUser();
}, [slug]);
const checkUser = async () => {
const { data: { user } } = await supabase.auth.getUser();
setCurrentUserId(user?.id || null);
};
const fetchSplit = async () => {
setIsLoading(true);
const result = await getSplitBySlug(slug as string);
if (result.success && result.data) {
setSplit(result.data);
if (result.data.shippingOptions.length > 0) {
setSelectedShipping(result.data.shippingOptions[0].name);
}
} else {
setError(result.error || 'Split nicht gefunden');
}
setIsLoading(false);
};
const calculatePrice = (amountCl: number): { whisky: number; glass: number; shipping: number; total: number } => {
if (!split) return { whisky: 0, glass: 0, shipping: 0, total: 0 };
const pricePerCl = split.priceBottle / split.totalVolume;
const whisky = pricePerCl * amountCl;
const glass = amountCl === 5 ? split.costGlass5cl : split.costGlass10cl;
const shippingOption = split.shippingOptions.find(s => s.name === selectedShipping);
const shipping = shippingOption?.price || 0;
return {
whisky: Math.round(whisky * 100) / 100,
glass,
shipping,
total: Math.round((whisky + glass + shipping) * 100) / 100,
};
};
const handleRequest = async () => {
if (!selectedShipping) return;
setIsRequesting(true);
setRequestError(null);
const result = await requestSlot(split!.id, selectedAmount, selectedShipping);
if (result.success) {
setRequestSuccess(true);
fetchSplit(); // Refresh data
} else {
setRequestError(result.error || 'Anfrage fehlgeschlagen');
}
setIsRequesting(false);
};
const userParticipation = split?.participants.find(p => p.userId === currentUserId);
const canRequest = !userParticipation && currentUserId && currentUserId !== split?.hostId;
const isWaitlist = split && split.remaining < selectedAmount;
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-950">
<Loader2 size={48} className="animate-spin text-orange-600" />
</div>
);
}
if (error || !split) {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-zinc-950 p-6">
<AlertCircle size={48} className="text-red-500" />
<h1 className="text-xl font-bold text-zinc-50">{error || 'Split nicht gefunden'}</h1>
<Link href="/" className="text-orange-600 font-bold">Zurück zum Start</Link>
</div>
);
}
const price = calculatePrice(selectedAmount);
return (
<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">
{/* Back */}
<Link
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]"
>
<ChevronLeft size={16} />
Zurück
</Link>
{/* Hero */}
<div className="bg-zinc-900 rounded-3xl overflow-hidden border border-zinc-800 shadow-xl">
{/* Bottle Image */}
{split.bottle.imageUrl && (
<div className="h-48 md:h-64 bg-zinc-800 relative">
<img
src={split.bottle.imageUrl}
alt={split.bottle.name}
className="w-full h-full object-cover opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-transparent" />
</div>
)}
<div className="p-6 space-y-4">
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-orange-500 mb-1">
Flaschenteilung
</p>
<h1 className="text-2xl md:text-3xl font-black text-zinc-50">
{split.bottle.name}
</h1>
{split.bottle.distillery && (
<p className="text-zinc-500 font-medium">{split.bottle.distillery}</p>
)}
</div>
{/* Stats Row */}
<div className="flex flex-wrap gap-3">
{split.bottle.abv && (
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
{split.bottle.abv}% ABV
</span>
)}
{split.bottle.age && (
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
{split.bottle.age} Jahre
</span>
)}
<span className="px-3 py-1.5 bg-zinc-800 rounded-xl text-xs font-bold text-zinc-300">
{split.totalVolume}cl Flasche
</span>
</div>
{/* Progress Bar */}
<div className="pt-4">
<SplitProgressBar
totalVolume={split.totalVolume}
hostShare={split.hostShare}
taken={split.taken}
reserved={split.reserved}
height="lg"
/>
</div>
</div>
</div>
{/* Request Form */}
{canRequest && !requestSuccess && (
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 space-y-6">
<h2 className="text-sm font-black uppercase tracking-widest text-zinc-400">
Sample bestellen
</h2>
{/* Amount Selection */}
<div className="space-y-3">
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest">Menge</label>
<div className="grid grid-cols-2 gap-3">
{[5, 10].map(amount => (
<button
key={amount}
onClick={() => setSelectedAmount(amount as 5 | 10)}
className={`p-4 rounded-2xl border-2 transition-all ${selectedAmount === amount
? 'border-orange-500 bg-orange-500/10'
: 'border-zinc-700 hover:border-zinc-600'
}`}
>
<span className="text-2xl font-black text-white">{amount}cl</span>
<p className="text-xs text-zinc-500 mt-1">
{amount === 5 ? 'Kleiner Taster' : 'Ordentliche Portion'}
</p>
</button>
))}
</div>
</div>
{/* Shipping Selection */}
<div className="space-y-3">
<label className="text-xs font-bold text-zinc-500 uppercase tracking-widest flex items-center gap-2">
<Truck size={14} />
Versand
</label>
<div className="grid grid-cols-2 gap-3">
{split.shippingOptions.map(option => (
<button
key={option.name}
onClick={() => setSelectedShipping(option.name)}
className={`p-4 rounded-2xl border-2 transition-all text-left ${selectedShipping === option.name
? 'border-orange-500 bg-orange-500/10'
: 'border-zinc-700 hover:border-zinc-600'
}`}
>
<span className="text-sm font-bold text-white">{option.name}</span>
<p className="text-xs text-zinc-500">{option.price.toFixed(2)}</p>
</button>
))}
</div>
</div>
{/* Price Breakdown */}
<div className="bg-zinc-950 rounded-2xl p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-zinc-500">Whisky ({selectedAmount}cl)</span>
<span className="text-zinc-300">{price.whisky.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-zinc-500">Sample-Flasche</span>
<span className="text-zinc-300">{price.glass.toFixed(2)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-zinc-500">Versand ({selectedShipping})</span>
<span className="text-zinc-300">{price.shipping.toFixed(2)}</span>
</div>
<div className="border-t border-zinc-800 pt-2 mt-2 flex justify-between">
<span className="font-bold text-white">Gesamt</span>
<span className="font-black text-xl text-orange-500">{price.total.toFixed(2)}</span>
</div>
</div>
{/* Error */}
{requestError && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{requestError}
</div>
)}
{/* Submit Button */}
<button
onClick={handleRequest}
disabled={isRequesting || !selectedShipping}
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-orange-600 hover:bg-orange-700'
} disabled:opacity-50`}
>
{isRequesting ? (
<Loader2 size={20} className="animate-spin" />
) : isWaitlist ? (
<>
<Clock size={20} />
Auf Warteliste setzen
</>
) : (
<>
<Package size={20} />
Anfrage senden
</>
)}
</button>
</div>
)}
{/* Success Message */}
{requestSuccess && (
<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" />
<h3 className="text-lg font-bold text-white mb-2">Anfrage gesendet!</h3>
<p className="text-zinc-400 text-sm">
Der Host wird deine Anfrage prüfen und sich bei dir melden.
</p>
</div>
)}
{/* Already Participating */}
{userParticipation && (
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800">
<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'
? 'bg-green-500/20 text-green-500'
: userParticipation.status === 'PENDING'
? 'bg-yellow-500/20 text-yellow-500'
: 'bg-red-500/20 text-red-500'
}`}>
{userParticipation.status === 'SHIPPED' ? <Package size={24} /> :
userParticipation.status === 'PENDING' ? <Clock size={24} /> :
<CheckCircle2 size={24} />}
</div>
<div>
<p className="text-sm font-bold text-white">Du nimmst teil</p>
<p className="text-xs text-zinc-500">
{userParticipation.amountCl}cl · {userParticipation.totalCost.toFixed(2)} ·
Status: {userParticipation.status}
</p>
</div>
</div>
</div>
)}
{/* Not logged in */}
{!currentUserId && (
<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" />
<p className="text-zinc-400 mb-4">Melde dich an, um teilzunehmen</p>
<Link
href="/login"
className="inline-block px-6 py-3 bg-orange-600 text-white rounded-xl font-bold"
>
Anmelden
</Link>
</div>
)}
{/* Host View */}
{currentUserId === split.hostId && (
<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>
<Link
href="/splits/manage"
className="inline-block px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-xl font-bold text-sm transition-colors"
>
Zum Dashboard
</Link>
</div>
)}
{/* Share Button */}
<button
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"
>
<Share2 size={18} />
Link teilen
</button>
</div>
</main>
);
}

View File

@@ -0,0 +1,407 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft, ChevronRight, Package, Truck, Plus, X, Loader2, Check, Wine } from 'lucide-react';
import { createSplit, ShippingOption } from '@/services/split-actions';
import { createClient } from '@/lib/supabase/client';
interface Bottle {
id: string;
name: string;
distillery?: string;
image_url?: string;
}
export default function CreateSplitPage() {
const router = useRouter();
const supabase = createClient();
const [step, setStep] = useState(1);
const [bottles, setBottles] = useState<Bottle[]>([]);
const [isLoadingBottles, setIsLoadingBottles] = useState(true);
// Form state
const [selectedBottle, setSelectedBottle] = useState<Bottle | null>(null);
const [totalVolume, setTotalVolume] = useState(70);
const [hostShare, setHostShare] = useState(10);
const [priceBottle, setPriceBottle] = useState('');
const [costGlass5cl, setCostGlass5cl] = useState('0.80');
const [costGlass10cl, setCostGlass10cl] = useState('1.50');
const [shippingOptions, setShippingOptions] = useState<ShippingOption[]>([
{ name: 'DHL', price: 5.50 },
{ name: 'Hermes', price: 4.90 },
]);
const [newShippingName, setNewShippingName] = useState('');
const [newShippingPrice, setNewShippingPrice] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchBottles();
loadSavedDefaults();
}, []);
const loadSavedDefaults = () => {
const saved = localStorage.getItem('split-defaults');
if (saved) {
const defaults = JSON.parse(saved);
setCostGlass5cl(defaults.costGlass5cl || '0.80');
setCostGlass10cl(defaults.costGlass10cl || '1.50');
if (defaults.shippingOptions) {
setShippingOptions(defaults.shippingOptions);
}
}
};
const saveDefaults = () => {
localStorage.setItem('split-defaults', JSON.stringify({
costGlass5cl,
costGlass10cl,
shippingOptions,
}));
};
const fetchBottles = async () => {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/login');
return;
}
const { data } = await supabase
.from('bottles')
.select('id, name, distillery, image_url')
.eq('user_id', user.id)
.is('finished_at', null)
.order('name');
setBottles(data || []);
setIsLoadingBottles(false);
};
const addShippingOption = () => {
if (!newShippingName || !newShippingPrice) return;
setShippingOptions([...shippingOptions, {
name: newShippingName,
price: parseFloat(newShippingPrice),
}]);
setNewShippingName('');
setNewShippingPrice('');
};
const removeShippingOption = (index: number) => {
setShippingOptions(shippingOptions.filter((_, i) => i !== index));
};
const handleCreate = async () => {
if (!selectedBottle || !priceBottle) return;
setIsCreating(true);
setError(null);
saveDefaults();
const result = await createSplit({
bottleId: selectedBottle.id,
totalVolume,
hostShare,
priceBottle: parseFloat(priceBottle),
costGlass5cl: parseFloat(costGlass5cl),
costGlass10cl: parseFloat(costGlass10cl),
shippingOptions,
});
if (result.success && result.slug) {
router.push(`/splits/${result.slug}`);
} else {
setError(result.error || 'Fehler beim Erstellen');
setIsCreating(false);
}
};
const pricePerCl = priceBottle ? parseFloat(priceBottle) / totalVolume : 0;
const price5cl = pricePerCl * 5 + parseFloat(costGlass5cl || '0');
const price10cl = pricePerCl * 10 + parseFloat(costGlass10cl || '0');
return (
<main className="min-h-screen bg-zinc-950 p-4 md:p-8 lg:p-12">
<div className="max-w-xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Link
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]"
>
<ChevronLeft size={16} />
Abbrechen
</Link>
<span className="text-xs font-bold text-zinc-600">Schritt {step} von 3</span>
</div>
{/* Progress */}
<div className="flex gap-2">
{[1, 2, 3].map(s => (
<div
key={s}
className={`h-1 flex-1 rounded-full transition-colors ${s <= step ? 'bg-orange-600' : 'bg-zinc-800'
}`}
/>
))}
</div>
{/* Step 1: Select Bottle */}
{step === 1 && (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-black text-white">Flasche wählen</h1>
<p className="text-zinc-500 text-sm">Welche Flasche möchtest du teilen?</p>
</div>
{isLoadingBottles ? (
<div className="flex justify-center py-12">
<Loader2 size={32} className="animate-spin text-orange-600" />
</div>
) : bottles.length === 0 ? (
<div className="text-center py-12 text-zinc-500">
<Wine size={48} className="mx-auto mb-4 opacity-50" />
<p>Keine Flaschen in deiner Sammlung</p>
</div>
) : (
<div className="grid gap-3 max-h-[400px] overflow-y-auto pr-2">
{bottles.map(bottle => (
<button
key={bottle.id}
onClick={() => setSelectedBottle(bottle)}
className={`flex items-center gap-4 p-4 rounded-2xl border-2 text-left transition-all ${selectedBottle?.id === bottle.id
? 'border-orange-500 bg-orange-500/10'
: 'border-zinc-800 hover:border-zinc-700'
}`}
>
{bottle.image_url ? (
<img
src={bottle.image_url}
alt={bottle.name}
className="w-14 h-14 rounded-xl object-cover"
/>
) : (
<div className="w-14 h-14 rounded-xl bg-zinc-800 flex items-center justify-center">
<Wine size={24} className="text-zinc-600" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-bold text-white truncate">{bottle.name}</p>
{bottle.distillery && (
<p className="text-xs text-zinc-500">{bottle.distillery}</p>
)}
</div>
{selectedBottle?.id === bottle.id && (
<Check size={20} className="text-orange-500" />
)}
</button>
))}
</div>
)}
<button
onClick={() => setStep(2)}
disabled={!selectedBottle}
className="w-full 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
<ChevronRight size={18} />
</button>
</div>
)}
{/* Step 2: Pricing */}
{step === 2 && (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-black text-white">Preise festlegen</h1>
<p className="text-zinc-500 text-sm">{selectedBottle?.name}</p>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-bold text-zinc-500 block mb-2">Flascheninhalt (cl)</label>
<input
type="number"
value={totalVolume}
onChange={e => setTotalVolume(parseInt(e.target.value) || 70)}
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white"
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 block mb-2">Dein Anteil (cl)</label>
<input
type="number"
value={hostShare}
onChange={e => setHostShare(parseInt(e.target.value) || 10)}
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white"
/>
</div>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 block mb-2">Flaschenpreis ()</label>
<input
type="number"
step="0.01"
value={priceBottle}
onChange={e => setPriceBottle(e.target.value)}
placeholder="z.B. 65.00"
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-bold text-zinc-500 block mb-2">Flasche 5cl ()</label>
<input
type="number"
step="0.01"
value={costGlass5cl}
onChange={e => setCostGlass5cl(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white"
/>
</div>
<div>
<label className="text-xs font-bold text-zinc-500 block mb-2">Flasche 10cl ()</label>
<input
type="number"
step="0.01"
value={costGlass10cl}
onChange={e => setCostGlass10cl(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white"
/>
</div>
</div>
{/* Preview */}
{priceBottle && (
<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>
<div className="flex justify-between">
<span className="text-white">5cl Preis:</span>
<span className="font-bold text-orange-500">{price5cl.toFixed(2)}</span>
</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 className="flex gap-3">
<button
onClick={() => setStep(1)}
className="flex-1 py-4 bg-zinc-800 hover:bg-zinc-700 text-white rounded-2xl font-bold transition-colors"
>
Zurück
</button>
<button
onClick={() => setStep(3)}
disabled={!priceBottle}
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
<ChevronRight size={18} />
</button>
</div>
</div>
)}
{/* Step 3: Shipping */}
{step === 3 && (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-black text-white">Versandoptionen</h1>
<p className="text-zinc-500 text-sm">Welche Versandarten bietest du an?</p>
</div>
<div className="space-y-3">
{shippingOptions.map((option, i) => (
<div
key={i}
className="flex items-center justify-between p-4 bg-zinc-800 rounded-2xl"
>
<div className="flex items-center gap-3">
<Truck size={20} className="text-orange-600" />
<span className="font-bold text-white">{option.name}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-zinc-400">{option.price.toFixed(2)}</span>
<button
onClick={() => removeShippingOption(i)}
className="text-red-500 hover:text-red-400"
>
<X size={18} />
</button>
</div>
</div>
))}
{/* Add new */}
<div className="flex gap-2">
<input
type="text"
value={newShippingName}
onChange={e => setNewShippingName(e.target.value)}
placeholder="Name (z.B. DPD)"
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white text-sm"
/>
<input
type="number"
step="0.01"
value={newShippingPrice}
onChange={e => setNewShippingPrice(e.target.value)}
placeholder="Preis"
className="w-24 bg-zinc-800 border border-zinc-700 rounded-xl px-4 py-3 text-white text-sm"
/>
<button
onClick={addShippingOption}
disabled={!newShippingName || !newShippingPrice}
className="p-3 bg-zinc-700 hover:bg-zinc-600 disabled:opacity-50 rounded-xl transition-colors"
>
<Plus size={20} className="text-white" />
</button>
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-3 text-sm text-red-400">
{error}
</div>
)}
<div className="flex gap-3">
<button
onClick={() => setStep(2)}
className="flex-1 py-4 bg-zinc-800 hover:bg-zinc-700 text-white rounded-2xl font-bold transition-colors"
>
Zurück
</button>
<button
onClick={handleCreate}
disabled={isCreating || shippingOptions.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"
>
{isCreating ? (
<Loader2 size={20} className="animate-spin" />
) : (
<>
<Package size={18} />
Split erstellen
</>
)}
</button>
</div>
</div>
)}
</div>
</main>
);
}

View File

@@ -0,0 +1,344 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { ChevronLeft, Package, Users, Copy, Check, ExternalLink, Loader2, X, Clock, CheckCircle2, Truck, Ban } from 'lucide-react';
import { getHostSplits, getSplitBySlug, updateParticipantStatus, generateForumExport, closeSplit, SplitDetails } from '@/services/split-actions';
import SplitProgressBar from '@/components/SplitProgressBar';
interface SplitSummary {
id: string;
slug: string;
bottleName: string;
bottleImage?: string;
totalVolume: number;
hostShare: number;
participantCount: number;
pendingCount: number;
isActive: boolean;
}
export default function SplitManagePage() {
const [splits, setSplits] = useState<SplitSummary[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedSplit, setSelectedSplit] = useState<SplitDetails | null>(null);
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [forumText, setForumText] = useState<string | null>(null);
const [copiedLink, setCopiedLink] = useState<string | null>(null);
useEffect(() => {
fetchSplits();
}, []);
const fetchSplits = async () => {
const result = await getHostSplits();
if (result.success && result.splits) {
setSplits(result.splits);
}
setIsLoading(false);
};
const openSplitDetails = async (slug: string) => {
setIsLoadingDetails(true);
const result = await getSplitBySlug(slug);
if (result.success && result.data) {
setSelectedSplit(result.data);
}
setIsLoadingDetails(false);
};
const handleStatusUpdate = async (participantId: string, newStatus: 'APPROVED' | 'REJECTED' | 'PAID' | 'SHIPPED') => {
const result = await updateParticipantStatus(participantId, newStatus);
if (result.success && selectedSplit) {
openSplitDetails(selectedSplit.publicSlug);
fetchSplits();
}
};
const handleForumExport = async (splitId: string) => {
const result = await generateForumExport(splitId);
if (result.success && result.text) {
setForumText(result.text);
}
};
const handleCloseSplit = async (splitId: string) => {
if (!confirm('Möchtest du diesen Split wirklich schließen?')) return;
const result = await closeSplit(splitId);
if (result.success) {
fetchSplits();
setSelectedSplit(null);
}
};
const copyLink = (slug: string) => {
const url = `${window.location.origin}/splits/${slug}`;
navigator.clipboard.writeText(url);
setCopiedLink(slug);
setTimeout(() => setCopiedLink(null), 2000);
};
const statusConfig: Record<string, { label: string; color: string; icon: typeof Clock }> = {
PENDING: { label: 'Ausstehend', color: 'bg-yellow-500/20 text-yellow-500', icon: Clock },
APPROVED: { label: 'Bestätigt', color: 'bg-green-500/20 text-green-500', icon: CheckCircle2 },
PAID: { label: 'Bezahlt', color: 'bg-blue-500/20 text-blue-500', icon: Check },
SHIPPED: { label: 'Versendet', color: 'bg-purple-500/20 text-purple-500', icon: Truck },
REJECTED: { label: 'Abgelehnt', color: 'bg-red-500/20 text-red-500', icon: Ban },
WAITLIST: { label: 'Warteliste', color: 'bg-zinc-500/20 text-zinc-500', icon: Clock },
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-950">
<Loader2 size={48} className="animate-spin text-orange-600" />
</div>
);
}
return (
<main className="min-h-screen bg-zinc-950 p-4 md:p-8 lg:p-12">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Link
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]"
>
<ChevronLeft size={16} />
Zurück
</Link>
<Link
href="/splits/create"
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-bold text-sm transition-colors"
>
Neuer Split
</Link>
</div>
<div>
<h1 className="text-3xl font-black text-white">Meine Splits</h1>
<p className="text-zinc-500">Verwalte deine Flaschenteilungen</p>
</div>
{splits.length === 0 ? (
<div className="bg-zinc-900 rounded-3xl p-12 border border-zinc-800 text-center">
<Package size={48} className="mx-auto text-zinc-600 mb-4" />
<p className="text-zinc-500 mb-4">Du hast noch keine Splits erstellt</p>
<Link
href="/splits/create"
className="inline-block px-6 py-3 bg-orange-600 text-white rounded-xl font-bold"
>
Ersten Split erstellen
</Link>
</div>
) : (
<div className="grid gap-4">
{splits.map(split => (
<div
key={split.id}
className={`bg-zinc-900 rounded-2xl p-4 border transition-all cursor-pointer ${selectedSplit?.id === split.id
? 'border-orange-500'
: 'border-zinc-800 hover:border-zinc-700'
}`}
onClick={() => openSplitDetails(split.slug)}
>
<div className="flex items-center gap-4">
{split.bottleImage ? (
<img
src={split.bottleImage}
alt={split.bottleName}
className="w-16 h-16 rounded-xl object-cover"
/>
) : (
<div className="w-16 h-16 rounded-xl bg-zinc-800 flex items-center justify-center">
<Package size={24} className="text-zinc-600" />
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-bold text-white truncate">{split.bottleName}</p>
{!split.isActive && (
<span className="px-2 py-0.5 bg-zinc-800 rounded text-[10px] font-bold text-zinc-500">
Geschlossen
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-zinc-500">
<span className="flex items-center gap-1">
<Users size={12} />
{split.participantCount} bestätigt
</span>
{split.pendingCount > 0 && (
<span className="flex items-center gap-1 text-yellow-500">
<Clock size={12} />
{split.pendingCount} ausstehend
</span>
)}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
copyLink(split.slug);
}}
className="p-2 hover:bg-zinc-800 rounded-xl transition-colors"
>
{copiedLink === split.slug ? (
<Check size={18} className="text-green-500" />
) : (
<Copy size={18} className="text-zinc-500" />
)}
</button>
</div>
</div>
))}
</div>
)}
{/* Detail Panel */}
{selectedSplit && (
<div className="bg-zinc-900 rounded-3xl p-6 border border-zinc-800 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-white">{selectedSplit.bottle.name}</h2>
<button
onClick={() => setSelectedSplit(null)}
className="p-2 hover:bg-zinc-800 rounded-xl"
>
<X size={20} className="text-zinc-500" />
</button>
</div>
{/* Progress */}
<SplitProgressBar
totalVolume={selectedSplit.totalVolume}
hostShare={selectedSplit.hostShare}
taken={selectedSplit.taken}
reserved={selectedSplit.reserved}
height="md"
/>
{/* Participants */}
<div className="space-y-3">
<h3 className="text-xs font-bold text-zinc-500 uppercase tracking-widest">Teilnehmer</h3>
{selectedSplit.participants.length === 0 ? (
<p className="text-sm text-zinc-500 italic">Noch keine Anfragen</p>
) : (
selectedSplit.participants.map(p => {
const config = statusConfig[p.status];
const StatusIcon = config.icon;
return (
<div key={p.id} className="flex items-center justify-between p-3 bg-zinc-800 rounded-xl">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${config.color}`}>
<StatusIcon size={16} />
</div>
<div>
<p className="text-sm font-bold text-white">
{p.username || 'Anonym'}
</p>
<p className="text-xs text-zinc-500">
{p.amountCl}cl · {p.totalCost.toFixed(2)} · {p.shippingMethod}
</p>
</div>
</div>
{/* Status Actions */}
<div className="flex gap-2">
{p.status === 'PENDING' && (
<>
<button
onClick={() => handleStatusUpdate(p.id, 'APPROVED')}
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded-lg text-xs font-bold"
>
Bestätigen
</button>
<button
onClick={() => handleStatusUpdate(p.id, 'REJECTED')}
className="px-3 py-1.5 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-lg text-xs font-bold"
>
Ablehnen
</button>
</>
)}
{p.status === 'APPROVED' && (
<button
onClick={() => handleStatusUpdate(p.id, 'PAID')}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-xs font-bold"
>
Bezahlt
</button>
)}
{p.status === 'PAID' && (
<button
onClick={() => handleStatusUpdate(p.id, 'SHIPPED')}
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-xs font-bold"
>
Versendet
</button>
)}
</div>
</div>
);
})
)}
</div>
{/* Actions */}
<div className="flex flex-wrap gap-3 pt-4 border-t border-zinc-800">
<Link
href={`/splits/${selectedSplit.publicSlug}`}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-xl text-sm font-bold flex items-center gap-2 transition-colors"
>
<ExternalLink size={16} />
Öffentliche Seite
</Link>
<button
onClick={() => handleForumExport(selectedSplit.id)}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-xl text-sm font-bold flex items-center gap-2 transition-colors"
>
<Copy size={16} />
Forum Export
</button>
{selectedSplit.isActive && (
<button
onClick={() => handleCloseSplit(selectedSplit.id)}
className="px-4 py-2 bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded-xl text-sm font-bold transition-colors"
>
Split schließen
</button>
)}
</div>
</div>
)}
{/* Forum Export Modal */}
{forumText && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center p-4 z-50">
<div className="bg-zinc-900 rounded-3xl p-6 max-w-lg w-full space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold text-white">Forum Export</h3>
<button onClick={() => setForumText(null)} className="p-2 hover:bg-zinc-800 rounded-xl">
<X size={20} className="text-zinc-500" />
</button>
</div>
<pre className="bg-zinc-950 p-4 rounded-xl text-xs text-zinc-300 overflow-x-auto whitespace-pre-wrap">
{forumText}
</pre>
<button
onClick={() => {
navigator.clipboard.writeText(forumText);
setForumText(null);
}}
className="w-full py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-xl font-bold flex items-center justify-center gap-2"
>
<Copy size={16} />
Kopieren & Schließen
</button>
</div>
</div>
)}
</div>
</main>
);
}