Membership-System und Bug Fixing (inkl. Nutrition) #8
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
241
frontend/src/pages/SubscriptionPage.jsx
Normal file
241
frontend/src/pages/SubscriptionPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user