feat: add SubscriptionPage - user-facing subscription info
User can now view:
- Current tier (Free, Basic, Premium, Selfhosted) with icon
- Trial status and end date
- Access expiration date
- Feature limits with usage bars
- Progress indicators (green/orange/red based on usage)
- Reset period info (daily/monthly/never)
Coupon redemption:
- Input field for coupon code
- Auto-uppercase, monospace display
- Enter key support
- Success/error feedback
- Auto-refresh after redemption
Features:
- Clean card-based layout
- Visual tier badges with colors
- Progress bars for count limits
- Trial and access warnings
- Integrated in Settings page
Link added to SettingsPage:
- "👑 Abo-Status, Limits & Coupon einlösen"
- Easy access for all users
Phase 3 complete - all user-facing subscription features done!
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3eae7eb43f
commit
a27f090616
|
|
@ -25,6 +25,7 @@ import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||||
import AdminTiersPage from './pages/AdminTiersPage'
|
import AdminTiersPage from './pages/AdminTiersPage'
|
||||||
import AdminCouponsPage from './pages/AdminCouponsPage'
|
import AdminCouponsPage from './pages/AdminCouponsPage'
|
||||||
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
|
||||||
|
import SubscriptionPage from './pages/SubscriptionPage'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
function Nav() {
|
function Nav() {
|
||||||
|
|
@ -125,6 +126,7 @@ function AppShell() {
|
||||||
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
|
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
|
||||||
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
|
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
|
||||||
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||||
|
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<Nav/>
|
<Nav/>
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,16 @@ export default function SettingsPage() {
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">Einstellungen</h1>
|
<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 */}
|
{/* Profile list */}
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Profile ({profiles.length})</div>
|
<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