feat: add admin subscriptions management page with new Stripe service and sidebar navigation.
This commit is contained in:
234
src/app/dashboard/subscriptions/page.tsx
Normal file
234
src/app/dashboard/subscriptions/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
adminStripeService,
|
||||
AdminSubscription,
|
||||
RevenueStats,
|
||||
getErrorMessage,
|
||||
} from '@/services';
|
||||
|
||||
const STATUS_OPTIONS = ['', 'ACTIVE', 'PAST_DUE', 'CANCELED', 'INCOMPLETE', 'UNPAID'];
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-100 text-green-800',
|
||||
PAST_DUE: 'bg-yellow-100 text-yellow-800',
|
||||
CANCELED: 'bg-red-100 text-red-800',
|
||||
INCOMPLETE: 'bg-gray-100 text-gray-800',
|
||||
UNPAID: 'bg-red-100 text-red-800',
|
||||
TRIALING: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${styles[status] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const router = useRouter();
|
||||
const [subscriptions, setSubscriptions] = useState<AdminSubscription[]>([]);
|
||||
const [stats, setStats] = useState<RevenueStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [statusFilter, page]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [subsResult, statsResult] = await Promise.all([
|
||||
adminStripeService.getAllSubscriptions({
|
||||
status: statusFilter || undefined,
|
||||
page,
|
||||
limit: 20,
|
||||
}),
|
||||
adminStripeService.getRevenueStats(),
|
||||
]);
|
||||
setSubscriptions(subsResult.data);
|
||||
setTotalPages(subsResult.totalPages);
|
||||
setTotal(subsResult.total);
|
||||
setStats(statsResult);
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
setError(errorMessage);
|
||||
if (errorMessage.includes('Unauthorized')) {
|
||||
router.push('/login');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const getAgentName = (sub: AdminSubscription) => {
|
||||
const profile = sub.user?.agentProfile;
|
||||
if (profile?.firstName || profile?.lastName) {
|
||||
return `${profile.firstName || ''} ${profile.lastName || ''}`.trim();
|
||||
}
|
||||
return sub.user?.email || 'Unknown';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Subscriptions</h1>
|
||||
<p className="text-gray-600 mt-1">Manage agent subscriptions and view revenue</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-600">Active Subscriptions</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{stats.activeSubscriptions}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-600">Total Revenue</p>
|
||||
<p className="text-2xl font-bold text-green-600 mt-1">{formatCurrency(stats.totalRevenue)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-sm text-gray-600">This Month</p>
|
||||
<p className="text-2xl font-bold text-blue-600 mt-1">{formatCurrency(stats.monthlyRevenue)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
All Subscriptions ({total})
|
||||
</h2>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-gray-900 bg-white text-sm"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{STATUS_OPTIONS.filter(Boolean).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
) : subscriptions.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No subscriptions found
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Agent</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Plan</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Period End</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{subscriptions.map((sub) => (
|
||||
<tr key={sub.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{getAgentName(sub)}</div>
|
||||
<div className="text-sm text-gray-500">{sub.user?.email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{sub.plan?.name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{statusBadge(sub.status)}
|
||||
{sub.cancelAtPeriodEnd && (
|
||||
<span className="ml-2 text-xs text-orange-600">(Canceling)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{sub.plan ? formatCurrency(sub.plan.amount / 100) : '-'}
|
||||
{sub.plan && <span className="text-gray-500">/{sub.plan.interval}</span>}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(sub.currentPeriodEnd)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(sub.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-700">
|
||||
Page {page} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,20 @@ const menuItems = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Subscriptions',
|
||||
href: '/dashboard/subscriptions',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Support Chat',
|
||||
href: '/dashboard/support-chat',
|
||||
|
||||
@@ -88,3 +88,12 @@ export type {
|
||||
// Support Chat Service
|
||||
export { supportChatService } from './support-chat.service';
|
||||
export type { SupportChat, SupportMessage, MessagesResponse } from './support-chat.service';
|
||||
|
||||
// Stripe Service
|
||||
export { adminStripeService } from './stripe.service';
|
||||
export type {
|
||||
SubscriptionPlan,
|
||||
AdminSubscription,
|
||||
SubscriptionsListResponse,
|
||||
RevenueStats,
|
||||
} from './stripe.service';
|
||||
|
||||
81
src/services/stripe.service.ts
Normal file
81
src/services/stripe.service.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import api, { ApiResponse } from './api';
|
||||
|
||||
export interface SubscriptionPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
stripePriceId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
interval: string;
|
||||
features: string | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface AdminSubscription {
|
||||
id: string;
|
||||
userId: string;
|
||||
planId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId: string | null;
|
||||
status: string;
|
||||
currentPeriodStart: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
canceledAt: string | null;
|
||||
createdAt: string;
|
||||
plan: SubscriptionPlan;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
agentProfile: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
} | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubscriptionsListResponse {
|
||||
data: AdminSubscription[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface RevenueStats {
|
||||
activeSubscriptions: number;
|
||||
totalRevenue: number;
|
||||
monthlyRevenue: number;
|
||||
}
|
||||
|
||||
class AdminStripeService {
|
||||
async getAllSubscriptions(params?: {
|
||||
status?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<SubscriptionsListResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.status) queryParams.set('status', params.status);
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.limit) queryParams.set('limit', params.limit.toString());
|
||||
|
||||
const response = await api.get<ApiResponse<SubscriptionsListResponse>>(
|
||||
`/stripe/admin/subscriptions?${queryParams.toString()}`,
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getPlans(): Promise<SubscriptionPlan[]> {
|
||||
const response = await api.get<ApiResponse<SubscriptionPlan[]>>('/stripe/plans');
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
async getRevenueStats(): Promise<RevenueStats> {
|
||||
const response = await api.get<ApiResponse<RevenueStats>>('/stripe/admin/stats');
|
||||
return response.data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const adminStripeService = new AdminStripeService();
|
||||
Reference in New Issue
Block a user