diff --git a/src/app/dashboard/subscriptions/page.tsx b/src/app/dashboard/subscriptions/page.tsx new file mode 100644 index 0000000..29ba43a --- /dev/null +++ b/src/app/dashboard/subscriptions/page.tsx @@ -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 = { + 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 ( + + {status} + + ); +}; + +export default function SubscriptionsPage() { + const router = useRouter(); + const [subscriptions, setSubscriptions] = useState([]); + const [stats, setStats] = useState(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 ( +
+ {/* Header */} +
+

Subscriptions

+

Manage agent subscriptions and view revenue

+
+ + {/* Stats Cards */} + {stats && ( +
+
+

Active Subscriptions

+

{stats.activeSubscriptions}

+
+
+

Total Revenue

+

{formatCurrency(stats.totalRevenue)}

+
+
+

This Month

+

{formatCurrency(stats.monthlyRevenue)}

+
+
+ )} + + {/* Filters */} +
+
+

+ All Subscriptions ({total}) +

+ +
+ + {error && ( +
+

{error}

+
+ )} + + {loading ? ( +
+
+
+ ) : subscriptions.length === 0 ? ( +
+ No subscriptions found +
+ ) : ( + <> +
+ + + + + + + + + + + + + {subscriptions.map((sub) => ( + + + + + + + + + ))} + +
AgentPlanStatusAmountPeriod EndCreated
+
+
{getAgentName(sub)}
+
{sub.user?.email}
+
+
+ {sub.plan?.name || '-'} + + {statusBadge(sub.status)} + {sub.cancelAtPeriodEnd && ( + (Canceling) + )} + + {sub.plan ? formatCurrency(sub.plan.amount / 100) : '-'} + {sub.plan && /{sub.plan.interval}} + + {formatDate(sub.currentPeriodEnd)} + + {formatDate(sub.createdAt)} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ + +
+
+ )} + + )} +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 88fffe6..6579756 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -74,6 +74,20 @@ const menuItems = [ ), }, + { + name: 'Subscriptions', + href: '/dashboard/subscriptions', + icon: ( + + + + ), + }, { name: 'Support Chat', href: '/dashboard/support-chat', diff --git a/src/services/index.ts b/src/services/index.ts index 8b18efd..d4029c2 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -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'; diff --git a/src/services/stripe.service.ts b/src/services/stripe.service.ts new file mode 100644 index 0000000..c27bda6 --- /dev/null +++ b/src/services/stripe.service.ts @@ -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 { + 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>( + `/stripe/admin/subscriptions?${queryParams.toString()}`, + ); + return response.data.data; + } + + async getPlans(): Promise { + const response = await api.get>('/stripe/plans'); + return response.data.data; + } + + async getRevenueStats(): Promise { + const response = await api.get>('/stripe/admin/stats'); + return response.data.data; + } +} + +export const adminStripeService = new AdminStripeService();