Files
webshop/shop/components/wizard/step-software.tsx
DanielS b692e6d4cf Merge branch 'staging' of https://gitea.hephex.de/admin/webshop into staging
# Conflicts:
#	shop/lib/actions/email-templates.ts
2026-08-27 00:34:39 +02:00

372 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import React from 'react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ShoppingCart, Check, AlertCircle, Lock } from 'lucide-react'
import * as Icons from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { Category, Product, CategorySelection } from '@/lib/types'
interface StepSoftwareProps {
visibleCategories: Category[]
products: Product[]
selections: Record<string, CategorySelection>
selectProduct: (catId: string, prodId: string) => void
isProductDisabled: (prod: Product, catId: string) => boolean
isModuleDisabled: (mod: any, selectedModIds: string[]) => boolean
toggleModule: (catId: string, modId: string) => void
moduleQuantities: Record<string, number>
setModuleQuantities: React.Dispatch<React.SetStateAction<Record<string, number>>>
selectedBillingInterval: 'one_time' | 'monthly'
billingLabel: (interval: string) => string
billingBadgeClass: (interval: string) => string
/** Modul-IDs, die bereits lizenziert sind (Upgrade-Modus) */
existingModuleIds?: string[]
activeCategoryId: string | null
editingDeviceName?: string | null
}
export function CategoryIcon({ icon, className }: { icon?: string | null; className?: string }) {
const LucideIcon = icon ? (Icons as any)[icon] : null
if (!LucideIcon) return <Icons.HelpCircle className={className} />
return <LucideIcon className={className} />
}
export function StepSoftware({
visibleCategories,
products,
selections,
selectProduct,
isProductDisabled,
isModuleDisabled,
toggleModule,
moduleQuantities,
setModuleQuantities,
selectedBillingInterval,
billingLabel,
billingBadgeClass,
existingModuleIds = [],
activeCategoryId,
editingDeviceName = null,
}: StepSoftwareProps) {
const currentCategory = visibleCategories.find(c => c.id === activeCategoryId) ?? visibleCategories[0] ?? null
if (!currentCategory) return null
const catProducts = products.filter(p => {
if (p.category_id !== currentCategory.id) return false
return selectedBillingInterval === 'one_time'
? p.show_in_kauf !== false
: p.show_in_abo !== false
})
const sel = selections[currentCategory.id]
const selectedProduct = catProducts.find(p => p.id === sel?.productId) ?? null
return (
<Card className="glass-dark border-white/10 h-full flex flex-col rounded-2xl">
<CardHeader className="border-b border-white/5 pb-4">
<CardTitle className="text-xl flex items-center justify-between gap-2 text-white font-bold flex-wrap">
<div className="flex items-center gap-2">
<ShoppingCart className="w-5 h-5 text-primary" />
<span>Optionen wählen</span>
</div>
{editingDeviceName && (
<Badge className="bg-amber-500/20 text-amber-400 border border-amber-500/30 text-xs font-bold gap-1 px-2.5 py-1 rounded-lg animate-pulse shrink-0">
Bearbeite Kasse: &quot;{editingDeviceName}&quot;
</Badge>
)}
</CardTitle>
<CardDescription className="text-slate-400">
Passen Sie die Konfiguration für {currentCategory.name} an.
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pt-6 space-y-6">
<AnimatePresence mode="wait">
<motion.div
key={currentCategory.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="space-y-6"
>
{/* Category header */}
{(() => {
const isMissingRequired = currentCategory.is_required && !sel?.productId && (!sel?.productIds || sel.productIds.length === 0)
return (
<div className={`flex items-center gap-3 p-4 rounded-xl border transition-all ${
isMissingRequired
? 'bg-red-500/10 border-red-500/50 text-red-400 font-bold'
: 'bg-white/5 border-white/10'
}`}>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
isMissingRequired ? 'bg-red-500/20 text-red-400' : 'bg-primary/20 text-primary'
}`}>
<CategoryIcon icon={currentCategory.icon} className="w-4 h-4" />
</div>
<div>
<h3 className="font-bold text-white text-sm flex items-center gap-2">
{currentCategory.name}
{isMissingRequired && (
<span className="text-[10px] bg-red-500/20 text-red-400 border border-red-500/40 px-2 py-0.5 rounded-full font-extrabold animate-pulse tracking-wider">
PFLICHTFELD AUSWÄHLEN!
</span>
)}
</h3>
{currentCategory.description && (
<p className="text-slate-400 text-xs mt-0.5">{currentCategory.description}</p>
)}
</div>
{sel?.productIds && sel.productIds.length > 0 ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30 text-xs rounded-full">
<Check className="w-3 h-3 mr-1" /> {sel.productIds.length} Ausgewählt
</Badge>
) : sel?.productId ? (
<Badge className="ml-auto bg-green-500/20 text-green-400 border border-green-500/30 text-xs rounded-full">
<Check className="w-3 h-3 mr-1" /> Ausgewählt
</Badge>
) : currentCategory.is_required ? (
<Badge variant="destructive" className="ml-auto opacity-90 text-xs rounded-full bg-red-500/30 text-red-400 border border-red-500/50 animate-pulse font-extrabold">
<AlertCircle className="w-3 h-3 mr-1 text-red-400" /> Bitte auswählen!
</Badge>
) : (
<Badge variant="outline" className="ml-auto border-white/10 text-slate-400 text-xs rounded-full bg-white/5">
Optional
</Badge>
)}
</div>
)
})()}
{catProducts.length === 0 ? (
<p className="text-slate-500 text-sm italic">
Keine Produkte in dieser Kategorie im aktuellen Abrechnungsmodell vorhanden.
</p>
) : currentCategory.allow_multiselect ? (
<div className="grid gap-3">
{catProducts.map(product => {
const isChecked = sel?.productIds?.includes(product.id) ?? false
const disabled = isProductDisabled(product, currentCategory.id)
return (
<div key={product.id} className="relative">
<Label
onClick={() => !disabled && selectProduct(currentCategory.id, product.id)}
className={`flex flex-col items-start p-4 rounded-xl border transition-all ${disabled
? 'border-white/5 bg-white/5 opacity-40 cursor-not-allowed'
: isChecked
? 'border-primary bg-primary/10 cursor-pointer shadow-[0_0_15px_rgba(59,130,246,0.1)]'
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
}`}
>
<div className="flex justify-between w-full items-center">
<div className="flex items-center gap-3">
<Checkbox
checked={isChecked}
disabled={disabled}
onCheckedChange={() => { }}
className="border-white/20 data-[state=checked]:bg-primary rounded"
/>
<span className="font-bold text-sm text-white">{product.name}</span>
<span className={`text-[9px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)} font-medium`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo'}
</span>
</div>
<span className="text-primary font-bold text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-[10px] text-slate-500 font-normal">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<span className="text-xs text-slate-400 mt-2 pl-7 font-normal leading-relaxed">{product.description}</span>
)}
{product.modules && product.modules.length > 0 && (
<span className="text-[10px] text-slate-500 mt-1 pl-7 font-normal">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</div>
) : (
<RadioGroup
value={sel?.productId ?? ''}
onValueChange={id => selectProduct(currentCategory.id, id)}
className="grid gap-3"
>
{catProducts.map(product => {
const disabled = isProductDisabled(product, currentCategory.id)
const isChecked = sel?.productId === product.id
return (
<div key={product.id} className="relative">
<RadioGroupItem
value={product.id}
id={`${currentCategory.id}-${product.id}`}
disabled={disabled}
className="peer sr-only"
/>
<Label
htmlFor={disabled ? undefined : `${currentCategory.id}-${product.id}`}
className={`flex flex-col items-start p-4 rounded-xl border transition-all ${disabled
? 'border-white/5 bg-white/5 opacity-40 cursor-not-allowed'
: isChecked
? 'border-primary bg-primary/10 cursor-pointer shadow-[0_0_15px_rgba(59,130,246,0.1)]'
: 'border-white/5 bg-white/5 hover:bg-white/10 cursor-pointer'
}`}
>
<div className="flex justify-between w-full items-center">
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-white">{product.name}</span>
<span className={`text-[9px] px-1.5 py-0.5 rounded border ${billingBadgeClass(product.billing_interval)} font-medium`}>
{product.billing_interval === 'one_time' ? 'Einmalig' : 'Abo'}
</span>
</div>
<span className="text-primary font-bold text-sm">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(product.base_price)}{' '}
<span className="text-[10px] text-slate-500 font-normal">{billingLabel(product.billing_interval)}</span>
</span>
</div>
{product.description && (
<span className="text-xs text-slate-400 mt-2 font-normal leading-relaxed">{product.description}</span>
)}
{product.modules && product.modules.length > 0 && (
<span className="text-[10px] text-slate-500 mt-1 font-normal">
{product.modules.length} optionale Module verfügbar
</span>
)}
</Label>
</div>
)
})}
</RadioGroup>
)}
{/* Modules */}
{selectedProduct?.modules && selectedProduct.modules.length > 0 && (
<div className="mt-6 p-5 rounded-2xl bg-white/5 border border-white/10 space-y-4">
<p className="text-sm font-bold text-white flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
Zusatzmodule für {selectedProduct.name}
</p>
<div className="space-y-2.5">
{selectedProduct.modules.map(module => {
const isExistingLicense = existingModuleIds.includes(module.id)
const disabled = isExistingLicense || isModuleDisabled(module, sel?.moduleIds ?? [])
const checked = isExistingLicense || (sel?.moduleIds.includes(module.id) ?? false)
return (
<div
key={module.id}
title={isExistingLicense ? "Dieses Modul ist auf dieser Kasse bereits aktiv und dauerhaft lizenziert." : undefined}
className={`flex flex-col p-4 rounded-xl border transition-all duration-200 ${
isExistingLicense
? 'border-primary/20 bg-primary/5 opacity-75'
: disabled
? 'border-white/5 bg-white/5 opacity-40'
: checked
? 'border-primary bg-primary/10 shadow-[0_0_15px_rgba(59,130,246,0.15)] text-white'
: 'border-white/5 bg-white/5 hover:bg-white/10'
}`}
>
<div className="flex items-start space-x-3">
<Checkbox
id={`mod-${currentCategory.id}-${module.id}`}
checked={checked}
onCheckedChange={() => !isExistingLicense && toggleModule(currentCategory.id, module.id)}
disabled={disabled}
className="rounded border-white/20 data-[state=checked]:bg-primary"
/>
<div className="flex-1">
<Label
htmlFor={isExistingLicense ? undefined : `mod-${currentCategory.id}-${module.id}`}
className={`font-semibold text-sm flex justify-between text-white ${
isExistingLicense ? 'cursor-default' : disabled ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
>
<span className="flex items-center gap-1.5 flex-wrap">
{module.name}
{isExistingLicense && (
<span className="inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-wider">
<Lock className="w-2.5 h-2.5" /> Bereits lizenziert
</span>
)}
</span>
<span className="text-primary font-bold text-sm">
{isExistingLicense ? (
<span className="text-slate-500 text-xs font-normal">inkl.</span>
) : (
<>+{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price)}</>
)}
</span>
</Label>
{module.description && (
<p className="text-xs text-slate-400 mt-1 leading-relaxed">{module.description}</p>
)}
{!isExistingLicense && disabled && (
<p className="text-[10px] text-red-400 mt-1.5 font-medium flex items-center gap-1">
<AlertCircle className="w-3 h-3 text-red-400 shrink-0" />
{module.requirements?.length && !module.requirements.some(
reqId => sel?.moduleIds.includes(reqId)
)
? 'Benötigt weitere Module'
: 'Nicht kombinierbar mit aktueller Auswahl'}
</p>
)}
</div>
</div>
{/* Scalable Quantity */}
{checked && !isExistingLicense && module.has_quantity && (
<div className="flex items-center gap-3 mt-4 pl-8 pt-3 border-t border-white/5">
<Label htmlFor={`qty-${module.id}`} className="text-xs text-slate-400 font-medium">Menge:</Label>
<Input
id={`qty-${module.id}`}
type="number"
min={1}
max={999}
value={moduleQuantities[module.id] || 1}
onChange={(e) => {
const val = Math.max(1, parseInt(e.target.value) || 1)
setModuleQuantities(prev => ({ ...prev, [module.id]: val }))
}}
className="w-16 h-8 bg-white/5 border-white/10 text-white text-xs text-center rounded-lg focus:border-primary focus:ring-1 focus:ring-primary"
/>
<span className="text-xs text-slate-400">
Gesamt:{' '}
<span className="text-primary font-bold">
{new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(module.price * (moduleQuantities[module.id] || 1))}
</span>
</span>
</div>
)}
</div>
)
})}
</div>
</div>
)}
</motion.div>
</AnimatePresence>
</CardContent>
</Card>
)
}