Membership-System und Bug Fixing (inkl. Nutrition) #8

Merged
Lars merged 56 commits from develop into main 2026-03-21 08:48:57 +01:00
3 changed files with 253 additions and 0 deletions
Showing only changes of commit a27f090616 - Show all commits

View File

@ -25,6 +25,7 @@ import AdminFeaturesPage from './pages/AdminFeaturesPage'
import AdminTiersPage from './pages/AdminTiersPage'
import AdminCouponsPage from './pages/AdminCouponsPage'
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
import SubscriptionPage from './pages/SubscriptionPage'
import './app.css'
function Nav() {
@ -125,6 +126,7 @@ function AppShell() {
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
<Route path="/subscription" element={<SubscriptionPage/>}/>
</Routes>
</main>
<Nav/>

View File

@ -234,6 +234,16 @@ export default function SettingsPage() {
<div>
<h1 className="page-title">Einstellungen</h1>
{/* Subscription */}
<div className="card section-gap">
<div className="card-title">Mein Abo</div>
<Link to="/subscription" style={{textDecoration:'none'}}>
<button className="btn btn-secondary btn-full">
👑 Abo-Status, Limits & Coupon einlösen
</button>
</Link>
</div>
{/* Profile list */}
<div className="card section-gap">
<div className="card-title">Profile ({profiles.length})</div>

View File

@ -0,0 +1,241 @@
import { useState, useEffect } from 'react'
import { Gift, AlertCircle, TrendingUp, Award } from 'lucide-react'
import { api } from '../utils/api'
export default function SubscriptionPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [subscription, setSubscription] = useState(null)
const [usage, setUsage] = useState([])
const [limits, setLimits] = useState([])
const [couponCode, setCouponCode] = useState('')
const [redeeming, setRedeeming] = useState(false)
const [couponSuccess, setCouponSuccess] = useState('')
useEffect(() => {
loadData()
}, [])
async function loadData() {
try {
setLoading(true)
const [subData, usageData, limitsData] = await Promise.all([
api.getMySubscription(),
api.getMyUsage(),
api.getMyLimits()
])
setSubscription(subData)
setUsage(usageData)
setLimits(limitsData)
setError('')
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
async function handleRedeemCoupon() {
if (!couponCode.trim()) return
try {
setRedeeming(true)
setError('')
setCouponSuccess('')
await api.redeemCoupon(couponCode.trim().toUpperCase())
setCouponSuccess('Coupon erfolgreich eingelöst!')
setCouponCode('')
await loadData()
} catch (e) {
setError(e.message)
} finally {
setRedeeming(false)
}
}
if (loading) return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" />
</div>
)
const tierColors = {
free: { bg: 'var(--surface2)', color: 'var(--text2)', icon: '🆓' },
basic: { bg: '#E3F2FD', color: '#1565C0', icon: '⭐' },
premium: { bg: '#F3E5F5', color: '#6A1B9A', icon: '👑' },
selfhosted: { bg: 'var(--accent-light)', color: 'var(--accent-dark)', icon: '🏠' }
}
const currentTier = subscription?.current_tier || 'free'
const tierStyle = tierColors[currentTier] || tierColors.free
return (
<div style={{ paddingBottom: 40 }}>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text1)' }}>
Mein Abo
</div>
<div style={{ fontSize: 13, color: 'var(--text3)', marginTop: 4 }}>
Tier, Limits und Nutzung
</div>
</div>
{/* Messages */}
{error && (
<div style={{
padding: 12, background: 'var(--danger)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{error}
</div>
)}
{couponSuccess && (
<div style={{
padding: 12, background: 'var(--accent)', color: 'white',
borderRadius: 8, marginBottom: 16, fontSize: 14
}}>
{couponSuccess}
</div>
)}
{/* Current Tier Card */}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{
width: 48, height: 48, borderRadius: 12,
background: tierStyle.bg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24
}}>
{tierStyle.icon}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, color: 'var(--text3)', textTransform: 'uppercase', fontWeight: 600 }}>
Aktueller Tier
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: tierStyle.color }}>
{currentTier.charAt(0).toUpperCase() + currentTier.slice(1)}
</div>
</div>
</div>
{subscription?.trial_ends_at && new Date(subscription.trial_ends_at) > new Date() && (
<div style={{
padding: 10, background: '#FFF3CD', borderRadius: 8,
fontSize: 13, color: '#856404', display: 'flex', gap: 8, alignItems: 'center'
}}>
<AlertCircle size={16} />
<div>
<strong>Trial aktiv:</strong> Endet am {new Date(subscription.trial_ends_at).toLocaleDateString('de-DE')}
</div>
</div>
)}
{subscription?.access_until && (
<div style={{
padding: 10, background: 'var(--accent-light)', borderRadius: 8,
fontSize: 13, color: 'var(--accent-dark)', display: 'flex', gap: 8, alignItems: 'center',
marginTop: 12
}}>
<Award size={16} />
<div>
<strong>Zugriff bis:</strong> {new Date(subscription.access_until).toLocaleDateString('de-DE')}
</div>
</div>
)}
</div>
{/* Feature Limits */}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{
fontSize: 14, fontWeight: 600, marginBottom: 12,
display: 'flex', alignItems: 'center', gap: 6
}}>
<TrendingUp size={16} color="var(--accent)" />
Feature-Limits & Nutzung
</div>
{limits.length === 0 ? (
<div style={{ textAlign: 'center', padding: 20, color: 'var(--text3)', fontSize: 13 }}>
Keine Limits konfiguriert
</div>
) : (
<div style={{ display: 'grid', gap: 12 }}>
{limits.map(limit => {
const usageEntry = usage.find(u => u.feature_id === limit.feature_id)
const used = usageEntry?.usage_count || 0
const limitValue = limit.limit_value
const percentage = limitValue ? Math.min((used / limitValue) * 100, 100) : 0
return (
<div key={limit.feature_id} style={{
padding: 12, background: 'var(--surface)', borderRadius: 8
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<div style={{ fontWeight: 500, fontSize: 13 }}>{limit.feature_name}</div>
<div style={{ fontSize: 13, color: 'var(--text3)' }}>
{used} / {limitValue === null ? '∞' : limitValue}
</div>
</div>
{limitValue !== null && (
<div style={{
height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden'
}}>
<div style={{
width: `${percentage}%`,
height: '100%',
background: percentage > 90 ? 'var(--danger)' : percentage > 70 ? '#FFA726' : 'var(--accent)',
transition: 'width 0.3s'
}} />
</div>
)}
{limit.reset_period !== 'never' && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
Reset: {limit.reset_period === 'daily' ? 'Täglich' : 'Monatlich'}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Coupon Redemption */}
<div className="card" style={{ padding: 20 }}>
<div style={{
fontSize: 14, fontWeight: 600, marginBottom: 12,
display: 'flex', alignItems: 'center', gap: 6
}}>
<Gift size={16} color="var(--accent)" />
Coupon einlösen
</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 12 }}>
Hast du einen Coupon-Code? Löse ihn hier ein um Zugriff auf Premium-Features zu erhalten.
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
className="form-input"
style={{ flex: 1, textTransform: 'uppercase', fontFamily: 'monospace' }}
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
placeholder="Z.B. PROMO-2026"
onKeyPress={(e) => e.key === 'Enter' && handleRedeemCoupon()}
/>
<button
className="btn btn-primary"
onClick={handleRedeemCoupon}
disabled={!couponCode.trim() || redeeming}
>
{redeeming ? 'Prüfen...' : 'Einlösen'}
</button>
</div>
</div>
</div>
)
}