Files
webshop/shop/components/order-wizard.tsx

569 lines
28 KiB
TypeScript

'use client'
import { useState, useMemo } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Product, ProductModule, Profile } from '@/lib/types'
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle } from 'lucide-react'
import { submitOrder } from '@/lib/actions/orders'
import { Category } from '@/lib/types'
import { Badge } from '@/components/ui/badge'
// ─── Types ────────────────────────────────────────────────────────────────────
type CategorySelection = {
productId: string | null // selected product in this category
moduleIds: string[] // selected module IDs for this product
}
// ─── Billing interval helpers ─────────────────────────────────────────────────
function billingLabel(interval?: string) {
if (interval === 'one_time') return 'einmalig'
return '/ Monat'
}
function billingBadgeClass(interval?: string) {
if (interval === 'one_time') return 'bg-slate-500/20 text-slate-300 border-slate-500/30'
return 'bg-blue-500/20 text-blue-400 border-blue-500/30'
}
// ─── Icon helper ──────────────────────────────────────────────────────────────
function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const Icon = icon === 'Cloud' ? Cloud : icon === 'Utensils' ? Utensils : icon === 'HardDrive' ? HardDrive : Package
return <Icon className={className} />
}
// ─── Module toggle logic ───────────────────────────────────────────────────────
function toggleModuleInList(
modules: ProductModule[],
currentIds: string[],
toggleId: string
): string[] {
const isSelected = currentIds.includes(toggleId)
if (isSelected) {
const next = currentIds.filter(id => id !== toggleId)
// cascade: remove modules that required this one
return next.filter(mId => {
const m = modules.find(mod => mod.id === mId)
return !m?.requirements || m.requirements.every(reqId => next.includes(reqId) || reqId === toggleId)
}).filter(id => id !== toggleId)
} else {
const module = modules.find(m => m.id === toggleId)
if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds
return [...currentIds, toggleId]
}
}
function isModuleDisabled(module: ProductModule, selectedModules: string[]): boolean {
const requirementsMet = !module.requirements?.length ||
module.requirements.every(reqId => selectedModules.includes(reqId))
const isExcluded = module.exclusions?.some(exId => selectedModules.includes(exId))
return !requirementsMet || !!isExcluded
}
// ─── Main Wizard ──────────────────────────────────────────────────────────────
export function OrderWizard({
products,
categories,
initialProfile,
}: {
products: Product[]
categories: Category[]
initialProfile: Profile | null
}) {
const [step, setStep] = useState(1)
const [customerData, setCustomerData] = useState<Partial<Profile>>(
initialProfile || {
company_name: '',
vat_id: '',
first_name: '',
last_name: '',
address: '',
city: '',
zip_code: '',
}
)
// Per-category selection map: categoryId -> { productId, moduleIds }
const [selections, setSelections] = useState<Record<string, CategorySelection>>(() => {
const init: Record<string, CategorySelection> = {}
categories.forEach(cat => {
const first = products.find(p => p.category_id === cat.id)
init[cat.id] = { productId: first?.id ?? null, moduleIds: [] }
})
return init
})
// Only REQUIRED categories must have a product selected
const allCategoriesFilled = useMemo(
() => categories.filter(cat => cat.is_required).every(cat => !!selections[cat.id]?.productId),
[categories, selections]
)
// Total price across all selections
const totalPrice = useMemo(() => {
let total = 0
for (const cat of categories) {
const sel = selections[cat.id]
if (!sel?.productId) continue
const prod = products.find(p => p.id === sel.productId)
if (!prod) continue
total += Number(prod.base_price)
sel.moduleIds.forEach(mId => {
const mod = prod.modules?.find(m => m.id === mId)
if (mod) total += Number(mod.price)
})
}
return total
}, [selections, products, categories])
// Helper: update product selection for a category (resets modules)
function selectProduct(catId: string, productId: string) {
setSelections(prev => ({
...prev,
[catId]: { productId, moduleIds: [] },
}))
}
// Helper: toggle module for a category's selected product
function toggleModule(catId: string, moduleId: string) {
setSelections(prev => {
const sel = prev[catId]
if (!sel?.productId) return prev
const prod = products.find(p => p.id === sel.productId)
const newModuleIds = toggleModuleInList(prod?.modules ?? [], sel.moduleIds, moduleId)
return { ...prev, [catId]: { ...sel, moduleIds: newModuleIds } }
})
}
const nextStep = () => setStep(s => s + 1)
const prevStep = () => setStep(s => s - 1)
const handleSubmit = async () => {
try {
await submitOrder({
selections,
products,
categories,
customerProfile: customerData,
})
alert('Bestellung erfolgreich aufgegeben!')
window.location.href = '/'
} catch (error) {
console.error(error)
alert('Fehler bei der Bestellung.')
}
}
return (
<div className="max-w-5xl mx-auto py-12 px-4">
{/* Progress Stepper */}
<div className="flex justify-between mb-12 relative">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-white/10 -translate-y-1/2 z-0" />
{[1, 2, 3].map(s => (
<div
key={s}
className={`relative z-10 w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
step >= s
? 'bg-primary text-white scale-110 shadow-[0_0_15px_rgba(59,130,246,0.5)]'
: 'bg-slate-800 text-slate-400'
}`}
>
{step > s ? <Check className="w-6 h-6" /> : s}
</div>
))}
</div>
<AnimatePresence mode="wait">
{/* ───── Step 1: Product Selection per Category ───── */}
{step === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<div className="grid md:grid-cols-3 gap-6">
{/* Left: Category sections */}
<div className="md:col-span-2 space-y-8">
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2">
<ShoppingCart className="w-6 h-6 text-primary" />
Software wählen
</CardTitle>
<CardDescription className="text-slate-300">
Wählen Sie pro Kategorie mindestens einen Artikel aus.
</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
{categories.map((cat, idx) => {
const catProducts = products.filter(p => p.category_id === cat.id)
const sel = selections[cat.id]
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
return (
<div key={cat.id}>
{idx > 0 && <Separator className="bg-white/10 mb-8" />}
{/* Category header */}
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
</div>
<div>
<h3 className="font-bold text-white text-lg">{cat.name}</h3>
{cat.description && (
<p className="text-slate-400 text-xs">{cat.description}</p>
)}
</div>
{sel?.productId ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30">
<Check className="w-3 h-3 mr-1" /> Ausgewählt
</Badge>
) : cat.is_required ? (
<Badge variant="destructive" className="ml-auto opacity-80">
<AlertCircle className="w-3 h-3 mr-1" /> Pflichtfeld
</Badge>
) : (
<Badge variant="outline" className="ml-auto border-white/20 text-slate-400">
Optional
</Badge>
)}
</div>
{catProducts.length === 0 ? (
<p className="text-slate-500 text-sm italic">
Keine Produkte in dieser Kategorie vorhanden.
</p>
) : (
<RadioGroup
value={sel?.productId ?? ''}
onValueChange={id => selectProduct(cat.id, id)}
className="grid gap-3"
>
{catProducts.map(product => (
<div key={product.id} className="relative">
<RadioGroupItem
value={product.id}
id={`${cat.id}-${product.id}`}
className="peer sr-only"
/>
<Label
htmlFor={`${cat.id}-${product.id}`}
className="flex flex-col items-start p-4 rounded-xl border-2 border-white/5 bg-white/5 hover:bg-white/10 peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5 cursor-pointer transition-all"
>
<div className="flex justify-between w-full items-center">
<div className="flex items-center gap-2">
<span className="font-bold text-base text-white">{product.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)}`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo/Monat'}
</span>
</div>
<span className="text-primary font-semibold text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-xs text-slate-400">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<span className="text-sm text-slate-300 mt-1">{product.description}</span>
)}
{product.modules && product.modules.length > 0 && (
<span className="text-xs text-slate-500 mt-1">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
))}
</RadioGroup>
)}
{/* Modules for selected product in this category */}
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
<div className="mt-4 space-y-3 pl-2 border-l-2 border-primary/30">
<p className="text-sm font-semibold text-white ml-2">Zusatzmodule:</p>
{selectedProduct.modules.map(module => {
const disabled = isModuleDisabled(module, sel?.moduleIds ?? [])
const checked = sel?.moduleIds.includes(module.id) ?? false
return (
<div
key={module.id}
className={`flex items-start space-x-3 p-3 rounded-lg border border-white/5 bg-white/5 ml-2 transition-colors ${disabled ? 'opacity-50' : 'hover:bg-white/10'}`}
>
<Checkbox
id={`mod-${cat.id}-${module.id}`}
checked={checked}
onCheckedChange={() => toggleModule(cat.id, module.id)}
disabled={disabled}
/>
<div className="flex-1">
<Label
htmlFor={`mod-${cat.id}-${module.id}`}
className={`font-medium cursor-pointer flex justify-between text-white ${disabled ? 'cursor-not-allowed' : ''}`}
>
<span>{module.name}</span>
<span className="text-primary font-bold">
+{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price)}
</span>
</Label>
{module.description && (
<p className="text-xs text-slate-400">{module.description}</p>
)}
{disabled && (
<p className="text-[10px] text-destructive mt-1">
{module.requirements?.some(
reqId => !sel?.moduleIds.includes(reqId)
)
? 'Benötigt weitere Module'
: 'Nicht kombinierbar mit aktueller Auswahl'}
</p>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</CardContent>
</Card>
</div>
{/* Right: Summary */}
<div className="space-y-6">
<Card className="glass-dark border-primary/20 sticky top-6">
<CardHeader>
<CardTitle className="text-white">Zusammenfassung</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{categories.map(cat => {
const sel = selections[cat.id]
const prod = products.find(p => p.id === sel?.productId)
if (!prod) return (
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
{cat.is_required ? (
<AlertCircle className="w-3 h-3 text-destructive" />
) : (
<span className="w-3 h-3 inline-block" />
)}
{cat.name}: {cat.is_required ? 'nicht gewählt' : 'nicht gewählt (optional)'}
</div>
)
return (
<div key={cat.id} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-slate-400 font-medium">{cat.name}</span>
</div>
<div className="flex justify-between text-sm pl-2">
<span className="text-white">{prod.name}</span>
<span className="text-white">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(prod.base_price)}
{' '}<span className="text-slate-400 text-xs">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.map(mId => {
const mod = prod.modules?.find(m => m.id === mId)
return mod ? (
<div key={mId} className="flex justify-between text-xs pl-4">
<span className="text-slate-300">+ {mod.name}</span>
<span className="text-slate-300">
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod.price)}
</span>
</div>
) : null
})}
</div>
)
})}
<Separator className="bg-white/10" />
<div className="flex justify-between text-xl font-bold text-gradient">
<span>Gesamt:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)}
</span>
</div>
{!allCategoriesFilled && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Bitte aus jeder Pflichtkategorie einen Artikel wählen.
</p>
)}
</CardContent>
<CardFooter>
<Button
className="w-full h-12 text-lg"
onClick={nextStep}
disabled={!allCategoriesFilled}
>
Weiter <ChevronRight className="ml-2 w-5 h-5" />
</Button>
</CardFooter>
</Card>
</div>
</div>
</motion.div>
)}
{/* ───── Step 2: Customer Data ───── */}
{step === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<Card className="glass-dark border-white/10">
<CardHeader>
<CardTitle className="text-2xl flex items-center gap-2">
<User className="w-6 h-6 text-primary" />
Kundendaten
</CardTitle>
<CardDescription className="text-slate-300">
Wir benötigen Ihre Daten für die Rechnungsstellung.
</CardDescription>
</CardHeader>
<CardContent className="grid md:grid-cols-2 gap-6">
{[
{ label: 'Firmenname', key: 'company_name', placeholder: 'GmbH / Einzelunternehmen', span: false },
{ label: 'USt-IdNr.', key: 'vat_id', placeholder: 'DE123456789', span: false },
{ label: 'Vorname', key: 'first_name', placeholder: '', span: false },
{ label: 'Nachname', key: 'last_name', placeholder: '', span: false },
{ label: 'Straße & Hausnummer', key: 'address', placeholder: '', span: true },
{ label: 'Postleitzahl', key: 'zip_code', placeholder: '', span: false },
{ label: 'Ort', key: 'city', placeholder: '', span: false },
].map(({ label, key, placeholder, span }) => (
<div key={key} className={`space-y-2 ${span ? 'md:col-span-2' : ''}`}>
<Label className="text-white">{label}</Label>
<Input
value={(customerData as any)[key] || ''}
onChange={e => setCustomerData({ ...customerData, [key]: e.target.value })}
placeholder={placeholder}
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500"
/>
</div>
))}
</CardContent>
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
<Button variant="ghost" onClick={prevStep}>
<ChevronLeft className="mr-2 w-4 h-4" /> Zurück
</Button>
<Button onClick={nextStep}>
Prüfung & Abschluss <ChevronRight className="ml-2 w-4 h-4" />
</Button>
</CardFooter>
</Card>
</motion.div>
)}
{/* ───── Step 3: Review & Submit ───── */}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="space-y-6 text-center"
>
<Card className="glass-dark border-primary/30 max-w-2xl mx-auto shadow-primary/10 shadow-2xl">
<CardHeader>
<div className="w-20 h-20 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-primary/50">
<ShieldCheck className="w-10 h-10 text-primary" />
</div>
<CardTitle className="text-3xl text-white">Bestellung prüfen</CardTitle>
<CardDescription className="text-slate-300">
Fast fertig! Bitte überprüfen Sie Ihre Auswahl.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 text-left">
<div className="p-4 rounded-lg bg-white/5 space-y-4">
{categories.map(cat => {
const sel = selections[cat.id]
const prod = products.find(p => p.id === sel?.productId)
if (!prod) return null
const catTotal =
Number(prod.base_price) +
sel.moduleIds.reduce((acc, mId) => {
const mod = prod.modules?.find(m => m.id === mId)
return acc + Number(mod?.price ?? 0)
}, 0)
return (
<div key={cat.id}>
<div className="flex justify-between font-bold text-base text-white">
<span className="flex items-center gap-2">
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
{prod.name}
</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(catTotal)}
{' '}<span className="text-sm font-normal text-slate-400">{billingLabel(prod.billing_interval)}</span>
</span>
</div>
{sel.moduleIds.length > 0 && (
<p className="text-sm text-slate-300 mt-1 pl-6">
Module: {sel.moduleIds
.map(id => prod.modules?.find(m => m.id === id)?.name)
.filter(Boolean)
.join(', ')}
</p>
)}
</div>
)
})}
<Separator className="bg-white/10" />
<div className="text-sm text-slate-300 space-y-1">
<p className="font-semibold text-white">{customerData.company_name}</p>
<p>
{customerData.first_name} {customerData.last_name}
</p>
<p>
{customerData.address}, {customerData.zip_code} {customerData.city}
</p>
</div>
</div>
<div className="flex justify-between text-xl font-bold text-gradient">
<span>Gesamtbetrag:</span>
<span>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat
</span>
</div>
<p className="text-xs text-slate-500 italic">
Mit dem Klick auf Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die
Datenschutzerklärung.
</p>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
onClick={handleSubmit}
>
Kostenpflichtig bestellen
</Button>
<Button variant="ghost" onClick={prevStep} className="w-full">
Noch etwas ändern
</Button>
</CardFooter>
</Card>
</motion.div>
)}
</AnimatePresence>
</div>
)
}