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:
357
src/app/splits/[slug]/page.tsx
Normal file
357
src/app/splits/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
407
src/app/splits/create/page.tsx
Normal file
407
src/app/splits/create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
344
src/app/splits/manage/page.tsx
Normal file
344
src/app/splits/manage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/SplitProgressBar.tsx
Normal file
101
src/components/SplitProgressBar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface SplitProgressBarProps {
|
||||
totalVolume: number;
|
||||
hostShare: number;
|
||||
taken: number;
|
||||
reserved: number;
|
||||
showLabels?: boolean;
|
||||
height?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export default function SplitProgressBar({
|
||||
totalVolume,
|
||||
hostShare,
|
||||
taken,
|
||||
reserved,
|
||||
showLabels = true,
|
||||
height = 'md',
|
||||
}: SplitProgressBarProps) {
|
||||
const available = totalVolume - hostShare - taken - reserved;
|
||||
|
||||
const hostPercent = (hostShare / totalVolume) * 100;
|
||||
const takenPercent = (taken / totalVolume) * 100;
|
||||
const reservedPercent = (reserved / totalVolume) * 100;
|
||||
const availablePercent = (available / totalVolume) * 100;
|
||||
|
||||
const heightClass = height === 'sm' ? 'h-3' : height === 'lg' ? 'h-8' : 'h-5';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className={`w-full ${heightClass} rounded-full overflow-hidden flex bg-zinc-800`}>
|
||||
{/* Host Share - Grey */}
|
||||
{hostPercent > 0 && (
|
||||
<div
|
||||
className="bg-zinc-600 flex items-center justify-center text-[8px] font-bold text-white/70"
|
||||
style={{ width: `${hostPercent}%` }}
|
||||
>
|
||||
{height !== 'sm' && hostPercent > 10 && 'Host'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Taken - Orange/Red */}
|
||||
{takenPercent > 0 && (
|
||||
<div
|
||||
className="bg-orange-600 flex items-center justify-center text-[8px] font-bold text-white"
|
||||
style={{ width: `${takenPercent}%` }}
|
||||
>
|
||||
{height !== 'sm' && takenPercent > 8 && `${taken}cl`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reserved/Pending - Yellow */}
|
||||
{reservedPercent > 0 && (
|
||||
<div
|
||||
className="bg-yellow-500 flex items-center justify-center text-[8px] font-bold text-black/70"
|
||||
style={{ width: `${reservedPercent}%` }}
|
||||
>
|
||||
{height !== 'sm' && reservedPercent > 8 && `${reserved}cl`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available - Green */}
|
||||
{availablePercent > 0 && (
|
||||
<div
|
||||
className="bg-green-500 flex items-center justify-center text-[8px] font-bold text-white"
|
||||
style={{ width: `${availablePercent}%` }}
|
||||
>
|
||||
{height !== 'sm' && availablePercent > 8 && `${available}cl`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showLabels && (
|
||||
<div className="flex flex-wrap gap-3 text-[10px] font-bold">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm bg-zinc-600" />
|
||||
<span className="text-zinc-500">Host ({hostShare}cl)</span>
|
||||
</div>
|
||||
{taken > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm bg-orange-600" />
|
||||
<span className="text-zinc-500">Vergeben ({taken}cl)</span>
|
||||
</div>
|
||||
)}
|
||||
{reserved > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm bg-yellow-500" />
|
||||
<span className="text-zinc-500">Reserviert ({reserved}cl)</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm bg-green-500" />
|
||||
<span className="text-zinc-500">Verfügbar ({available}cl)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
588
src/services/split-actions.ts
Normal file
588
src/services/split-actions.ts
Normal file
@@ -0,0 +1,588 @@
|
||||
'use server';
|
||||
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
const generateSlug = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 8);
|
||||
|
||||
export interface ShippingOption {
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface CreateSplitData {
|
||||
bottleId: string;
|
||||
totalVolume?: number;
|
||||
hostShare?: number;
|
||||
priceBottle: number;
|
||||
costGlass5cl?: number;
|
||||
costGlass10cl?: number;
|
||||
shippingOptions: ShippingOption[];
|
||||
}
|
||||
|
||||
export interface SplitDetails {
|
||||
id: string;
|
||||
publicSlug: string;
|
||||
bottleId: string;
|
||||
hostId: string;
|
||||
totalVolume: number;
|
||||
hostShare: number;
|
||||
priceBottle: number;
|
||||
costGlass5cl: number;
|
||||
costGlass10cl: number;
|
||||
shippingOptions: ShippingOption[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
bottle: {
|
||||
id: string;
|
||||
name: string;
|
||||
distillery?: string;
|
||||
imageUrl?: string;
|
||||
abv?: number;
|
||||
age?: number;
|
||||
};
|
||||
// Calculated availability
|
||||
available: number;
|
||||
taken: number;
|
||||
reserved: number;
|
||||
remaining: number;
|
||||
participants: Array<{
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
amountCl: number;
|
||||
shippingMethod: string;
|
||||
totalCost: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new bottle split
|
||||
*/
|
||||
export async function createSplit(data: CreateSplitData): Promise<{
|
||||
success: boolean;
|
||||
splitId?: string;
|
||||
slug?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert' };
|
||||
}
|
||||
|
||||
// Verify bottle belongs to user
|
||||
const { data: bottle, error: bottleError } = await supabase
|
||||
.from('bottles')
|
||||
.select('id, user_id')
|
||||
.eq('id', data.bottleId)
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (bottleError || !bottle) {
|
||||
return { success: false, error: 'Flasche nicht gefunden' };
|
||||
}
|
||||
|
||||
// Check if split already exists for this bottle
|
||||
const { data: existingSplit } = await supabase
|
||||
.from('bottle_splits')
|
||||
.select('id')
|
||||
.eq('bottle_id', data.bottleId)
|
||||
.single();
|
||||
|
||||
if (existingSplit) {
|
||||
return { success: false, error: 'Diese Flasche hat bereits einen aktiven Split' };
|
||||
}
|
||||
|
||||
const slug = generateSlug();
|
||||
|
||||
const { data: split, error } = await supabase
|
||||
.from('bottle_splits')
|
||||
.insert({
|
||||
bottle_id: data.bottleId,
|
||||
host_id: user.id,
|
||||
total_volume: data.totalVolume || 70,
|
||||
host_share: data.hostShare || 10,
|
||||
price_bottle: data.priceBottle,
|
||||
cost_glass_5cl: data.costGlass5cl || 0.80,
|
||||
cost_glass_10cl: data.costGlass10cl || 1.50,
|
||||
shipping_options: data.shippingOptions,
|
||||
public_slug: slug,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Create split error:', error);
|
||||
return { success: false, error: 'Split konnte nicht erstellt werden' };
|
||||
}
|
||||
|
||||
revalidatePath(`/bottles/${data.bottleId}`);
|
||||
revalidatePath('/splits/manage');
|
||||
|
||||
return { success: true, splitId: split.id, slug };
|
||||
} catch (error) {
|
||||
console.error('createSplit error:', error);
|
||||
return { success: false, error: 'Unerwarteter Fehler' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get split details by public slug (for public page)
|
||||
*/
|
||||
export async function getSplitBySlug(slug: string): Promise<{
|
||||
success: boolean;
|
||||
data?: SplitDetails;
|
||||
error?: string;
|
||||
}> {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: split, error } = await supabase
|
||||
.from('bottle_splits')
|
||||
.select(`
|
||||
id,
|
||||
public_slug,
|
||||
bottle_id,
|
||||
host_id,
|
||||
total_volume,
|
||||
host_share,
|
||||
price_bottle,
|
||||
cost_glass_5cl,
|
||||
cost_glass_10cl,
|
||||
shipping_options,
|
||||
is_active,
|
||||
created_at,
|
||||
bottles (id, name, distillery, image_url, abv, age)
|
||||
`)
|
||||
.eq('public_slug', slug)
|
||||
.eq('is_active', true)
|
||||
.single();
|
||||
|
||||
if (error || !split) {
|
||||
return { success: false, error: 'Split nicht gefunden' };
|
||||
}
|
||||
|
||||
// Get participants
|
||||
const { data: participants } = await supabase
|
||||
.from('split_participants')
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
amount_cl,
|
||||
shipping_method,
|
||||
total_cost,
|
||||
status,
|
||||
created_at,
|
||||
profiles:user_id (username)
|
||||
`)
|
||||
.eq('split_id', split.id)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
// Calculate availability
|
||||
const available = split.total_volume - split.host_share;
|
||||
|
||||
let taken = 0;
|
||||
let reserved = 0;
|
||||
|
||||
(participants || []).forEach(p => {
|
||||
if (['APPROVED', 'PAID', 'SHIPPED'].includes(p.status)) {
|
||||
taken += p.amount_cl;
|
||||
} else if (p.status === 'PENDING') {
|
||||
reserved += p.amount_cl;
|
||||
}
|
||||
});
|
||||
|
||||
const remaining = available - taken - reserved;
|
||||
|
||||
const bottle = split.bottles as any;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: split.id,
|
||||
publicSlug: split.public_slug,
|
||||
bottleId: split.bottle_id,
|
||||
hostId: split.host_id,
|
||||
totalVolume: split.total_volume,
|
||||
hostShare: split.host_share,
|
||||
priceBottle: split.price_bottle,
|
||||
costGlass5cl: split.cost_glass_5cl,
|
||||
costGlass10cl: split.cost_glass_10cl,
|
||||
shippingOptions: split.shipping_options as ShippingOption[],
|
||||
isActive: split.is_active,
|
||||
createdAt: split.created_at,
|
||||
bottle: {
|
||||
id: bottle.id,
|
||||
name: bottle.name,
|
||||
distillery: bottle.distillery,
|
||||
imageUrl: bottle.image_url,
|
||||
abv: bottle.abv,
|
||||
age: bottle.age,
|
||||
},
|
||||
available,
|
||||
taken,
|
||||
reserved,
|
||||
remaining,
|
||||
participants: (participants || []).map(p => ({
|
||||
id: p.id,
|
||||
userId: p.user_id,
|
||||
username: (p.profiles as any)?.username,
|
||||
amountCl: p.amount_cl,
|
||||
shippingMethod: p.shipping_method,
|
||||
totalCost: p.total_cost,
|
||||
status: p.status,
|
||||
createdAt: p.created_at,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getSplitBySlug error:', error);
|
||||
return { success: false, error: 'Unerwarteter Fehler' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a slot in a split
|
||||
*/
|
||||
export async function requestSlot(
|
||||
splitId: string,
|
||||
amountCl: 5 | 10,
|
||||
shippingMethod: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert' };
|
||||
}
|
||||
|
||||
// Get split details
|
||||
const { data: split, error: splitError } = await supabase
|
||||
.from('bottle_splits')
|
||||
.select('*')
|
||||
.eq('id', splitId)
|
||||
.eq('is_active', true)
|
||||
.single();
|
||||
|
||||
if (splitError || !split) {
|
||||
return { success: false, error: 'Split nicht gefunden' };
|
||||
}
|
||||
|
||||
// Can't join your own split
|
||||
if (split.host_id === user.id) {
|
||||
return { success: false, error: 'Du kannst nicht an deinem eigenen Split teilnehmen' };
|
||||
}
|
||||
|
||||
// Check if already participating
|
||||
const { data: existing } = await supabase
|
||||
.from('split_participants')
|
||||
.select('id, status')
|
||||
.eq('split_id', splitId)
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (existing && !['REJECTED'].includes(existing.status)) {
|
||||
return { success: false, error: 'Du nimmst bereits an diesem Split teil' };
|
||||
}
|
||||
|
||||
// Calculate availability
|
||||
const { data: participants } = await supabase
|
||||
.from('split_participants')
|
||||
.select('amount_cl, status')
|
||||
.eq('split_id', splitId);
|
||||
|
||||
const available = split.total_volume - split.host_share;
|
||||
let takenAndReserved = 0;
|
||||
|
||||
(participants || []).forEach(p => {
|
||||
if (['APPROVED', 'PAID', 'SHIPPED', 'PENDING'].includes(p.status)) {
|
||||
takenAndReserved += p.amount_cl;
|
||||
}
|
||||
});
|
||||
|
||||
const remaining = available - takenAndReserved;
|
||||
|
||||
// Calculate total cost
|
||||
const pricePerCl = split.price_bottle / split.total_volume;
|
||||
const glassCost = amountCl === 5 ? split.cost_glass_5cl : split.cost_glass_10cl;
|
||||
const shippingOption = (split.shipping_options as ShippingOption[])
|
||||
.find(s => s.name === shippingMethod);
|
||||
const shippingCost = shippingOption?.price || 0;
|
||||
const totalCost = (pricePerCl * amountCl) + glassCost + shippingCost;
|
||||
|
||||
// Determine status
|
||||
const status = remaining >= amountCl ? 'PENDING' : 'WAITLIST';
|
||||
|
||||
// Insert or update (if was rejected)
|
||||
if (existing) {
|
||||
const { error } = await supabase
|
||||
.from('split_participants')
|
||||
.update({
|
||||
amount_cl: amountCl,
|
||||
shipping_method: shippingMethod,
|
||||
total_cost: totalCost,
|
||||
status,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', existing.id);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: 'Anfrage fehlgeschlagen' };
|
||||
}
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('split_participants')
|
||||
.insert({
|
||||
split_id: splitId,
|
||||
user_id: user.id,
|
||||
amount_cl: amountCl,
|
||||
shipping_method: shippingMethod,
|
||||
total_cost: totalCost,
|
||||
status,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Insert participant error:', error);
|
||||
return { success: false, error: 'Anfrage fehlgeschlagen' };
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/splits/${split.public_slug}`);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('requestSlot error:', error);
|
||||
return { success: false, error: 'Unerwarteter Fehler' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update participant status (host only)
|
||||
*/
|
||||
export async function updateParticipantStatus(
|
||||
participantId: string,
|
||||
newStatus: 'APPROVED' | 'REJECTED' | 'PAID' | 'SHIPPED'
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert' };
|
||||
}
|
||||
|
||||
// Verify host owns the split
|
||||
const { data: participant, error: findError } = await supabase
|
||||
.from('split_participants')
|
||||
.select(`
|
||||
id,
|
||||
split_id,
|
||||
bottle_splits!inner (host_id, public_slug)
|
||||
`)
|
||||
.eq('id', participantId)
|
||||
.single();
|
||||
|
||||
if (findError || !participant) {
|
||||
return { success: false, error: 'Teilnehmer nicht gefunden' };
|
||||
}
|
||||
|
||||
const split = participant.bottle_splits as any;
|
||||
if (split.host_id !== user.id) {
|
||||
return { success: false, error: 'Nicht autorisiert' };
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('split_participants')
|
||||
.update({ status: newStatus })
|
||||
.eq('id', participantId);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: 'Status konnte nicht aktualisiert werden' };
|
||||
}
|
||||
|
||||
revalidatePath(`/splits/${split.public_slug}`);
|
||||
revalidatePath('/splits/manage');
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('updateParticipantStatus error:', error);
|
||||
return { success: false, error: 'Unerwarteter Fehler' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all splits hosted by current user
|
||||
*/
|
||||
export async function getHostSplits(): Promise<{
|
||||
success: boolean;
|
||||
splits?: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
bottleName: string;
|
||||
bottleImage?: string;
|
||||
totalVolume: number;
|
||||
hostShare: number;
|
||||
participantCount: number;
|
||||
pendingCount: number;
|
||||
isActive: boolean;
|
||||
}>;
|
||||
error?: string;
|
||||
}> {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert' };
|
||||
}
|
||||
|
||||
const { data: splits, error } = await supabase
|
||||
.from('bottle_splits')
|
||||
.select(`
|
||||
id,
|
||||
public_slug,
|
||||
total_volume,
|
||||
host_share,
|
||||
is_active,
|
||||
bottles (name, image_url),
|
||||
split_participants (status)
|
||||
`)
|
||||
.eq('host_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: 'Fehler beim Laden' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
splits: (splits || []).map(s => {
|
||||
const participants = (s.split_participants as any[]) || [];
|
||||
const bottle = s.bottles as any;
|
||||
return {
|
||||
id: s.id,
|
||||
slug: s.public_slug,
|
||||
bottleName: bottle?.name || 'Unbekannt',
|
||||
bottleImage: bottle?.image_url,
|
||||
totalVolume: s.total_volume,
|
||||
hostShare: s.host_share,
|
||||
participantCount: participants.filter(p =>
|
||||
['APPROVED', 'PAID', 'SHIPPED'].includes(p.status)
|
||||
).length,
|
||||
pendingCount: participants.filter(p => p.status === 'PENDING').length,
|
||||
isActive: s.is_active,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getHostSplits error:', error);
|
||||
return { success: false, error: 'Unerwarteter Fehler' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate forum export text
|
||||
*/
|
||||
export async function generateForumExport(splitId: string): Promise<{
|
||||
success: boolean;
|
||||
text?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert' };
|
||||
}
|
||||
|
||||
const { data: split, error } = await supabase
|
||||
.from('bottle_splits')
|
||||
.select(`
|
||||
*,
|
||||
bottles (name, distillery),
|
||||
split_participants (
|
||||
amount_cl,
|
||||
status,
|
||||
profiles:user_id (username)
|
||||
)
|
||||
`)
|
||||
.eq('id', splitId)
|
||||
.eq('host_id', user.id)
|
||||
.single();
|
||||
|
||||
if (error || !split) {
|
||||
return { success: false, error: 'Split nicht gefunden' };
|
||||
}
|
||||
|
||||
const bottle = split.bottles as any;
|
||||
const participants = split.split_participants as any[] || [];
|
||||
const shippingOptions = split.shipping_options as ShippingOption[];
|
||||
|
||||
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);
|
||||
|
||||
let text = `[b]${bottle.name}${bottle.distillery ? ` (${bottle.distillery})` : ''} Split[/b]\n\n`;
|
||||
text += `Flaschenpreis: ${split.price_bottle.toFixed(2)}€\n`;
|
||||
text += `5cl: ${price5cl}€ | 10cl: ${price10cl}€\n`;
|
||||
text += `Versand: ${shippingOptions.map(s => `${s.name} (${s.price.toFixed(2)}€)`).join(', ')}\n\n`;
|
||||
text += `[b]Teilnehmer:[/b]\n`;
|
||||
text += `1. Host (${split.host_share}cl)\n`;
|
||||
|
||||
participants.forEach((p, i) => {
|
||||
const profile = p.profiles as any;
|
||||
const statusLabel = p.status === 'PENDING' ? ' - Ausstehend' :
|
||||
p.status === 'APPROVED' ? ' - Bestätigt' :
|
||||
p.status === 'PAID' ? ' - Bezahlt' :
|
||||
p.status === 'SHIPPED' ? ' - Versendet' : '';
|
||||
text += `${i + 2}. ${profile?.username || 'Anonym'} (${p.amount_cl}cl)${statusLabel}\n`;
|
||||
});
|
||||
|
||||
return { success: true, text };
|
||||
} catch (error) {
|
||||
console.error('generateForumExport error:', error);
|
||||
return { success: false, error: 'Unerwarteter Fehler' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close/deactivate a split
|
||||
*/
|
||||
export async function closeSplit(splitId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const supabase = await createClient();
|
||||
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return { success: false, error: 'Nicht autorisiert' };
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('bottle_splits')
|
||||
.update({ is_active: false })
|
||||
.eq('id', splitId)
|
||||
.eq('host_id', user.id);
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: 'Split konnte nicht geschlossen werden' };
|
||||
}
|
||||
|
||||
revalidatePath('/splits/manage');
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('closeSplit error:', error);
|
||||
return { success: false, error: 'Unerwarteter Fehler' };
|
||||
}
|
||||
}
|
||||
@@ -445,3 +445,76 @@ DROP POLICY IF EXISTS "buddy_invites_redeem_policy" ON buddy_invites;
|
||||
CREATE POLICY "buddy_invites_redeem_policy" ON buddy_invites
|
||||
FOR SELECT USING (expires_at > now());
|
||||
|
||||
-- ============================================
|
||||
-- Bottle Splits (Flaschenteilung)
|
||||
-- ============================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bottle_splits (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bottle_id UUID REFERENCES bottles(id) ON DELETE CASCADE UNIQUE,
|
||||
host_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
|
||||
total_volume INTEGER DEFAULT 70, -- in cl
|
||||
host_share INTEGER DEFAULT 10, -- what the host keeps, in cl
|
||||
price_bottle DECIMAL(10, 2) NOT NULL,
|
||||
cost_glass_5cl DECIMAL(10, 2) DEFAULT 0.80,
|
||||
cost_glass_10cl DECIMAL(10, 2) DEFAULT 1.50,
|
||||
shipping_options JSONB DEFAULT '[]'::jsonb,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
public_slug TEXT UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now())
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bottle_splits_host_id ON bottle_splits(host_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bottle_splits_public_slug ON bottle_splits(public_slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_bottle_splits_bottle_id ON bottle_splits(bottle_id);
|
||||
|
||||
ALTER TABLE bottle_splits ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Host can manage their own splits
|
||||
DROP POLICY IF EXISTS "bottle_splits_host_policy" ON bottle_splits;
|
||||
CREATE POLICY "bottle_splits_host_policy" ON bottle_splits
|
||||
FOR ALL USING ((SELECT auth.uid()) = host_id);
|
||||
|
||||
-- Anyone can view active splits (for public page)
|
||||
DROP POLICY IF EXISTS "bottle_splits_public_view" ON bottle_splits;
|
||||
CREATE POLICY "bottle_splits_public_view" ON bottle_splits
|
||||
FOR SELECT USING (is_active = true);
|
||||
|
||||
-- Split Participants
|
||||
CREATE TABLE IF NOT EXISTS split_participants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
split_id UUID REFERENCES bottle_splits(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)),
|
||||
shipping_method TEXT NOT NULL,
|
||||
total_cost DECIMAL(10, 2) NOT NULL,
|
||||
status TEXT DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'APPROVED', 'PAID', 'SHIPPED', 'REJECTED', 'WAITLIST')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()),
|
||||
UNIQUE(split_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_split_participants_split_id ON split_participants(split_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_split_participants_user_id ON split_participants(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_split_participants_status ON split_participants(status);
|
||||
|
||||
ALTER TABLE split_participants ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can view their own participations
|
||||
DROP POLICY IF EXISTS "split_participants_own_policy" ON split_participants;
|
||||
CREATE POLICY "split_participants_own_policy" ON split_participants
|
||||
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||
|
||||
-- Hosts can view/manage participants for their splits
|
||||
DROP POLICY IF EXISTS "split_participants_host_policy" ON split_participants;
|
||||
CREATE POLICY "split_participants_host_policy" ON split_participants
|
||||
FOR ALL USING (
|
||||
split_id IN (SELECT id FROM bottle_splits WHERE host_id = (SELECT auth.uid()))
|
||||
);
|
||||
|
||||
-- Anyone can view participants for public splits (to show fill-level)
|
||||
DROP POLICY IF EXISTS "split_participants_public_view" ON split_participants;
|
||||
CREATE POLICY "split_participants_public_view" ON split_participants
|
||||
FOR SELECT USING (
|
||||
split_id IN (SELECT id FROM bottle_splits WHERE is_active = true)
|
||||
);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user