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>
|
</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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
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