feat: add admin subscriptions management page with new Stripe service and sidebar navigation.

This commit is contained in:
pradeepkumar
2026-03-07 10:10:15 +05:30
parent 9f763e3f1e
commit 10b671531b
4 changed files with 338 additions and 0 deletions

View 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>
);
}

View File

@@ -74,6 +74,20 @@ const menuItems = [
</svg> </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', name: 'Support Chat',
href: '/dashboard/support-chat', href: '/dashboard/support-chat',

View File

@@ -88,3 +88,12 @@ export type {
// Support Chat Service // Support Chat Service
export { supportChatService } from './support-chat.service'; export { supportChatService } from './support-chat.service';
export type { SupportChat, SupportMessage, MessagesResponse } 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';

View 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();