Files
membership-fe/src/pages/admin/AdminPlans.js

389 lines
14 KiB
JavaScript

import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext';
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 { hasPermission } = useAuth();
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',
custom: 'Custom Billing Cycle'
};
return labels[cycle] || cycle;
};
const formatCustomCycleDates = (plan) => {
if (!plan.custom_cycle_enabled) return null;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const startMonth = months[plan.custom_cycle_start_month - 1];
const endMonth = months[plan.custom_cycle_end_month - 1];
return `${startMonth} ${plan.custom_cycle_start_day} - ${endMonth} ${plan.custom_cycle_end_day}`;
};
return (
<>
<div className="mb-8">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-4xl md:text-5xl font-semibold text-primary mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Subscription Plans
</h1>
<p className="text-lg text-muted-foreground" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage membership plans and pricing.
</p>
</div>
{hasPermission('subscriptions.plans') && (
<Button
onClick={handleCreatePlan}
className="bg-chart-6 text-primary hover:bg-background rounded-full px-6"
>
<Plus className="h-4 w-4 mr-2" />
Create Plan
</Button>
)}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<Card className="p-6 bg-background rounded-2xl border border-chart-6">
<p className="text-sm text-muted-foreground mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Plans</p>
<p className="text-3xl font-semibold text-primary" style={{ fontFamily: "'Inter', sans-serif" }}>
{plans.length}
</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-chart-6">
<p className="text-sm text-muted-foreground mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Plans</p>
<p className="text-3xl font-semibold text-primary" style={{ fontFamily: "'Inter', sans-serif" }}>
{plans.filter(p => p.active).length}
</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-chart-6">
<p className="text-sm text-muted-foreground mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Subscribers</p>
<p className="text-3xl font-semibold text-primary" style={{ fontFamily: "'Inter', sans-serif" }}>
{plans.reduce((sum, p) => sum + (p.subscriber_count || 0), 0)}
</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-chart-6">
<p className="text-sm text-muted-foreground mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Revenue (Annual Est.)</p>
<p className="text-3xl font-semibold text-primary" style={{ fontFamily: "'Inter', sans-serif" }}>
{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-background rounded-2xl border border-chart-6 mb-8">
<div className="grid grid-cols-1 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-muted-foreground" />
<Input
placeholder="Search plans..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-chart-6 focus:border-muted-foreground"
/>
</div>
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-chart-6">
<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-muted-foreground" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
</div>
) : filteredPlans.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPlans.map((plan) => (
<Card
key={plan.id}
className={`p-6 bg-background rounded-2xl border-2 transition-all hover:shadow-lg ${plan.active
? 'border-chart-6 hover:border-muted-foreground'
: 'border-gray-400 opacity-60'
}`}
>
{/* Header with badges */}
<div className="flex flex-wrap gap-2 mb-4">
<Badge
className={`${plan.active
? 'bg-success text-white'
: 'bg-gray-400 text-white'
}`}
>
{plan.active ? 'Active' : 'Inactive'}
</Badge>
{plan.subscriber_count > 0 && (
<Badge className="bg-chart-6 text-primary">
<Users className="h-3 w-3 mr-1" />
{plan.subscriber_count}
</Badge>
)}
{plan.custom_cycle_enabled && (
<Badge className="bg-muted-foreground text-white">
Custom Dates
</Badge>
)}
{plan.allow_donation && (
<Badge className="bg-accent text-white">
Donations Enabled
</Badge>
)}
</div>
{/* Plan Name */}
<h3 className="text-2xl font-semibold text-primary mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{plan.name}
</h3>
{/* Description */}
{plan.description && (
<p className="text-sm text-muted-foreground mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{plan.description}
</p>
)}
{/* Price */}
<div className="mb-4">
<div className="flex items-baseline gap-2">
<div className="text-3xl font-bold text-accent" style={{ fontFamily: "'Inter', sans-serif" }}>
{formatPrice(plan.minimum_price_cents || plan.price_cents)}
</div>
{plan.suggested_price_cents && plan.suggested_price_cents > plan.minimum_price_cents && (
<div className="text-lg text-muted-foreground" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
(Suggested: {formatPrice(plan.suggested_price_cents)})
</div>
)}
</div>
<p className="text-sm text-muted-foreground" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{getBillingCycleLabel(plan.billing_cycle)}
</p>
{plan.custom_cycle_enabled && (
<p className="text-xs text-muted-foreground font-mono mt-1">
{formatCustomCycleDates(plan)}
</p>
)}
</div>
{/* Actions */}
{hasPermission('subscriptions.plans') && (
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-chart-6">
<Button
onClick={() => handleEditPlan(plan)}
variant="outline"
size="sm"
className="flex-1 border-muted-foreground text-muted-foreground hover:bg-muted-foreground 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-red-500 text-red-500 hover:bg-red-500 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-muted-foreground mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Cannot delete plan with active subscribers
</p>
)}
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<CreditCard className="h-20 w-20 text-chart-6 mx-auto mb-6" />
<h3 className="text-2xl font-semibold text-primary mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
No Plans Found
</h3>
<p className="text-muted-foreground mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{searchQuery || activeFilter !== 'all'
? 'Try adjusting your filters'
: 'Create your first subscription plan to get started'}
</p>
{!searchQuery && activeFilter === 'all' && hasPermission('subscriptions.plans') && (
<Button
onClick={handleCreatePlan}
className="bg-chart-6 text-primary hover:bg-background 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 p-4">
<Card className="p-6 sm:p-8 bg-background rounded-2xl max-w-md w-full">
<h2 className="text-xl sm:text-2xl font-semibold text-primary mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Delete Plan
</h2>
<p className="text-sm sm:text-base text-muted-foreground mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
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 flex-col-reverse sm:flex-row gap-3 sm:gap-4">
<Button
onClick={() => setDeleteDialogOpen(false)}
variant="outline"
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleDeleteConfirm}
className="flex-1 bg-red-500 hover:bg-red-600 text-white"
>
Delete Plan
</Button>
</div>
</Card>
</div>
)}
</>
);
};
export default AdminPlans;