feat: add AdminCouponsPage for coupon management
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:
parent
bc4db19190
commit
18991025bf
|
|
@ -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/>
|
||||||
|
|
|
||||||
523
frontend/src/pages/AdminCouponsPage.jsx
Normal file
523
frontend/src/pages/AdminCouponsPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user