AdminFeaturesPage: - Full CRUD for features registry - Add/edit features with all properties - Category, limit type, reset period configuration - Default limits and sorting AdminTiersPage: - Full CRUD for subscription tiers - Pricing configuration (monthly/yearly in cents) - Active/inactive state management - Card-based layout with edit/delete actions Both pages: - Form validation - Success/error messaging - Clean table/card layouts - Integrated in AdminPanel navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
393 lines
13 KiB
JavaScript
393 lines
13 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { Save, Plus, Edit2, Trash2, X } from 'lucide-react'
|
|
import { api } from '../utils/api'
|
|
|
|
export default function AdminTiersPage() {
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [success, setSuccess] = useState('')
|
|
const [tiers, setTiers] = useState([])
|
|
const [editingId, setEditingId] = useState(null)
|
|
const [showAddForm, setShowAddForm] = useState(false)
|
|
const [formData, setFormData] = useState({
|
|
id: '',
|
|
name: '',
|
|
description: '',
|
|
price_monthly_cents: '',
|
|
price_yearly_cents: '',
|
|
sort_order: 50,
|
|
active: true
|
|
})
|
|
|
|
useEffect(() => {
|
|
loadTiers()
|
|
}, [])
|
|
|
|
async function loadTiers() {
|
|
try {
|
|
setLoading(true)
|
|
const data = await api.listTiers()
|
|
setTiers(data)
|
|
setError('')
|
|
} catch (e) {
|
|
setError(e.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
setFormData({
|
|
id: '',
|
|
name: '',
|
|
description: '',
|
|
price_monthly_cents: '',
|
|
price_yearly_cents: '',
|
|
sort_order: 50,
|
|
active: true
|
|
})
|
|
setEditingId(null)
|
|
setShowAddForm(false)
|
|
}
|
|
|
|
function startEdit(tier) {
|
|
setFormData({
|
|
id: tier.id,
|
|
name: tier.name,
|
|
description: tier.description || '',
|
|
price_monthly_cents: tier.price_monthly_cents === null ? '' : tier.price_monthly_cents,
|
|
price_yearly_cents: tier.price_yearly_cents === null ? '' : tier.price_yearly_cents,
|
|
sort_order: tier.sort_order || 50,
|
|
active: tier.active
|
|
})
|
|
setEditingId(tier.id)
|
|
setShowAddForm(false)
|
|
}
|
|
|
|
async function handleSave() {
|
|
try {
|
|
setError('')
|
|
setSuccess('')
|
|
|
|
// Validation
|
|
if (!formData.name.trim()) {
|
|
setError('Name erforderlich')
|
|
return
|
|
}
|
|
|
|
const payload = {
|
|
name: formData.name.trim(),
|
|
description: formData.description.trim(),
|
|
price_monthly_cents: formData.price_monthly_cents === '' ? null : parseInt(formData.price_monthly_cents),
|
|
price_yearly_cents: formData.price_yearly_cents === '' ? null : parseInt(formData.price_yearly_cents),
|
|
sort_order: formData.sort_order,
|
|
active: formData.active
|
|
}
|
|
|
|
if (editingId) {
|
|
// Update existing
|
|
await api.updateTier(editingId, payload)
|
|
setSuccess('Tier aktualisiert')
|
|
} else {
|
|
// Create new
|
|
if (!formData.id.trim()) {
|
|
setError('ID erforderlich')
|
|
return
|
|
}
|
|
payload.id = formData.id.trim()
|
|
await api.createTier(payload)
|
|
setSuccess('Tier erstellt')
|
|
}
|
|
|
|
await loadTiers()
|
|
resetForm()
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}
|
|
|
|
async function handleDelete(tierId) {
|
|
if (!confirm('Tier wirklich deaktivieren?')) return
|
|
try {
|
|
setError('')
|
|
await api.deleteTier(tierId)
|
|
setSuccess('Tier deaktiviert')
|
|
await loadTiers()
|
|
} catch (e) {
|
|
setError(e.message)
|
|
}
|
|
}
|
|
|
|
function formatPrice(cents) {
|
|
if (cents === null || cents === undefined) return 'Kostenlos'
|
|
return `${(cents / 100).toFixed(2)} €`
|
|
}
|
|
|
|
if (loading) return (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div style={{ paddingBottom: 80 }}>
|
|
{/* Header */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
marginBottom: 20
|
|
}}>
|
|
<div>
|
|
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
|
|
Tier-Verwaltung
|
|
</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
|
|
Subscription-Tiers konfigurieren
|
|
</div>
|
|
</div>
|
|
{!showAddForm && !editingId && (
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={() => setShowAddForm(true)}
|
|
>
|
|
<Plus size={16} /> Neuer Tier
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
{error && (
|
|
<div style={{
|
|
padding: 12, background: 'var(--danger)', color: 'white',
|
|
borderRadius: 8, marginBottom: 16, fontSize: 14
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && (
|
|
<div style={{
|
|
padding: 12, background: 'var(--accent)', color: 'white',
|
|
borderRadius: 8, marginBottom: 16, fontSize: 14
|
|
}}>
|
|
{success}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add/Edit Form */}
|
|
{(showAddForm || editingId) && (
|
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
marginBottom: 16
|
|
}}>
|
|
<div style={{ fontSize: 16, fontWeight: 600 }}>
|
|
{editingId ? 'Tier bearbeiten' : 'Neuen Tier erstellen'}
|
|
</div>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={resetForm}
|
|
style={{ padding: '6px 12px' }}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gap: 12 }}>
|
|
{/* ID (nur bei Neuanlage) */}
|
|
{!editingId && (
|
|
<div className="form-row">
|
|
<label className="form-label">ID (Slug) *</label>
|
|
<input
|
|
className="form-input"
|
|
value={formData.id}
|
|
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
|
placeholder="z.B. enterprise"
|
|
/>
|
|
<span className="form-unit" style={{ fontSize: 11, color: 'var(--text3)' }}>
|
|
Kleinbuchstaben, keine Leerzeichen
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Name */}
|
|
<div className="form-row">
|
|
<label className="form-label">Name *</label>
|
|
<input
|
|
className="form-input"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="z.B. Enterprise"
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="form-row">
|
|
<label className="form-label">Beschreibung</label>
|
|
<input
|
|
className="form-input"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
placeholder="z.B. Für Teams und Unternehmen"
|
|
/>
|
|
</div>
|
|
|
|
{/* Pricing */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<div className="form-row">
|
|
<label className="form-label">Monatspreis (Cent)</label>
|
|
<input
|
|
className="form-input"
|
|
type="number"
|
|
value={formData.price_monthly_cents}
|
|
onChange={(e) => setFormData({ ...formData, price_monthly_cents: e.target.value })}
|
|
placeholder="Leer = kostenlos"
|
|
/>
|
|
<span className="form-unit" style={{ fontSize: 11 }}>
|
|
{formData.price_monthly_cents ? formatPrice(parseInt(formData.price_monthly_cents)) : '-'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Jahrespreis (Cent)</label>
|
|
<input
|
|
className="form-input"
|
|
type="number"
|
|
value={formData.price_yearly_cents}
|
|
onChange={(e) => setFormData({ ...formData, price_yearly_cents: e.target.value })}
|
|
placeholder="Leer = kostenlos"
|
|
/>
|
|
<span className="form-unit" style={{ fontSize: 11 }}>
|
|
{formData.price_yearly_cents ? formatPrice(parseInt(formData.price_yearly_cents)) : '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sort Order + Active */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'end' }}>
|
|
<div className="form-row">
|
|
<label className="form-label">Sortierung</label>
|
|
<input
|
|
className="form-input"
|
|
type="number"
|
|
value={formData.sort_order}
|
|
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })}
|
|
/>
|
|
</div>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, paddingBottom: 8 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.active}
|
|
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
|
/>
|
|
Aktiv
|
|
</label>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
<button className="btn btn-primary" onClick={handleSave}>
|
|
<Save size={14} /> {editingId ? 'Aktualisieren' : 'Erstellen'}
|
|
</button>
|
|
<button className="btn btn-secondary" onClick={resetForm}>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tiers List */}
|
|
<div style={{ display: 'grid', gap: 12 }}>
|
|
{tiers.length === 0 && (
|
|
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
|
|
Keine Tiers vorhanden
|
|
</div>
|
|
)}
|
|
{tiers.map(tier => (
|
|
<div
|
|
key={tier.id}
|
|
className="card"
|
|
style={{
|
|
padding: 16,
|
|
opacity: tier.active ? 1 : 0.6,
|
|
border: tier.active ? '1px solid var(--border)' : '1px dashed var(--border)'
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
|
<div style={{ fontSize: 16, fontWeight: 700, color: 'var(--text1)' }}>
|
|
{tier.name}
|
|
</div>
|
|
<span style={{
|
|
padding: '2px 8px', borderRadius: 4, fontSize: 10,
|
|
background: 'var(--surface2)', color: 'var(--text3)', fontFamily: 'monospace'
|
|
}}>
|
|
{tier.id}
|
|
</span>
|
|
{!tier.active && (
|
|
<span style={{
|
|
padding: '2px 8px', borderRadius: 4, fontSize: 10,
|
|
background: 'var(--danger)', color: 'white', fontWeight: 600
|
|
}}>
|
|
INAKTIV
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{tier.description && (
|
|
<div style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 8 }}>
|
|
{tier.description}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text3)' }}>
|
|
<div>
|
|
<strong>Monatlich:</strong> {formatPrice(tier.price_monthly_cents)}
|
|
</div>
|
|
<div>
|
|
<strong>Jährlich:</strong> {formatPrice(tier.price_yearly_cents)}
|
|
</div>
|
|
<div>
|
|
<strong>Sortierung:</strong> {tier.sort_order}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => startEdit(tier)}
|
|
style={{ padding: '6px 12px', fontSize: 12 }}
|
|
>
|
|
<Edit2 size={14} />
|
|
</button>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => handleDelete(tier.id)}
|
|
style={{ padding: '6px 12px', fontSize: 12, color: 'var(--danger)' }}
|
|
disabled={!tier.active}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div style={{
|
|
marginTop: 16, padding: 12, background: 'var(--surface2)',
|
|
borderRadius: 8, fontSize: 12, color: 'var(--text3)'
|
|
}}>
|
|
<strong>Hinweis:</strong> Limits für jeden Tier können in der{' '}
|
|
<a href="/admin/tier-limits" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
|
Tier Limits Matrix
|
|
</a>{' '}
|
|
konfiguriert werden.
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|