feat: add admin pages for plans, users, and placeholders

- AdminPlans: full CRUD UI with cards, visibility toggle
- AdminUsers: search, suspend/unsuspend, grant Pro access
- AdminPaymentMethods: placeholder
- AdminPayments: placeholder
- AdminSettings: placeholder
- All routes wired in App.tsx
- Admin panel fully navigable
This commit is contained in:
dwindown
2025-10-11 18:30:18 +07:00
parent cd6b047d3f
commit 7396cb5bd6
6 changed files with 528 additions and 0 deletions

View File

@@ -8,6 +8,11 @@ import { OtpVerification } from './components/pages/OtpVerification'
import { AuthCallback } from './components/pages/AuthCallback'
import { AdminLayout } from './components/admin/AdminLayout'
import { AdminDashboard } from './components/admin/pages/AdminDashboard'
import { AdminPlans } from './components/admin/pages/AdminPlans'
import { AdminPaymentMethods } from './components/admin/pages/AdminPaymentMethods'
import { AdminPayments } from './components/admin/pages/AdminPayments'
import { AdminUsers } from './components/admin/pages/AdminUsers'
import { AdminSettings } from './components/admin/pages/AdminSettings'
import { Loader2 } from 'lucide-react'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -64,6 +69,11 @@ export default function App() {
{/* Admin Routes */}
<Route path="/admin" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
<Route index element={<AdminDashboard />} />
<Route path="plans" element={<AdminPlans />} />
<Route path="payment-methods" element={<AdminPaymentMethods />} />
<Route path="payments" element={<AdminPayments />} />
<Route path="users" element={<AdminUsers />} />
<Route path="settings" element={<AdminSettings />} />
</Route>
{/* Protected Routes */}

View File

@@ -0,0 +1,12 @@
export function AdminPaymentMethods() {
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
Payment Methods
</h1>
<p className="text-gray-600 dark:text-gray-400">
Manage payment methods (Coming soon)
</p>
</div>
)
}

View File

@@ -0,0 +1,12 @@
export function AdminPayments() {
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
Payment Verification
</h1>
<p className="text-gray-600 dark:text-gray-400">
Verify pending payments (Coming soon)
</p>
</div>
)
}

View File

