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

588 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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'
if (interval === 'yearly') return '/ Jahr'
return '/ Monat'
}
function billingBadgeClass(interval?: string) {
if (interval === 'one_time') return 'bg-slate-500/20 text-slate-300 border-slate-500/30'
if (interval === 'yearly') return 'bg-purple-500/20 text-purple-400 border-purple-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 {
// Build multi-product order payload submit each category item
const items = categories
.map(cat => {
const sel = selections[cat.id]
if (!sel?.productId) return null
const prod = products.find(p => p.id === sel.productId)!
return {
category: cat.name,
product_id: sel.productId,
product_name: prod.name,
base_price: prod.base_price,
selected_modules: sel.moduleIds,
}
})
.filter(Boolean)
await submitOrder({
product_id: items[0]!.product_id, // primary (first category)
selected_modules: items.flatMap(i => i!.selected_modules),
total_amount: totalPrice,
customer_details: customerData,
// @ts-ignore extended payload
items,
})
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' : product.billing_interval === 'yearly' ? 'Abo/Jahr' : '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>
)
}