feat: add AdminCouponsPage for coupon management
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 13s

Full CRUD interface for coupons:
- Create, edit, delete coupons
- Three coupon types supported:
  - Single-Use: one-time redemption per user
  - Multi-Use Period: unlimited redemptions in timeframe (Wellpass)
  - Gift: bonus system coupons

Features:
- Auto-generate random coupon codes
- Configure tier, duration, validity period
- Set max redemptions (or unlimited)
- View redemption history per coupon (modal)
- Active/inactive state management
- Card-based layout with visual type indicators

Form improvements:
- Conditional fields based on coupon type
- Date pickers for period coupons
- Duration config for single-use/gift
- Help text for each field
- Labels above inputs (consistent with other pages)

Integrated in AdminPanel navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-20 07:53:47 +01:00
parent bc4db19190
commit 18991025bf
3 changed files with 530 additions and 0 deletions

View File

@ -23,6 +23,7 @@ import GuidePage from './pages/GuidePage'
import AdminTierLimitsPage from './pages/AdminTierLimitsPage' import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
import AdminFeaturesPage from './pages/AdminFeaturesPage' import AdminFeaturesPage from './pages/AdminFeaturesPage'
import AdminTiersPage from './pages/AdminTiersPage' import AdminTiersPage from './pages/AdminTiersPage'
import AdminCouponsPage from './pages/AdminCouponsPage'
import './app.css' import './app.css'
function Nav() { function Nav() {
@ -121,6 +122,7 @@ function AppShell() {
<Route path="/admin/tier-limits" element={<AdminTierLimitsPage/>}/> <Route path="/admin/tier-limits" element={<AdminTierLimitsPage/>}/>
<Route path="/admin/features" element={<AdminFeaturesPage/>}/> <Route path="/admin/features" element={<AdminFeaturesPage/>}/>
<Route path="/admin/tiers" element={<AdminTiersPage/>}/> <Route path="/admin/tiers" element={<AdminTiersPage/>}/>
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
</Routes> </Routes>
</main> </main>
<Nav/> <Nav/>

View File

@ -0,0 +1,523 @@
import { useState, useEffect } from 'react'
import { Save, Plus, Edit2, Trash2, X, Eye, Gift, Ticket } from 'lucide-react'
import { api } from '../utils/api'
export default function AdminCouponsPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [coupons, setCoupons] = useState([])
const [editingId, setEditingId] = useState(null)
const [showAddForm, setShowAddForm] = useState(false)
const [viewingRedemptions, setViewingRedemptions] = useState(null)
const [redemptions, setRedemptions] = useState([])
const [formData, setFormData] = useState({
code: '',
type: 'single_use',
valid_from: '',
valid_until: '',
grants_tier: 'premium',
duration_days: 30,
max_redemptions: 1,
notes: '',
active: true
})
useEffect(() => {
loadCoupons()
}, [])
async function loadCoupons() {
try {
setLoading(true)
const data = await api.listCoupons()
setCoupons(data)
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
async function loadRedemptions(couponId) {
try {
const data = await api.getCouponRedemptions(couponId)
setRedemptions(data)
setViewingRedemptions(couponId)
} catch (e) {
setError(e.message)
}
}
function resetForm() {
setFormData({
code: '',
type: 'single_use',
valid_from: '',
valid_until: '',
grants_tier: 'premium',
duration_days: 30,
max_redemptions: 1,
notes: '',
active: true
})
setEditingId(null)
setShowAddForm(false)
}
function startEdit(coupon) {
setFormData({
code: coupon.code,
type: coupon.type,
valid_from: coupon.valid_from ? coupon.valid_from.split('T')[0] : '',
valid_until: coupon.valid_until ? coupon.valid_until.split('T')[0] : '',
grants_tier: coupon.grants_tier,
duration_days: coupon.duration_days || 30,
max_redemptions: coupon.max_redemptions || 1,
notes: coupon.notes || '',
active: coupon.active
})
setEditingId(coupon.id)
setShowAddForm(false)
}
function startAdd() {
// Generate random code
const randomCode = `${formData.type === 'gift' ? 'GIFT' : 'PROMO'}-${Math.random().toString(36).substr(2, 8).toUpperCase()}`
setFormData({ ...formData, code: randomCode })
setShowAddForm(true)
setEditingId(null)
}
async function handleSave() {
try {
setError('')
setSuccess('')
if (!formData.code.trim()) {
setError('Code erforderlich')
return
}
const payload = {
code: formData.code.trim().toUpperCase(),
type: formData.type,
valid_from: formData.valid_from || null,
valid_until: formData.valid_until || null,
grants_tier: formData.grants_tier,
duration_days: parseInt(formData.duration_days) || null,
max_redemptions: formData.type === 'single_use' ? 1 : (parseInt(formData.max_redemptions) || null),
notes: formData.notes.trim(),
active: formData.active
}
if (editingId) {
await api.updateCoupon(editingId, payload)
setSuccess('Coupon aktualisiert')
} else {
await api.createCoupon(payload)
setSuccess('Coupon erstellt')
}
await loadCoupons()
resetForm()
} catch (e) {
setError(e.message)
}
}
async function handleDelete(couponId) {
if (!confirm('Coupon wirklich löschen?')) return
try {
setError('')
await api.deleteCoupon(couponId)
setSuccess('Coupon gelöscht')
await loadCoupons()
} catch (e) {
setError(e.message)
}
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('de-DE')
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const couponTypes = [
{ value: 'single_use', label: 'Single-Use (einmalig)', icon: '🎟️' },
{ value: 'multi_use_period', label: 'Multi-Use Period (Zeitraum)', icon: '🔄' },
{ value: 'gift', label: 'Geschenk-Coupon', icon: '🎁' }
]
const tierOptions = [
{ value: 'free', label: 'Free' },
{ value: 'basic', label: 'Basic' },
{ value: 'premium', label: 'Premium' }
]
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)' }}>
Coupon-Verwaltung
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Trial-Codes, Wellpass-Integration, Geschenk-Coupons
</div>
</div>
{!showAddForm && !editingId && (
<button className="btn btn-primary" onClick={startAdd}>
<Plus size={16} /> Neuer Coupon
</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 ? 'Coupon bearbeiten' : 'Neuer Coupon'}
</div>
<button
className="btn btn-secondary"
onClick={resetForm}
style={{ padding: '6px 12px' }}
>
<X size={16} />
</button>
</div>
<div style={{ display: 'grid', gap: 16 }}>
{/* Code */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Coupon-Code *
</label>
<input
className="form-input"
style={{ width: '100%', fontFamily: 'monospace', textTransform: 'uppercase' }}
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
placeholder="Z.B. PROMO-2026"
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Wird automatisch in Großbuchstaben konvertiert
</div>
</div>
{/* Type */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Coupon-Typ
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
>
{couponTypes.map(t => (
<option key={t.value} value={t.value}>
{t.icon} {t.label}
</option>
))}
</select>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
{formData.type === 'single_use' && 'Kann nur einmal pro User eingelöst werden'}
{formData.type === 'multi_use_period' && 'Unbegrenzte Einlösungen im Gültigkeitszeitraum (z.B. Wellpass)'}
{formData.type === 'gift' && 'Geschenk-Coupon für Bonus-System'}
</div>
</div>
{/* Tier */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gewährt Tier
</label>
<select
className="form-input"
style={{ width: '100%' }}
value={formData.grants_tier}
onChange={(e) => setFormData({ ...formData, grants_tier: e.target.value })}
>
{tierOptions.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
{/* Duration (only for single_use and gift) */}
{(formData.type === 'single_use' || formData.type === 'gift') && (
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gültigkeitsdauer (Tage)
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="number"
value={formData.duration_days}
onChange={(e) => setFormData({ ...formData, duration_days: e.target.value })}
/>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Wie lange ist der gewährte Zugriff gültig?
</div>
</div>
)}
{/* Valid From/Until (for multi_use_period) */}
{formData.type === 'multi_use_period' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gültig ab
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="date"
value={formData.valid_from}
onChange={(e) => setFormData({ ...formData, valid_from: e.target.value })}
/>
</div>
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Gültig bis
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="date"
value={formData.valid_until}
onChange={(e) => setFormData({ ...formData, valid_until: e.target.value })}
/>
</div>
</div>
)}
{/* Max Redemptions (not for single_use) */}
{formData.type !== 'single_use' && (
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Maximale Einlösungen (optional)
</label>
<input
className="form-input"
style={{ width: '100%' }}
type="number"
value={formData.max_redemptions}
onChange={(e) => setFormData({ ...formData, max_redemptions: e.target.value })}
placeholder="Leer = unbegrenzt"
/>
</div>
)}
{/* Notes */}
<div>
<label className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Notizen (optional)
</label>
<input
className="form-input"
style={{ width: '100%' }}
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Z.B. 'Für Marketing-Kampagne März 2026'"
/>
</div>
{/* Active */}
<div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={formData.active}
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
/>
Coupon aktiviert (kann eingelöst werden)
</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>
)}
{/* Redemptions Modal */}
{viewingRedemptions && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 20
}} onClick={() => setViewingRedemptions(null)}>
<div className="card" style={{ padding: 20, maxWidth: 600, width: '100%', maxHeight: '80vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ fontSize: 16, fontWeight: 600 }}>Einlösungen</div>
<button className="btn btn-secondary" onClick={() => setViewingRedemptions(null)} style={{ padding: '6px 12px' }}>
<X size={16} />
</button>
</div>
{redemptions.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
Noch keine Einlösungen
</div>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{redemptions.map(r => (
<div key={r.id} className="card" style={{ padding: 12, background: 'var(--surface)' }}>
<div style={{ fontWeight: 500 }}>{r.profile_name || `User #${r.profile_id}`}</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>
Eingelöst am {formatDate(r.redeemed_at)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Coupons List */}
<div style={{ display: 'grid', gap: 12 }}>
{coupons.length === 0 && (
<div className="card" style={{ padding: 40, textAlign: 'center', color: 'var(--text3)' }}>
Keine Coupons vorhanden
</div>
)}
{coupons.map(coupon => (
<div
key={coupon.id}
className="card"
style={{
padding: 16,
opacity: coupon.active ? 1 : 0.6,
border: coupon.active ? '1px solid var(--border)' : '1px dashed var(--border)'
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{
fontSize: 16, fontWeight: 700, color: 'var(--text1)',
fontFamily: 'monospace', background: 'var(--surface2)',
padding: '4px 8px', borderRadius: 4
}}>
{coupon.code}
</div>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11,
background: 'var(--accent-light)', color: 'var(--accent-dark)', fontWeight: 600
}}>
{couponTypes.find(t => t.value === coupon.type)?.icon} {couponTypes.find(t => t.value === coupon.type)?.label}
</span>
{!coupon.active && (
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 10,
background: 'var(--danger)', color: 'white', fontWeight: 600
}}>
INAKTIV
</span>
)}
</div>
<div style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 8 }}>
Gewährt: <strong>{coupon.grants_tier}</strong>
{coupon.duration_days && ` für ${coupon.duration_days} Tage`}
</div>
{coupon.notes && (
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>
📝 {coupon.notes}
</div>
)}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text3)' }}>
{coupon.valid_from && (
<div><strong>Gültig ab:</strong> {formatDate(coupon.valid_from)}</div>
)}
{coupon.valid_until && (
<div><strong>Gültig bis:</strong> {formatDate(coupon.valid_until)}</div>
)}
<div>
<strong>Einlösungen:</strong> {coupon.current_redemptions || 0}
{coupon.max_redemptions ? ` / ${coupon.max_redemptions}` : ' / ∞'}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
<button
className="btn btn-secondary"
onClick={() => loadRedemptions(coupon.id)}
style={{ padding: '6px 12px', fontSize: 12 }}
title="Einlösungen anzeigen"
>
<Eye size={14} />
</button>
<button
className="btn btn-secondary"
onClick={() => startEdit(coupon)}
style={{ padding: '6px 12px', fontSize: 12 }}
>
<Edit2 size={14} />
</button>
<button
className="btn btn-secondary"
onClick={() => handleDelete(coupon.id)}
style={{ padding: '6px 12px', fontSize: 12, color: 'var(--danger)' }}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -423,6 +423,11 @@ export default function AdminPanel() {
📊 Tier Limits Matrix bearbeiten 📊 Tier Limits Matrix bearbeiten
</button> </button>
</Link> </Link>
<Link to="/admin/coupons">
<button className="btn btn-secondary btn-full">
🎟 Coupons verwalten
</button>
</Link>
</div> </div>
</div> </div>
</div> </div>