Files
webshop/shop/app/admin/wysiwyg/wysiwyg-client.tsx
DanielS d05274700a
All checks were successful
Staging Build / build (push) Successful in 2m46s
feat(admin): extend wysiwyg live editor
- Add is_required and allow_multiselect category toggles

- Implement module-popover for product module assignment

- Add cascade-protected deletion for categories and products

- Add sidebar link with AdminNavLink active states
2026-07-09 15:52:47 +02:00

325 lines
14 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, useTransition } from 'react'
import { Category, Product, ProductModule } from '@/lib/types'
import { updateProduct, createProduct, updateCategory, createCategory, deleteProduct, deleteCategory } from '@/lib/actions/products'
import { InlineInput } from './components/inline-input'
import { ModulePopover } from './components/module-popover'
import { Trash2 } from 'lucide-react'
export function WysiwygAdminClient({
initialCategories,
initialProducts,
allModules
}: {
initialCategories: Category[]
initialProducts: Product[]
allModules: ProductModule[]
}) {
const [mode, setMode] = useState<'purchase' | 'subscription'>('purchase')
const [categories, setCategories] = useState(initialCategories)
const [products, setProducts] = useState(initialProducts)
const [isPending, startTransition] = useTransition()
const handleDeleteCategory = async (catId: string) => {
// Cascade-Check: check if there are products belonging to this category
const categoryProducts = products.filter(p => p.category_id === catId)
if (categoryProducts.length > 0) {
alert(`Diese Kategorie enthält noch ${categoryProducts.length} Produkte. Bitte lösche oder verschiebe diese zuerst!`)
return
}
if (!window.confirm('Möchten Sie diese Kategorie wirklich löschen?')) {
return
}
startTransition(async () => {
try {
await deleteCategory(catId)
setCategories(categories.filter(c => c.id !== catId))
} catch (e) {
console.error(e)
alert('Fehler beim Löschen der Kategorie.')
}
})
}
const handleDeleteProduct = async (prodId: string) => {
if (!window.confirm('Möchten Sie dieses Produkt wirklich löschen?')) {
return
}
startTransition(async () => {
try {
await deleteProduct(prodId)
setProducts(products.filter(p => p.id !== prodId))
} catch (e) {
console.error(e)
alert('Fehler beim Löschen des Produkts.')
}
})
}
// Filterung der Kategorien nach aktivem Modus
const filteredCategories = categories.filter(cat =>
mode === 'purchase' ? cat.show_in_kauf : cat.show_in_abo
)
// Filterung der Produkte nach aktivem Modus
const filteredProducts = products.filter(p =>
mode === 'purchase' ? p.billing_interval === 'one_time' : p.billing_interval === 'monthly'
)
const handleAddCategory = async () => {
startTransition(async () => {
try {
const newCat = await createCategory({
name: 'Neue Kategorie',
description: 'Beschreibung hier...',
sort_order: categories.length + 1,
is_required: false,
allow_multiselect: false,
free_items_limit: 0,
preselect: false,
show_in_kauf: mode === 'purchase',
show_in_abo: mode === 'subscription'
})
setCategories([...categories, newCat])
} catch (e) {
console.error(e)
}
})
}
const handleAddProduct = async (categoryId: string) => {
startTransition(async () => {
try {
const newProd = await createProduct({
name: 'Neues Produkt',
description: 'Produktbeschreibung...',
base_price: 19.99,
tax_rate: 19,
billing_interval: mode === 'purchase' ? 'one_time' : 'monthly',
category_id: categoryId,
show_in_kauf: mode === 'purchase',
show_in_abo: mode === 'subscription'
}, [])
setProducts([...products, newProd])
} catch (e) {
console.error(e)
}
})
}
const handleCategoryToggle = async (catId: string, fields: Partial<Category>) => {
startTransition(async () => {
try {
setCategories(categories.map(c => c.id === catId ? { ...c, ...fields } : c))
await updateCategory(catId, fields)
} catch (e) {
console.error(e)
}
})
}
return (
<div className={`p-6 max-w-7xl mx-auto space-y-6 ${isPending ? 'opacity-70 pointer-events-none' : ''}`}>
<div className="flex justify-between items-center border-b border-white/10 pb-4">
<div className="flex space-x-4">
<button
onClick={() => setMode('purchase')}
className={`px-4 py-2 rounded-lg font-medium transition ${
mode === 'purchase' ? 'bg-primary text-black' : 'bg-white/5 text-white hover:bg-white/10'
}`}
>
Kauf-Modus (Einmalig)
</button>
<button
onClick={() => setMode('subscription')}
className={`px-4 py-2 rounded-lg font-medium transition ${
mode === 'subscription' ? 'bg-primary text-black' : 'bg-white/5 text-white hover:bg-white/10'
}`}
>
Abo-Modus (Monatlich)
</button>
</div>
{isPending && <span className="text-xs text-primary animate-pulse font-medium">Speichere Änderungen...</span>}
</div>
<div className="space-y-8">
{filteredCategories
.sort((a, b) => a.sort_order - b.sort_order)
.map(cat => {
const catProducts = filteredProducts.filter(p => p.category_id === cat.id)
return (
<div key={cat.id} className="p-6 bg-white/5 border border-white/10 rounded-2xl space-y-4 shadow-xl">
<div className="flex justify-between items-start border-b border-white/5 pb-4">
<div className="w-full">
<InlineInput
value={cat.name}
onSave={async (newName) => {
await updateCategory(cat.id, { name: newName })
setCategories(categories.map(c => c.id === cat.id ? { ...c, name: newName } : c))
}}
className="text-2xl font-bold text-white block w-full"
/>
<InlineInput
value={cat.description || ''}
onSave={async (newDesc) => {
await updateCategory(cat.id, { description: newDesc })
setCategories(categories.map(c => c.id === cat.id ? { ...c, description: newDesc } : c))
}}
className="text-slate-400 text-sm mt-1 block w-full"
/>
{/* Kategorie Attribute (Toggles) */}
<div className="flex items-center gap-6 mt-3 text-xs text-slate-400">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={cat.is_required}
onChange={(e) => handleCategoryToggle(cat.id, { is_required: e.target.checked })}
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
/>
<span>Pflichtauswahl (is_required)</span>
</label>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={cat.allow_multiselect}
onChange={(e) => handleCategoryToggle(cat.id, { allow_multiselect: e.target.checked })}
className="rounded border-white/10 bg-white/5 text-primary focus:ring-0 focus:ring-offset-0"
/>
<span>Mehrfachauswahl (allow_multiselect)</span>
</label>
</div>
</div>
<button
onClick={() => handleDeleteCategory(cat.id)}
className="p-2 text-slate-500 hover:text-red-500 hover:bg-white/5 rounded-lg transition-all ml-4 flex-shrink-0"
title="Kategorie löschen"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
{catProducts.map(prod => (
<div key={prod.id} className="p-4 bg-white/5 border border-white/10 rounded-xl space-y-3 relative group flex flex-col justify-between">
<div className="space-y-2">
<div className="flex justify-between items-start gap-2">
<div className="flex-1">
<InlineInput
value={prod.name}
onSave={async (newName) => {
await updateProduct(prod.id, { name: newName }, prod.modules || [])
setProducts(products.map(p => p.id === prod.id ? { ...p, name: newName } : p))
}}
className="font-semibold text-white block"
/>
</div>
<button
onClick={() => handleDeleteProduct(prod.id)}
className="p-1 text-slate-500 hover:text-red-500 hover:bg-white/5 rounded transition-all opacity-0 group-hover:opacity-100 flex-shrink-0"
title="Produkt löschen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<InlineInput
value={prod.description || ''}
onSave={async (newDesc) => {
await updateProduct(prod.id, { description: newDesc }, prod.modules || [])
setProducts(products.map(p => p.id === prod.id ? { ...p, description: newDesc } : p))
}}
className="text-xs text-slate-400 block"
/>
<div className="text-primary font-mono text-sm flex items-center space-x-1">
<InlineInput
value={prod.base_price.toString()}
type="number"
onSave={async (newPrice) => {
const priceNum = parseFloat(newPrice) || 0
await updateProduct(prod.id, { base_price: priceNum }, prod.modules || [])
setProducts(products.map(p => p.id === prod.id ? { ...p, base_price: priceNum } : p))
}}
className="inline-block"
/>
<span></span>
</div>
{/* Modul-Badges */}
<div className="space-y-1">
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold block">Module:</span>
<div className="flex flex-wrap gap-1">
{(prod.modules || []).length > 0 ? (
(prod.modules || []).map((m) => (
<span key={m.id} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300 font-medium">
{m.name}
<button
onClick={async () => {
const updatedModules = (prod.modules || []).filter(mod => mod.id !== m.id)
setProducts(products.map(p => p.id === prod.id ? { ...p, modules: updatedModules } : p))
await updateProduct(prod.id, {}, updatedModules)
}}
className="text-slate-500 hover:text-white font-bold transition-colors ml-0.5"
title="Modul entfernen"
>
×
</button>
</span>
))
) : (
<span className="text-[10px] text-slate-500 italic">Keine Module</span>
)}
</div>
</div>
</div>
{/* Modul hinzufügen Popover */}
<div className="mt-3 pt-2 border-t border-white/5">
<ModulePopover
assignedModules={prod.modules || []}
allModules={allModules}
onAddModule={async (mod) => {
const newMod = {
name: mod.name,
description: mod.description,
price: mod.price,
requirements: mod.requirements || [],
exclusions: mod.exclusions || [],
has_quantity: mod.has_quantity || false
}
const updatedModules = [...(prod.modules || []), newMod]
setProducts(products.map(p => p.id === prod.id ? { ...p, modules: updatedModules as any } : p))
await updateProduct(prod.id, {}, updatedModules)
}}
/>
</div>
</div>
))}
<button
onClick={() => handleAddProduct(cat.id)}
className="flex flex-col items-center justify-center p-4 border border-dashed border-white/20 hover:border-primary/50 rounded-xl text-slate-400 hover:text-primary transition h-full min-h-[140px]"
>
<span className="text-2xl mb-1">+</span>
<span className="text-xs font-medium">Produkt hinzufügen</span>
</button>
</div>
</div>
)
})}
<button
onClick={handleAddCategory}
className="w-full py-4 border border-dashed border-white/20 hover:border-primary/50 rounded-2xl flex items-center justify-center text-slate-400 hover:text-primary transition"
>
<span className="text-xl mr-2">+</span>
<span className="font-medium">Neue Kategorie hinzufügen</span>
</button>
</div>
</div>
)
}