feat(admin): add wysiwyg editor for order wizard
All checks were successful
Staging Build / build (push) Successful in 2m49s
All checks were successful
Staging Build / build (push) Successful in 2m49s
This commit is contained in:
63
shop/app/admin/wysiwyg/components/inline-input.tsx
Normal file
63
shop/app/admin/wysiwyg/components/inline-input.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface InlineInputProps {
|
||||||
|
value: string
|
||||||
|
onSave: (val: string) => Promise<void>
|
||||||
|
className?: string
|
||||||
|
type?: 'text' | 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineInput({ value, onSave, className, type = 'text' }: InlineInputProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [currentValue, setCurrentValue] = useState(value)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentValue(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isEditing])
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
setIsEditing(false)
|
||||||
|
if (currentValue !== value) {
|
||||||
|
await onSave(currentValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleBlur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type={type}
|
||||||
|
step={type === 'number' ? '0.01' : undefined}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => setCurrentValue(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={`bg-white/10 text-white border border-primary px-1 rounded outline-none w-full ${className}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className={`cursor-pointer hover:bg-white/5 rounded px-1 transition ${className}`}
|
||||||
|
>
|
||||||
|
{value || <span className="italic text-slate-500">Klicken zum Bearbeiten</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
shop/app/admin/wysiwyg/page.tsx
Normal file
33
shop/app/admin/wysiwyg/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { getProducts, getCategories } from '@/lib/actions/products'
|
||||||
|
import { WysiwygAdminClient } from './wysiwyg-client'
|
||||||
|
import { Edit, Sparkles } from 'lucide-react'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export default async function WysiwygAdminPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-8 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight text-gradient flex items-center gap-3">
|
||||||
|
<Edit className="w-8 h-8 text-primary" />
|
||||||
|
WYSIWYG Live-Editor
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-300">
|
||||||
|
Kategorien und Produkte visuell im Wizard-Layout bearbeiten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Suspense fallback={<div className="h-40 w-full animate-pulse bg-white/5 rounded-xl" />}>
|
||||||
|
<WysiwygDataWrapper />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function WysiwygDataWrapper() {
|
||||||
|
const products = await getProducts().catch(() => [])
|
||||||
|
const categories = await getCategories().catch(() => [])
|
||||||
|
return <WysiwygAdminClient initialProducts={products} initialCategories={categories} />
|
||||||
|
}
|
||||||
180
shop/app/admin/wysiwyg/wysiwyg-client.tsx
Normal file
180
shop/app/admin/wysiwyg/wysiwyg-client.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { Category, Product } from '@/lib/types'
|
||||||
|
import { updateProduct, createProduct, updateCategory, createCategory } from '@/lib/actions/products'
|
||||||
|
import { InlineInput } from './components/inline-input'
|
||||||
|
|
||||||
|
export function WysiwygAdminClient({
|
||||||
|
initialCategories,
|
||||||
|
initialProducts
|
||||||
|
}: {
|
||||||
|
initialCategories: Category[]
|
||||||
|
initialProducts: Product[]
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<'purchase' | 'subscription'>('purchase')
|
||||||
|
const [categories, setCategories] = useState(initialCategories)
|
||||||
|
const [products, setProducts] = useState(initialProducts)
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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">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">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{catProducts.map(prod => (
|
||||||
|
<div key={prod.id} className="p-4 bg-white/5 border border-white/10 rounded-xl space-y-2 relative group">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</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">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>Neue Kategorie hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user