feat: add subscription plans management page with Stripe synchronization functionality
This commit is contained in:
164
src/app/dashboard/subscription-plans/page.tsx
Normal file
164
src/app/dashboard/subscription-plans/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { adminStripeService, SubscriptionPlan, getErrorMessage } from '@/services';
|
||||||
|
|
||||||
|
export default function SubscriptionPlansPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [syncingId, setSyncingId] = useState<string | null>(null);
|
||||||
|
const [syncMessage, setSyncMessage] = useState<{ planId: string; text: string; isError?: boolean } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const data = await adminStripeService.getAdminPlans();
|
||||||
|
setPlans(data);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = getErrorMessage(err);
|
||||||
|
setError(msg);
|
||||||
|
if (msg.includes('Unauthorized')) router.push('/login');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSync = async (plan: SubscriptionPlan) => {
|
||||||
|
setSyncingId(plan.id);
|
||||||
|
setSyncMessage(null);
|
||||||
|
try {
|
||||||
|
const updated = await adminStripeService.syncPlanFromStripe(plan.id);
|
||||||
|
setPlans((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
|
||||||
|
setSyncMessage({
|
||||||
|
planId: plan.id,
|
||||||
|
text: `Synced: ${formatCurrency(updated.amount / 100)} / ${updated.interval}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setSyncMessage({ planId: plan.id, text: getErrorMessage(err), isError: true });
|
||||||
|
} finally {
|
||||||
|
setSyncingId(null);
|
||||||
|
setTimeout(() => setSyncMessage(null), 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) =>
|
||||||
|
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Subscription Plans</h1>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
Manage subscription plans. Use "Sync from Stripe" to pull the latest amount,
|
||||||
|
currency, interval, name, and description directly from Stripe for a given price.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">All Plans ({plans.length})</h2>
|
||||||
|
</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>
|
||||||
|
) : plans.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">No plans 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">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Stripe Price ID
|
||||||
|
</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">
|
||||||
|
Interval
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Active
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<tr key={plan.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{plan.name}</div>
|
||||||
|
{plan.description && (
|
||||||
|
<div className="text-sm text-gray-500">{plan.description}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<code className="text-xs text-gray-700 bg-gray-100 px-2 py-1 rounded">
|
||||||
|
{plan.stripePriceId}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{formatCurrency(plan.amount / 100)}
|
||||||
|
<span className="text-gray-500 ml-1">{plan.currency.toUpperCase()}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{plan.interval}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
|
plan.isActive ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{plan.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSync(plan)}
|
||||||
|
disabled={syncingId === plan.id}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{syncingId === plan.id ? 'Syncing...' : 'Sync from Stripe'}
|
||||||
|
</button>
|
||||||
|
{syncMessage && syncMessage.planId === plan.id && (
|
||||||
|
<p
|
||||||
|
className={`mt-2 text-xs ${
|
||||||
|
syncMessage.isError ? 'text-red-600' : 'text-green-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{syncMessage.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -92,6 +92,20 @@ const menuItems = [
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Subscription Plans',
|
||||||
|
href: '/dashboard/subscription-plans',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Contact Messages',
|
name: 'Contact Messages',
|
||||||
href: '/dashboard/contact-messages',
|
href: '/dashboard/contact-messages',
|
||||||
|
|||||||
@@ -72,6 +72,18 @@ class AdminStripeService {
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAdminPlans(): Promise<SubscriptionPlan[]> {
|
||||||
|
const response = await api.get<ApiResponse<SubscriptionPlan[]>>('/stripe/admin/plans');
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncPlanFromStripe(planId: string): Promise<SubscriptionPlan> {
|
||||||
|
const response = await api.post<ApiResponse<SubscriptionPlan>>(
|
||||||
|
`/stripe/admin/plans/${planId}/sync`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
async getRevenueStats(): Promise<RevenueStats> {
|
async getRevenueStats(): Promise<RevenueStats> {
|
||||||
const response = await api.get<ApiResponse<RevenueStats>>('/stripe/admin/stats');
|
const response = await api.get<ApiResponse<RevenueStats>>('/stripe/admin/stats');
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user