diff --git a/shop/app/admin/layout.tsx b/shop/app/admin/layout.tsx index 4c0c367..46da5c5 100644 --- a/shop/app/admin/layout.tsx +++ b/shop/app/admin/layout.tsx @@ -1,9 +1,10 @@ import Link from 'next/link' -import { LayoutDashboard, Package, Settings, Users, LayoutGrid, ShoppingCart, Wrench, Database, Building2 } from 'lucide-react' +import { LayoutDashboard, Package, Settings, Users, LayoutGrid, ShoppingCart, Wrench, Database, Building2, Sparkles } from 'lucide-react' import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { AdminLogoutButton } from '@/components/admin/logout-menu-item' import { DemoWrapper } from '@/components/DemoWrapper' +import { AdminNavLink } from '@/components/admin/admin-nav-link' export default async function AdminLayout({ children, @@ -41,53 +42,58 @@ export default async function AdminLayout({ +
diff --git a/shop/app/admin/wysiwyg/components/inline-input.tsx b/shop/app/admin/wysiwyg/components/inline-input.tsx index c7f2e7d..a7372ba 100644 --- a/shop/app/admin/wysiwyg/components/inline-input.tsx +++ b/shop/app/admin/wysiwyg/components/inline-input.tsx @@ -26,8 +26,26 @@ export function InlineInput({ value, onSave, className, type = 'text' }: InlineI const handleBlur = async () => { setIsEditing(false) - if (currentValue !== value) { - await onSave(currentValue) + const trimmed = currentValue.trim() + + if (type === 'number') { + const parsed = parseFloat(trimmed) + if (isNaN(parsed) || trimmed === '') { + setCurrentValue(value) + return + } + const finalVal = parsed.toString() + if (finalVal !== value) { + await onSave(finalVal) + } + } else { + if (trimmed === '') { + setCurrentValue(value) + return + } + if (trimmed !== value) { + await onSave(trimmed) + } } } diff --git a/shop/app/admin/wysiwyg/components/module-popover.tsx b/shop/app/admin/wysiwyg/components/module-popover.tsx new file mode 100644 index 0000000..d692bf3 --- /dev/null +++ b/shop/app/admin/wysiwyg/components/module-popover.tsx @@ -0,0 +1,118 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { ProductModule } from '@/lib/types' +import { Plus, Search } from 'lucide-react' + +interface ModulePopoverProps { + assignedModules: ProductModule[] + allModules: ProductModule[] + onAddModule: (module: ProductModule) => void +} + +export function ModulePopover({ assignedModules, allModules, onAddModule }: ModulePopoverProps) { + const [isOpen, setIsOpen] = useState(false) + const [search, setSearch] = useState('') + const popoverRef = useRef(null) + const inputRef = useRef(null) + + // Filter modules that are not already assigned to this product + // We match by name to treat identical modules across products as the same entity + const availableModules = allModules.filter(m => { + const isAssigned = assignedModules.some(am => am.name.toLowerCase() === m.name.toLowerCase()) + const matchesSearch = m.name.toLowerCase().includes(search.toLowerCase()) || + (m.description && m.description.toLowerCase().includes(search.toLowerCase())) + return !isAssigned && matchesSearch + }) + + // Deduplicate available modules by name to show a clean list + const uniqueAvailableModules: ProductModule[] = [] + const seenNames = new Set() + for (const m of availableModules) { + const nameKey = m.name.toLowerCase() + if (!seenNames.has(nameKey)) { + seenNames.add(nameKey) + uniqueAvailableModules.push(m) + } + } + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus() + } + }, [isOpen]) + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [isOpen]) + + return ( +
+ + + {isOpen && ( +
+
+ + setSearch(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-lg pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-400 outline-none focus:border-primary/50 transition-colors" + /> +
+ +
+ {uniqueAvailableModules.length > 0 ? ( + uniqueAvailableModules.map((mod) => ( + + )) + ) : ( +
+ Keine passenden Module gefunden. +
+ )} +
+
+ )} +
+ ) +} diff --git a/shop/app/admin/wysiwyg/page.tsx b/shop/app/admin/wysiwyg/page.tsx index a6ad821..af94339 100644 --- a/shop/app/admin/wysiwyg/page.tsx +++ b/shop/app/admin/wysiwyg/page.tsx @@ -26,8 +26,23 @@ export default async function WysiwygAdminPage() { ) } +import { createClient } from '@/lib/supabase/server' + async function WysiwygDataWrapper() { const products = await getProducts().catch(() => []) const categories = await getCategories().catch(() => []) - return + + const supabase = await createClient() + const { data: allModules } = await supabase + .from('product_modules') + .select('*') + .order('name', { ascending: true }) + + return ( + + ) } diff --git a/shop/app/admin/wysiwyg/wysiwyg-client.tsx b/shop/app/admin/wysiwyg/wysiwyg-client.tsx index 4fd7e55..d12f5d0 100644 --- a/shop/app/admin/wysiwyg/wysiwyg-client.tsx +++ b/shop/app/admin/wysiwyg/wysiwyg-client.tsx @@ -1,22 +1,65 @@ 'use client' import { useState, useTransition } from 'react' -import { Category, Product } from '@/lib/types' -import { updateProduct, createProduct, updateCategory, createCategory } from '@/lib/actions/products' +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 + 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 @@ -68,6 +111,17 @@ export function WysiwygAdminClient({ }) } + const handleCategoryToggle = async (catId: string, fields: Partial) => { + startTransition(async () => { + try { + setCategories(categories.map(c => c.id === catId ? { ...c, ...fields } : c)) + await updateCategory(catId, fields) + } catch (e) { + console.error(e) + } + }) + } + return (
@@ -89,7 +143,7 @@ export function WysiwygAdminClient({ Abo-Modus (Monatlich)
- {isPending && Speichere Änderungen...} + {isPending && Speichere Änderungen...}
@@ -98,8 +152,8 @@ export function WysiwygAdminClient({ .map(cat => { const catProducts = filteredProducts.filter(p => p.category_id === cat.id) return ( -
-
+
+
+ + {/* Kategorie Attribute (Toggles) */} +
+ + +
+
-
+
{catProducts.map(prod => ( -
- { - 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" - /> - { - 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" - /> -
+
+
+
+
+ { + 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" + /> +
+ +
{ - 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)) + 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="inline-block" - /> - + className="text-xs text-slate-400 block" + /> +
+ { + 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" + /> + +
+ + {/* Modul-Badges */} +
+ Module: +
+ {(prod.modules || []).length > 0 ? ( + (prod.modules || []).map((m) => ( + + {m.name} + + + )) + ) : ( + Keine Module + )} +
+
+
+ + {/* Modul hinzufügen Popover */} +
+ { + 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) + }} + />
))} @@ -160,7 +304,7 @@ export function WysiwygAdminClient({ 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]" > + - Produkt hinzufügen + Produkt hinzufügen
@@ -172,7 +316,7 @@ export function WysiwygAdminClient({ 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" > + - Neue Kategorie hinzufügen + Neue Kategorie hinzufügen
diff --git a/shop/components/admin/admin-nav-link.tsx b/shop/components/admin/admin-nav-link.tsx new file mode 100644 index 0000000..c9c991a --- /dev/null +++ b/shop/components/admin/admin-nav-link.tsx @@ -0,0 +1,28 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { ReactNode } from 'react' + +interface AdminNavLinkProps { + href: string + children: ReactNode +} + +export function AdminNavLink({ href, children }: AdminNavLinkProps) { + const pathname = usePathname() + const isActive = pathname === href + + return ( + + {children} + + ) +}