Initial Commit
This commit is contained in:
365
src/pages/admin/AdminPlans.js
Normal file
365
src/pages/admin/AdminPlans.js
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import PlanDialog from '../../components/PlanDialog';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
CreditCard,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Users,
|
||||
Search,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminPlans = () => {
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [filteredPlans, setFilteredPlans] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState('all');
|
||||
const [planDialogOpen, setPlanDialogOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [planToDelete, setPlanToDelete] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
filterPlans();
|
||||
}, [plans, searchQuery, activeFilter]);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const response = await api.get('/admin/subscriptions/plans');
|
||||
setPlans(response.data);
|
||||
} catch (error) {
|
||||
toast.error('Failed to fetch plans');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterPlans = () => {
|
||||
let filtered = plans;
|
||||
|
||||
if (activeFilter !== 'all') {
|
||||
filtered = filtered.filter(plan =>
|
||||
activeFilter === 'active' ? plan.active : !plan.active
|
||||
);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(plan =>
|
||||
plan.name.toLowerCase().includes(query) ||
|
||||
plan.description?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredPlans(filtered);
|
||||
};
|
||||
|
||||
const handleCreatePlan = () => {
|
||||
setSelectedPlan(null);
|
||||
setPlanDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditPlan = (plan) => {
|
||||
setSelectedPlan(plan);
|
||||
setPlanDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (plan) => {
|
||||
setPlanToDelete(plan);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
try {
|
||||
await api.delete(`/admin/subscriptions/plans/${planToDelete.id}`);
|
||||
toast.success('Plan deleted successfully');
|
||||
fetchPlans();
|
||||
setDeleteDialogOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete plan');
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (cents) => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const getBillingCycleLabel = (cycle) => {
|
||||
const labels = {
|
||||
monthly: 'Monthly',
|
||||
quarterly: 'Quarterly',
|
||||
yearly: 'Yearly',
|
||||
lifetime: 'Lifetime'
|
||||
};
|
||||
return labels[cycle] || cycle;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
|
||||
Subscription Plans
|
||||
</h1>
|
||||
<p className="text-lg text-[#6B708D]">
|
||||
Manage membership plans and pricing.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreatePlan}
|
||||
className="bg-[#E07A5F] hover:bg-[#D0694E] text-white rounded-full px-6"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
|
||||
<p className="text-sm text-[#6B708D] mb-2">Total Plans</p>
|
||||
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
|
||||
{plans.length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
|
||||
<p className="text-sm text-[#6B708D] mb-2">Active Plans</p>
|
||||
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
|
||||
{plans.filter(p => p.active).length}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
|
||||
<p className="text-sm text-[#6B708D] mb-2">Total Subscribers</p>
|
||||
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
|
||||
{plans.reduce((sum, p) => sum + (p.subscriber_count || 0), 0)}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
|
||||
<p className="text-sm text-[#6B708D] mb-2">Revenue (Annual Est.)</p>
|
||||
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
|
||||
{formatPrice(
|
||||
plans.reduce((sum, p) => {
|
||||
const annualPrice = p.billing_cycle === 'yearly'
|
||||
? p.price_cents
|
||||
: p.price_cents * 12;
|
||||
return sum + (annualPrice * (p.subscriber_count || 0));
|
||||
}, 0)
|
||||
)}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#6B708D]" />
|
||||
<Input
|
||||
placeholder="Search plans..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-12 h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
|
||||
/>
|
||||
</div>
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]">
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Plans</SelectItem>
|
||||
<SelectItem value="active">Active Only</SelectItem>
|
||||
<SelectItem value="inactive">Inactive Only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Plans Grid */}
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#6B708D]">Loading plans...</p>
|
||||
</div>
|
||||
) : filteredPlans.length > 0 ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPlans.map((plan) => (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className={`p-6 bg-white rounded-2xl border-2 transition-all hover:shadow-lg ${
|
||||
plan.active
|
||||
? 'border-[#EAE0D5] hover:border-[#E07A5F]'
|
||||
: 'border-[#6B708D] opacity-60'
|
||||
}`}
|
||||
>
|
||||
{/* Header with badges */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<Badge
|
||||
className={`${
|
||||
plan.active
|
||||
? 'bg-[#81B29A] text-white'
|
||||
: 'bg-[#6B708D] text-white'
|
||||
}`}
|
||||
>
|
||||
{plan.active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
{plan.subscriber_count > 0 && (
|
||||
<Badge className="bg-[#F2CC8F] text-[#3D405B]">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
{plan.subscriber_count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan Name */}
|
||||
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-2">
|
||||
{plan.name}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
{plan.description && (
|
||||
<p className="text-sm text-[#6B708D] mb-4 line-clamp-2">
|
||||
{plan.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<div className="mb-4">
|
||||
<div className="text-3xl font-bold fraunces text-[#E07A5F]">
|
||||
{formatPrice(plan.price_cents)}
|
||||
</div>
|
||||
<p className="text-sm text-[#6B708D]">
|
||||
{getBillingCycleLabel(plan.billing_cycle)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stripe Integration Status */}
|
||||
<div className="mb-6">
|
||||
{plan.stripe_price_id ? (
|
||||
<Badge className="bg-[#81B29A] text-white text-xs">
|
||||
<DollarSign className="h-3 w-3 mr-1" />
|
||||
Stripe Integrated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-[#F2CC8F] text-[#3D405B] text-xs">
|
||||
Manual/Test Plan
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4 border-t border-[#EAE0D5]">
|
||||
<Button
|
||||
onClick={() => handleEditPlan(plan)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 border-[#A3B1C6] text-[#A3B1C6] hover:bg-[#A3B1C6] hover:text-white rounded-full"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteClick(plan)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 border-[#E07A5F] text-[#E07A5F] hover:bg-[#E07A5F] hover:text-white rounded-full"
|
||||
disabled={plan.subscriber_count > 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Warning for plans with subscribers */}
|
||||
{plan.subscriber_count > 0 && (
|
||||
<p className="text-xs text-[#6B708D] mt-2 text-center">
|
||||
Cannot delete plan with active subscribers
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<CreditCard className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
|
||||
No Plans Found
|
||||
</h3>
|
||||
<p className="text-[#6B708D] mb-6">
|
||||
{searchQuery || activeFilter !== 'all'
|
||||
? 'Try adjusting your filters'
|
||||
: 'Create your first subscription plan to get started'}
|
||||
</p>
|
||||
{!searchQuery && activeFilter === 'all' && (
|
||||
<Button
|
||||
onClick={handleCreatePlan}
|
||||
className="bg-[#E07A5F] hover:bg-[#D0694E] text-white rounded-full px-8"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Plan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan Dialog */}
|
||||
<PlanDialog
|
||||
open={planDialogOpen}
|
||||
onOpenChange={setPlanDialogOpen}
|
||||
plan={selectedPlan}
|
||||
onSuccess={fetchPlans}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{deleteDialogOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<Card className="p-8 bg-white rounded-2xl max-w-md mx-4">
|
||||
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
|
||||
Delete Plan
|
||||
</h2>
|
||||
<p className="text-[#6B708D] mb-6">
|
||||
Are you sure you want to delete "{planToDelete?.name}"? This action
|
||||
will deactivate the plan and it won't be available for new subscriptions.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteConfirm}
|
||||
className="flex-1 bg-[#E07A5F] hover:bg-[#D0694E] text-white"
|
||||
>
|
||||
Delete Plan
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPlans;
|
||||
Reference in New Issue
Block a user