Admin Panel (Next.js): - Dashboard with stats - Users list with relationships (watches/watched_by) - User detail pages - Deployments list and detail pages - Devices, Orders, Subscriptions pages - OTP-based admin authentication Backend Optimizations: - Fixed N+1 query problem in admin APIs - Added pagination support - Added .range() and count support to Supabase wrapper - Optimized batch queries with lookup maps Database: - Added migrations for schema evolution - New tables: push_tokens, notification_settings - Updated access model iOS Build Scripts: - build-ios.sh, clear-apple-cache.sh - EAS configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
260 lines
6.8 KiB
JavaScript
260 lines
6.8 KiB
JavaScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import AdminLayout from '../../components/AdminLayout';
|
|
import { getSubscriptions } from '../../lib/api';
|
|
|
|
export default function SubscriptionsPage() {
|
|
const [subscriptions, setSubscriptions] = useState([]);
|
|
const [filter, setFilter] = useState('all');
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
loadSubscriptions();
|
|
}, []);
|
|
|
|
const loadSubscriptions = async () => {
|
|
try {
|
|
const data = await getSubscriptions();
|
|
setSubscriptions(data.subscriptions || []);
|
|
} catch (err) {
|
|
console.error('Failed to load subscriptions:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filteredSubscriptions = subscriptions.filter((sub) => {
|
|
if (filter === 'all') return true;
|
|
if (filter === 'active') return sub.status === 'active';
|
|
if (filter === 'premium') return sub.plan === 'premium' && sub.status === 'active';
|
|
if (filter === 'canceled') return sub.status === 'canceled';
|
|
return true;
|
|
});
|
|
|
|
const stats = {
|
|
total: subscriptions.length,
|
|
active: subscriptions.filter(s => s.status === 'active').length,
|
|
premium: subscriptions.filter(s => s.plan === 'premium' && s.status === 'active').length,
|
|
free: subscriptions.filter(s => s.plan === 'free').length,
|
|
canceled: subscriptions.filter(s => s.status === 'canceled').length,
|
|
};
|
|
|
|
const mrr = stats.premium * 9.99;
|
|
|
|
return (
|
|
<AdminLayout>
|
|
<div style={styles.header}>
|
|
<h1 style={styles.title}>Subscriptions</h1>
|
|
<div style={styles.filters}>
|
|
{['all', 'active', 'premium', 'canceled'].map((f) => (
|
|
<button
|
|
key={f}
|
|
onClick={() => setFilter(f)}
|
|
style={{
|
|
...styles.filterBtn,
|
|
...(filter === f ? styles.filterBtnActive : {}),
|
|
}}
|
|
>
|
|
{f.charAt(0).toUpperCase() + f.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={styles.statsGrid}>
|
|
<StatCard label="MRR" value={`$${mrr.toFixed(2)}`} />
|
|
<StatCard label="Active" value={stats.active} />
|
|
<StatCard label="Premium" value={stats.premium} color="var(--primary)" />
|
|
<StatCard label="Churned" value={stats.canceled} color="var(--error)" />
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p style={styles.loading}>Loading...</p>
|
|
) : (
|
|
<div style={styles.table}>
|
|
<div style={styles.tableHeader}>
|
|
<span>User</span>
|
|
<span>Plan</span>
|
|
<span>Status</span>
|
|
<span>Created</span>
|
|
<span>Expires</span>
|
|
<span>Stripe ID</span>
|
|
</div>
|
|
{filteredSubscriptions.length === 0 ? (
|
|
<div style={styles.empty}>No subscriptions found</div>
|
|
) : (
|
|
filteredSubscriptions.map((sub) => (
|
|
<div key={sub.id} style={styles.tableRow}>
|
|
<span style={styles.email}>
|
|
{sub.user?.email || '—'}
|
|
</span>
|
|
<span>
|
|
<PlanBadge plan={sub.plan} />
|
|
</span>
|
|
<span>
|
|
<StatusBadge status={sub.status} />
|
|
</span>
|
|
<span style={styles.date}>
|
|
{sub.created_at ? new Date(sub.created_at).toLocaleDateString() : '—'}
|
|
</span>
|
|
<span style={styles.date}>
|
|
{sub.expires_at ? new Date(sub.expires_at).toLocaleDateString() : '—'}
|
|
</span>
|
|
<span style={styles.stripeId}>
|
|
{sub.stripe_subscription_id || '—'}
|
|
</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
function StatCard({ label, value, color }) {
|
|
return (
|
|
<div style={styles.statCard}>
|
|
<div style={styles.statLabel}>{label}</div>
|
|
<div style={{ ...styles.statValue, color: color || 'var(--text-primary)' }}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlanBadge({ plan }) {
|
|
const isPremium = plan === 'premium';
|
|
return (
|
|
<span style={{
|
|
padding: '4px 10px',
|
|
borderRadius: '12px',
|
|
fontSize: '12px',
|
|
fontWeight: '500',
|
|
background: isPremium ? '#FEF3C7' : '#F3F4F6',
|
|
color: isPremium ? '#92400E' : '#6B7280',
|
|
}}>
|
|
{plan || 'free'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }) {
|
|
const colors = {
|
|
active: { bg: '#D1FAE5', color: '#065F46' },
|
|
canceled: { bg: '#FEE2E2', color: '#991B1B' },
|
|
expired: { bg: '#FEE2E2', color: '#991B1B' },
|
|
trialing: { bg: '#DBEAFE', color: '#1E40AF' },
|
|
};
|
|
const style = colors[status] || colors.active;
|
|
return (
|
|
<span style={{
|
|
padding: '4px 10px',
|
|
borderRadius: '12px',
|
|
fontSize: '12px',
|
|
fontWeight: '500',
|
|
background: style.bg,
|
|
color: style.color,
|
|
}}>
|
|
{status || 'unknown'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const styles = {
|
|
header: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: '24px',
|
|
},
|
|
title: {
|
|
fontSize: '24px',
|
|
fontWeight: '600',
|
|
},
|
|
filters: {
|
|
display: 'flex',
|
|
gap: '8px',
|
|
},
|
|
filterBtn: {
|
|
padding: '8px 16px',
|
|
fontSize: '13px',
|
|
fontWeight: '500',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '6px',
|
|
background: 'white',
|
|
color: 'var(--text-secondary)',
|
|
cursor: 'pointer',
|
|
},
|
|
filterBtnActive: {
|
|
background: 'var(--primary)',
|
|
color: 'white',
|
|
borderColor: 'var(--primary)',
|
|
},
|
|
statsGrid: {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
|
gap: '16px',
|
|
marginBottom: '24px',
|
|
},
|
|
statCard: {
|
|
background: 'white',
|
|
borderRadius: '12px',
|
|
padding: '20px',
|
|
textAlign: 'center',
|
|
},
|
|
statLabel: {
|
|
fontSize: '13px',
|
|
color: 'var(--text-muted)',
|
|
marginBottom: '4px',
|
|
},
|
|
statValue: {
|
|
fontSize: '28px',
|
|
fontWeight: '700',
|
|
},
|
|
loading: {
|
|
color: 'var(--text-muted)',
|
|
},
|
|
table: {
|
|
background: 'white',
|
|
borderRadius: '12px',
|
|
overflow: 'hidden',
|
|
},
|
|
tableHeader: {
|
|
display: 'grid',
|
|
gridTemplateColumns: '1.5fr 0.8fr 0.8fr 1fr 1fr 1.5fr',
|
|
gap: '16px',
|
|
padding: '16px 20px',
|
|
background: 'var(--surface)',
|
|
fontSize: '12px',
|
|
fontWeight: '600',
|
|
color: 'var(--text-muted)',
|
|
textTransform: 'uppercase',
|
|
},
|
|
tableRow: {
|
|
display: 'grid',
|
|
gridTemplateColumns: '1.5fr 0.8fr 0.8fr 1fr 1fr 1.5fr',
|
|
gap: '16px',
|
|
padding: '16px 20px',
|
|
borderBottom: '1px solid var(--border)',
|
|
alignItems: 'center',
|
|
fontSize: '14px',
|
|
},
|
|
email: {
|
|
color: 'var(--text-secondary)',
|
|
},
|
|
date: {
|
|
color: 'var(--text-muted)',
|
|
fontSize: '13px',
|
|
},
|
|
stripeId: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '11px',
|
|
color: 'var(--text-muted)',
|
|
},
|
|
empty: {
|
|
padding: '48px',
|
|
textAlign: 'center',
|
|
color: 'var(--text-muted)',
|
|
},
|
|
};
|