feat: rework order wizard to require one product per category with multi-selection
This commit is contained in:
@@ -10,89 +10,154 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, CreditCard, ShieldCheck, Cloud, Utensils, HardDrive, Package } from 'lucide-react'
|
import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle } from 'lucide-react'
|
||||||
import { submitOrder } from '@/lib/actions/orders'
|
import { submitOrder } from '@/lib/actions/orders'
|
||||||
import { Category } from '@/lib/types'
|
import { Category } from '@/lib/types'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
export function OrderWizard({ products, categories, initialProfile }: { products: Product[], categories: Category[], initialProfile: Profile | null }) {
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
type CategorySelection = {
|
||||||
|
productId: string | null // selected product in this category
|
||||||
|
moduleIds: string[] // selected module IDs for this product
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 [step, setStep] = useState(1)
|
||||||
const [activeCategory, setActiveCategory] = useState<string>(categories[0]?.id || 'all')
|
const [customerData, setCustomerData] = useState<Partial<Profile>>(
|
||||||
const [customerData, setCustomerData] = useState<Partial<Profile>>(initialProfile || {
|
initialProfile || {
|
||||||
company_name: '',
|
company_name: '',
|
||||||
vat_id: '',
|
vat_id: '',
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
address: '',
|
address: '',
|
||||||
city: '',
|
city: '',
|
||||||
zip_code: ''
|
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
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredProducts = useMemo(() => {
|
// All categories must have a product selected
|
||||||
if (activeCategory === 'all') return products
|
const allCategoriesFilled = useMemo(
|
||||||
return products.filter(p => p.category_id === activeCategory)
|
() => categories.every(cat => !!selections[cat.id]?.productId),
|
||||||
}, [products, activeCategory])
|
[categories, selections]
|
||||||
|
)
|
||||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(filteredProducts[0] || null)
|
|
||||||
const [selectedModules, setSelectedModules] = useState<string[]>([])
|
|
||||||
|
|
||||||
|
// Total price across all selections
|
||||||
const totalPrice = useMemo(() => {
|
const totalPrice = useMemo(() => {
|
||||||
if (!selectedProduct) return 0
|
let total = 0
|
||||||
let total = Number(selectedProduct.base_price)
|
for (const cat of categories) {
|
||||||
selectedModules.forEach(modId => {
|
const sel = selections[cat.id]
|
||||||
const mod = selectedProduct.modules?.find(m => m.id === modId)
|
if (!sel?.productId) continue
|
||||||
if (mod) total += Number(mod.price)
|
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
|
return total
|
||||||
}, [selectedProduct, selectedModules])
|
}, [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 nextStep = () => setStep(s => s + 1)
|
||||||
const prevStep = () => setStep(s => s - 1)
|
const prevStep = () => setStep(s => s - 1)
|
||||||
|
|
||||||
const toggleModule = (id: string) => {
|
|
||||||
const module = selectedProduct?.modules?.find(m => m.id === id)
|
|
||||||
if (!module) return
|
|
||||||
|
|
||||||
setSelectedModules(prev => {
|
|
||||||
const isSelected = prev.includes(id)
|
|
||||||
|
|
||||||
if (isSelected) {
|
|
||||||
// When deselecting, we might need to deselect others that depend on this one
|
|
||||||
const newSelection = prev.filter(m => m !== id)
|
|
||||||
return newSelection.filter(mId => {
|
|
||||||
const m = selectedProduct?.modules?.find(mod => mod.id === mId)
|
|
||||||
return !m?.requirements || m.requirements.every(reqId => newSelection.includes(reqId) || reqId === id)
|
|
||||||
}).filter(mId => mId !== id)
|
|
||||||
} else {
|
|
||||||
// When selecting, check exclusions
|
|
||||||
if (module.exclusions && module.exclusions.some(exId => prev.includes(exId))) {
|
|
||||||
return prev // Should be disabled anyway, but safety check
|
|
||||||
}
|
|
||||||
return [...prev, id]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isModuleDisabled = (module: ProductModule) => {
|
|
||||||
// 1. Check requirements: all required modules must be selected
|
|
||||||
const requirementsMet = !module.requirements || module.requirements.length === 0 ||
|
|
||||||
module.requirements.every(reqId => selectedModules.includes(reqId))
|
|
||||||
|
|
||||||
// 2. Check exclusions: no excluded module must be selected
|
|
||||||
const isExcluded = module.exclusions && module.exclusions.some(exId => selectedModules.includes(exId))
|
|
||||||
|
|
||||||
return !requirementsMet || isExcluded
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!selectedProduct) return
|
|
||||||
try {
|
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({
|
await submitOrder({
|
||||||
product_id: selectedProduct.id,
|
product_id: items[0]!.product_id, // primary (first category)
|
||||||
selected_modules: selectedModules,
|
selected_modules: items.flatMap(i => i!.selected_modules),
|
||||||
total_amount: totalPrice,
|
total_amount: totalPrice,
|
||||||
customer_details: customerData
|
customer_details: customerData,
|
||||||
|
// @ts-ignore extended payload
|
||||||
|
items,
|
||||||
})
|
})
|
||||||
alert('Bestellung erfolgreich aufgegeben!')
|
alert('Bestellung erfolgreich aufgegeben!')
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
@@ -103,15 +168,17 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto py-12 px-4">
|
<div className="max-w-5xl mx-auto py-12 px-4">
|
||||||
{/* Progress Stepper */}
|
{/* Progress Stepper */}
|
||||||
<div className="flex justify-between mb-12 relative">
|
<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" />
|
<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) => (
|
{[1, 2, 3].map(s => (
|
||||||
<div
|
<div
|
||||||
key={s}
|
key={s}
|
||||||
className={`relative z-10 w-10 h-10 rounded-full flex items-center justify-center transition-all duration-500 ${
|
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(var(--primary),0.5)]' : 'bg-slate-800 text-slate-400'
|
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}
|
{step > s ? <Check className="w-6 h-6" /> : s}
|
||||||
@@ -120,6 +187,7 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
{/* ───── Step 1: Product Selection per Category ───── */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="step1"
|
key="step1"
|
||||||
@@ -129,152 +197,211 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
<div className="md:col-span-2 space-y-6">
|
{/* Left: Category sections */}
|
||||||
|
<div className="md:col-span-2 space-y-8">
|
||||||
<Card className="glass-dark border-white/10">
|
<Card className="glass-dark border-white/10">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl flex items-center gap-2">
|
<CardTitle className="text-2xl flex items-center gap-2">
|
||||||
<ShoppingCart className="w-6 h-6 text-primary" />
|
<ShoppingCart className="w-6 h-6 text-primary" />
|
||||||
Software wählen
|
Software wählen
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Wählen Sie Ihr Basispaket und optionale Erweiterungen.</CardDescription>
|
<CardDescription className="text-slate-300">
|
||||||
|
Wählen Sie pro Kategorie mindestens einen Artikel aus.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-8">
|
||||||
{/* Category Tabs */}
|
{categories.map((cat, idx) => {
|
||||||
<div className="space-y-4">
|
const catProducts = products.filter(p => p.category_id === cat.id)
|
||||||
<Label className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Kategorie</Label>
|
const sel = selections[cat.id]
|
||||||
<Tabs value={activeCategory} onValueChange={(val) => {
|
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
|
||||||
setActiveCategory(val)
|
|
||||||
// Auto-select first product in new category
|
|
||||||
const firstInCat = products.find(p => val === 'all' || p.category_id === val)
|
|
||||||
if (firstInCat) {
|
|
||||||
setSelectedProduct(firstInCat)
|
|
||||||
setSelectedModules([])
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<TabsList className="bg-white/5 border border-white/10 p-1 h-auto grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
||||||
<TabsTrigger value="all" className="data-[state=active]:bg-primary flex items-center gap-2 py-2">
|
|
||||||
<Package className="w-4 h-4" /> Alle
|
|
||||||
</TabsTrigger>
|
|
||||||
{categories.map(cat => {
|
|
||||||
const Icon = cat.icon === 'Cloud' ? Cloud : cat.icon === 'Utensils' ? Utensils : cat.icon === 'HardDrive' ? HardDrive : Package
|
|
||||||
return (
|
|
||||||
<TabsTrigger key={cat.id} value={cat.id} className="data-[state=active]:bg-primary flex items-center gap-2 py-2">
|
|
||||||
<Icon className="w-4 h-4" /> {cat.name}
|
|
||||||
</TabsTrigger>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="bg-white/10" />
|
return (
|
||||||
|
<div key={cat.id}>
|
||||||
|
{idx > 0 && <Separator className="bg-white/10 mb-8" />}
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Category header */}
|
||||||
<Label className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Lösung</Label>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<RadioGroup
|
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||||
value={selectedProduct?.id}
|
<CategoryIcon icon={cat.icon} className="w-4 h-4 text-primary" />
|
||||||
onValueChange={(id) => {
|
|
||||||
const p = products.find(prod => prod.id === id)
|
|
||||||
if (p) {
|
|
||||||
setSelectedProduct(p)
|
|
||||||
setSelectedModules([]) // Reset modules when product changes
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="grid gap-4"
|
|
||||||
>
|
|
||||||
{filteredProducts.map(product => (
|
|
||||||
<div key={product.id} className="relative">
|
|
||||||
<RadioGroupItem value={product.id} id={product.id} className="peer sr-only" />
|
|
||||||
<Label
|
|
||||||
htmlFor={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">
|
|
||||||
<span className="font-bold text-lg">{product.name}</span>
|
|
||||||
{product.category && (
|
|
||||||
<Badge variant="outline" className="text-[10px] uppercase border-primary/30 text-primary">
|
|
||||||
{product.category.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground">{product.description}</span>
|
|
||||||
<span className="mt-2 text-primary font-semibold">
|
|
||||||
Ab {new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(product.base_price)}
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedProduct && selectedProduct.modules && selectedProduct.modules.length > 0 && (
|
|
||||||
<div className="space-y-4 pt-4">
|
|
||||||
<Label className="text-lg font-semibold">Zusatzmodule</Label>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{selectedProduct.modules.map(module => (
|
|
||||||
<div
|
|
||||||
key={module.id}
|
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-white/5 bg-white/5 hover:bg-white/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={module.id}
|
|
||||||
checked={selectedModules.includes(module.id)}
|
|
||||||
onCheckedChange={() => toggleModule(module.id)}
|
|
||||||
disabled={isModuleDisabled(module)}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label
|
|
||||||
htmlFor={module.id}
|
|
||||||
className={`font-medium cursor-pointer flex justify-between ${isModuleDisabled(module) ? 'opacity-50' : ''}`}
|
|
||||||
>
|
|
||||||
<span>{module.name}</span>
|
|
||||||
<span className="text-primary">+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(module.price)}</span>
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">{module.description}</p>
|
|
||||||
{isModuleDisabled(module) && (
|
|
||||||
<p className="text-[10px] text-destructive mt-1">
|
|
||||||
{module.requirements && module.requirements.some(reqId => !selectedModules.includes(reqId))
|
|
||||||
? "Benötigt weitere Module"
|
|
||||||
: "Nicht kombinierbar mit aktueller Auswahl"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
||||||
|
) : (
|
||||||
|
<Badge variant="destructive" className="ml-auto opacity-80">
|
||||||
|
<AlertCircle className="w-3 h-3 mr-1" /> Pflichtfeld
|
||||||
|
</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">
|
||||||
|
<span className="font-bold text-base text-white">{product.name}</span>
|
||||||
|
<span className="text-primary font-semibold text-sm">
|
||||||
|
{new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
}).format(product.base_price)}
|
||||||
|
</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>
|
</div>
|
||||||
</div>
|
)
|
||||||
)}
|
})}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Summary */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="glass-dark border-primary/20 sticky top-6">
|
<Card className="glass-dark border-primary/20 sticky top-6">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Zusammenfassung</CardTitle>
|
<CardTitle className="text-white">Zusammenfassung</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex justify-between text-sm">
|
{categories.map(cat => {
|
||||||
<span className="text-muted-foreground">Basispreis:</span>
|
const sel = selections[cat.id]
|
||||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(selectedProduct?.base_price || 0)}</span>
|
const prod = products.find(p => p.id === sel?.productId)
|
||||||
</div>
|
if (!prod) return (
|
||||||
{selectedModules.map(modId => {
|
<div key={cat.id} className="text-xs text-slate-500 italic flex items-center gap-1">
|
||||||
const mod = selectedProduct?.modules?.find(m => m.id === modId)
|
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||||
|
{cat.name}: nicht gewählt
|
||||||
|
</div>
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div key={modId} className="flex justify-between text-sm">
|
<div key={cat.id} className="space-y-1">
|
||||||
<span className="text-muted-foreground">{mod?.name}:</span>
|
<div className="flex justify-between text-sm">
|
||||||
<span>+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(mod?.price || 0)}</span>
|
<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>
|
||||||
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<Separator className="bg-white/10" />
|
<Separator className="bg-white/10" />
|
||||||
<div className="flex justify-between text-xl font-bold text-gradient">
|
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||||
<span>Gesamt:</span>
|
<span>Gesamt:</span>
|
||||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)}</span>
|
<span>
|
||||||
|
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{!allCategoriesFilled && (
|
||||||
|
<p className="text-xs text-destructive flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
Bitte aus jeder Kategorie einen Artikel wählen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button className="w-full h-12 text-lg" onClick={nextStep} disabled={!selectedProduct}>
|
<Button
|
||||||
|
className="w-full h-12 text-lg"
|
||||||
|
onClick={nextStep}
|
||||||
|
disabled={!allCategoriesFilled}
|
||||||
|
>
|
||||||
Weiter <ChevronRight className="ml-2 w-5 h-5" />
|
Weiter <ChevronRight className="ml-2 w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
@@ -284,6 +411,7 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───── Step 2: Customer Data ───── */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="step2"
|
key="step2"
|
||||||
@@ -298,67 +426,30 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
<User className="w-6 h-6 text-primary" />
|
<User className="w-6 h-6 text-primary" />
|
||||||
Kundendaten
|
Kundendaten
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Wir benötigen Ihre Daten für die Rechnungsstellung.</CardDescription>
|
<CardDescription className="text-slate-300">
|
||||||
|
Wir benötigen Ihre Daten für die Rechnungsstellung.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid md:grid-cols-2 gap-6">
|
<CardContent className="grid md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
{[
|
||||||
<Label>Firmenname</Label>
|
{ label: 'Firmenname', key: 'company_name', placeholder: 'GmbH / Einzelunternehmen', span: false },
|
||||||
<Input
|
{ label: 'USt-IdNr.', key: 'vat_id', placeholder: 'DE123456789', span: false },
|
||||||
value={customerData.company_name || ''}
|
{ label: 'Vorname', key: 'first_name', placeholder: '', span: false },
|
||||||
onChange={(e) => setCustomerData({...customerData, company_name: e.target.value})}
|
{ label: 'Nachname', key: 'last_name', placeholder: '', span: false },
|
||||||
placeholder="GmbH / Einzelunternehmen"
|
{ label: 'Straße & Hausnummer', key: 'address', placeholder: '', span: true },
|
||||||
className="bg-white/5 border-white/10"
|
{ label: 'Postleitzahl', key: 'zip_code', placeholder: '', span: false },
|
||||||
/>
|
{ label: 'Ort', key: 'city', placeholder: '', span: false },
|
||||||
</div>
|
].map(({ label, key, placeholder, span }) => (
|
||||||
<div className="space-y-2">
|
<div key={key} className={`space-y-2 ${span ? 'md:col-span-2' : ''}`}>
|
||||||
<Label>USt-IdNr.</Label>
|
<Label className="text-white">{label}</Label>
|
||||||
<Input
|
<Input
|
||||||
value={customerData.vat_id || ''}
|
value={(customerData as any)[key] || ''}
|
||||||
onChange={(e) => setCustomerData({...customerData, vat_id: e.target.value})}
|
onChange={e => setCustomerData({ ...customerData, [key]: e.target.value })}
|
||||||
placeholder="DE123456789"
|
placeholder={placeholder}
|
||||||
className="bg-white/5 border-white/10"
|
className="bg-white/5 border-white/10 text-white placeholder:text-slate-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
))}
|
||||||
<Label>Vorname</Label>
|
|
||||||
<Input
|
|
||||||
value={customerData.first_name || ''}
|
|
||||||
onChange={(e) => setCustomerData({...customerData, first_name: e.target.value})}
|
|
||||||
className="bg-white/5 border-white/10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Nachname</Label>
|
|
||||||
<Input
|
|
||||||
value={customerData.last_name || ''}
|
|
||||||
onChange={(e) => setCustomerData({...customerData, last_name: e.target.value})}
|
|
||||||
className="bg-white/5 border-white/10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-2 space-y-2">
|
|
||||||
<Label>Straße & Hausnummer</Label>
|
|
||||||
<Input
|
|
||||||
value={customerData.address || ''}
|
|
||||||
onChange={(e) => setCustomerData({...customerData, address: e.target.value})}
|
|
||||||
className="bg-white/5 border-white/10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Postleitzahl</Label>
|
|
||||||
<Input
|
|
||||||
value={customerData.zip_code || ''}
|
|
||||||
onChange={(e) => setCustomerData({...customerData, zip_code: e.target.value})}
|
|
||||||
className="bg-white/5 border-white/10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Ort</Label>
|
|
||||||
<Input
|
|
||||||
value={customerData.city || ''}
|
|
||||||
onChange={(e) => setCustomerData({...customerData, city: e.target.value})}
|
|
||||||
className="bg-white/5 border-white/10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
|
<CardFooter className="flex justify-between border-t border-white/10 pt-6">
|
||||||
<Button variant="ghost" onClick={prevStep}>
|
<Button variant="ghost" onClick={prevStep}>
|
||||||
@@ -372,6 +463,7 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ───── Step 3: Review & Submit ───── */}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="step3"
|
key="step3"
|
||||||
@@ -385,29 +477,72 @@ export function OrderWizard({ products, categories, initialProfile }: { products
|
|||||||
<div className="w-20 h-20 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4 border border-primary/50">
|
<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" />
|
<ShieldCheck className="w-10 h-10 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-3xl">Bestellung prüfen</CardTitle>
|
<CardTitle className="text-3xl text-white">Bestellung prüfen</CardTitle>
|
||||||
<CardDescription>Fast fertig! Bitte überprüfen Sie Ihre Auswahl.</CardDescription>
|
<CardDescription className="text-slate-300">
|
||||||
|
Fast fertig! Bitte überprüfen Sie Ihre Auswahl.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6 text-left">
|
<CardContent className="space-y-6 text-left">
|
||||||
<div className="p-4 rounded-lg bg-white/5 space-y-4">
|
<div className="p-4 rounded-lg bg-white/5 space-y-4">
|
||||||
<div className="flex justify-between font-bold text-lg">
|
{categories.map(cat => {
|
||||||
<span>{selectedProduct?.name}</span>
|
const sel = selections[cat.id]
|
||||||
<span>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(totalPrice)} / Monat</span>
|
const prod = products.find(p => p.id === sel?.productId)
|
||||||
</div>
|
if (!prod) return null
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
const catTotal =
|
||||||
<p>Inklusive: {selectedModules.length > 0 ? selectedModules.map(id => selectedProduct?.modules?.find(m => m.id === id)?.name).join(', ') : 'Keine Zusatzmodule'}</p>
|
Number(prod.base_price) +
|
||||||
<Separator className="bg-white/10 my-2" />
|
sel.moduleIds.reduce((acc, mId) => {
|
||||||
<p>{customerData.company_name}</p>
|
const mod = prod.modules?.find(m => m.id === mId)
|
||||||
<p>{customerData.address}, {customerData.zip_code} {customerData.city}</p>
|
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>
|
||||||
|
</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>
|
</div>
|
||||||
|
<div className="flex justify-between text-xl font-bold text-gradient">
|
||||||
<p className="text-xs text-muted-foreground italic">
|
<span>Gesamtbetrag:</span>
|
||||||
Mit dem Klick auf "Kostenpflichtig bestellen" akzeptieren Sie unsere AGB und die Datenschutzerklärung.
|
<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>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col gap-4">
|
<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}>
|
<Button
|
||||||
|
className="w-full h-14 text-xl font-bold bg-primary hover:bg-primary/90"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
Kostenpflichtig bestellen
|
Kostenpflichtig bestellen
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={prevStep} className="w-full">
|
<Button variant="ghost" onClick={prevStep} className="w-full">
|
||||||
|
|||||||
Reference in New Issue
Block a user