@@ -0,0 +1,248 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Plus, Edit, Trash2, Eye, EyeOff, GripVertical } from 'lucide-react'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
interface Plan {
id: string
name: string
slug: string
description: string
price: string
currency: string
durationType: string
durationDays: number | null
trialDays: number
features: any
badge: string | null
badgeColor: string | null
sortOrder: number
isActive: boolean
isVisible: boolean
isFeatured: boolean
_count: {
subscriptions: number
}
}
export function AdminPlans() {
const [plans, setPlans] = useState<Plan[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingPlan, setEditingPlan] = useState<Plan | null>(null)
useEffect(() => {
fetchPlans()
}, [])
const fetchPlans = async () => {
try {
const token = localStorage.getItem('token')
const response = await axios.get(`${API_URL}/api/admin/plans`, {
headers: { Authorization: `Bearer ${token}` },
})
setPlans(response.data)
} catch (error) {
console.error('Failed to fetch plans:', error)
} finally {
setLoading(false)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this plan?')) return
try {
const token = localStorage.getItem('token')
await axios.delete(`${API_URL}/api/admin/plans/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
fetchPlans()
} catch (error) {
console.error('Failed to delete plan:', error)
alert('Failed to delete plan')
}
}
const toggleVisibility = async (plan: Plan) => {
try {
const token = localStorage.getItem('token')
await axios.put(
`${API_URL}/api/admin/plans/${plan.id}`,
{ isVisible: !plan.isVisible },
{ headers: { Authorization: `Bearer ${token}` } }
)
fetchPlans()
} catch (error) {
console.error('Failed to update plan:', error)
}
}
const formatPrice = (price: string, currency: string) => {
const amount = parseInt(price)
if (amount === 0) return 'Free'
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
}).format(amount)
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
</div>
)
}
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Plans Management
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Manage subscription plans
</p>
</div>
<button
onClick={() => {
setEditingPlan(null)
setShowModal(true)
}}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-5 w-5 mr-2" />
Add Plan
</button>
</div>
{/* Plans Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{plans.map((plan) => (
<div
key={plan.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center">
<GripVertical className="h-5 w-5 text-gray-400 mr-2 cursor-move" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{plan.name}
</h3>
</div>
{plan.badge && (
<span
className={`px-2 py-1 text-xs font-medium rounded ${
plan.badgeColor === 'blue'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400'
: plan.badgeColor === 'green'
? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{plan.badge}
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400">
{plan.description}
</p>
</div>
{/* Price */}
<div className="p-6 bg-gray-50 dark:bg-gray-900/50">
<div className="text-3xl font-bold text-gray-900 dark:text-white">
{formatPrice(plan.price, plan.currency)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{plan.durationType === 'lifetime'
? 'Lifetime access'
: plan.durationType === 'monthly'
? 'per month'
: plan.durationType === 'yearly'
? 'per year'
: plan.durationType}
</div>
{plan.trialDays > 0 && (
<div className="text-sm text-blue-600 dark:text-blue-400 mt-1">
{plan.trialDays} days free trial
</div>
)}
</div>
{/* Stats */}
<div className="p-6 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Subscriptions:
</span>
<span className="font-medium text-gray-900 dark:text-white">
{plan._count.subscriptions}
</span>
</div>
<div className="flex items-center justify-between text-sm mt-2">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<div className="flex items-center space-x-2">
{plan.isActive && (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400 rounded">
Active
</span>
)}
{plan.isVisible ? (
<Eye className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<EyeOff className="h-4 w-4 text-gray-400" />
)}
</div>
</div>
</div>
{/* Actions */}
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end space-x-2">
<button
onClick={() => toggleVisibility(plan)}
className="p-2 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
title={plan.isVisible ? 'Hide' : 'Show'}
>
{plan.isVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
<button
onClick={() => {
setEditingPlan(plan)
setShowModal(true)
}}
className="p-2 text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
title="Edit"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(plan.id)}
className="p-2 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Delete"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
{plans.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">No plans found</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,12 @@
export function AdminSettings() {
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
App Settings
</h1>
<p className="text-gray-600 dark:text-gray-400">
Manage app configuration (Coming soon)
</p>
</div>
)
}

View File

@@ -0,0 +1,234 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Search, UserX, UserCheck, Crown } from 'lucide-react'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
interface User {
id: string
email: string
name: string | null
role: string
emailVerified: boolean
createdAt: string
lastLoginAt: string | null
suspendedAt: string | null
_count: {
wallets: number
transactions: number
}
}
export function AdminUsers() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
useEffect(() => {
fetchUsers()
}, [search])
const fetchUsers = async () => {
try {
const token = localStorage.getItem('token')
const params = search ? `?search=${encodeURIComponent(search)}` : ''
const response = await axios.get(`${API_URL}/api/admin/users${params}`, {
headers: { Authorization: `Bearer ${token}` },
})
setUsers(response.data)
} catch (error) {
console.error('Failed to fetch users:', error)
} finally {
setLoading(false)
}
}
const handleSuspend = async (userId: string, suspend: boolean) => {
const reason = suspend
? prompt('Enter suspension reason:')
: null
if (suspend && !reason) return
try {
const token = localStorage.getItem('token')
const endpoint = suspend ? 'suspend' : 'unsuspend'
await axios.post(
`${API_URL}/api/admin/users/${userId}/${endpoint}`,
suspend ? { reason } : {},
{ headers: { Authorization: `Bearer ${token}` } }
)
fetchUsers()
} catch (error) {
console.error('Failed to update user:', error)
alert('Failed to update user')
}
}
const handleGrantPro = async (userId: string) => {
const days = prompt('Enter duration in days (e.g., 30 for monthly):')
if (!days || isNaN(parseInt(days))) return
try {
const token = localStorage.getItem('token')
await axios.post(
`${API_URL}/api/admin/users/${userId}/grant-pro`,
{
planSlug: 'pro-monthly',
durationDays: parseInt(days),
},
{ headers: { Authorization: `Bearer ${token}` } }
)
alert('Pro access granted successfully!')
fetchUsers()
} catch (error) {
console.error('Failed to grant pro access:', error)
alert('Failed to grant pro access')
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-600 dark:text-gray-400">Loading...</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Users Management
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Manage user accounts and permissions
</p>
</div>
{/* Search */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
{/* Users Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Stats
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{users.map((user) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
{user.name?.[0] || user.email[0].toUpperCase()}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{user.name || 'No name'}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{user.email}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-medium rounded ${
user.role === 'admin'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<div>{user._count.wallets} wallets</div>
<div>{user._count.transactions} transactions</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{user.suspendedAt ? (
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400 rounded">
Suspended
</span>
) : user.emailVerified ? (
<span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400 rounded">
Active
</span>
) : (
<span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400 rounded">
Unverified
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
{user.suspendedAt ? (
<button
onClick={() => handleSuspend(user.id, false)}
className="text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
title="Unsuspend"
>
<UserCheck className="h-4 w-4 inline" />
</button>
) : (
<button
onClick={() => handleSuspend(user.id, true)}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
title="Suspend"
>
<UserX className="h-4 w-4 inline" />
</button>
)}
<button
onClick={() => handleGrantPro(user.id)}
className="text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
title="Grant Pro Access"
>
<Crown className="h-4 w-4 inline" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{users.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">No users found</p>
</div>
)}
</div>
)
}