- Updated color classes to use CSS variables for better maintainability and theming. - Refactored component styles in MembersDirectory.js to enhance visual consistency. - Adjusted loading states and empty states in NewsletterArchive.js for improved user experience. - Added new brand colors to tailwind.config.js for future use.
788 lines
32 KiB
JavaScript
788 lines
32 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useAuth } from '../../context/AuthContext';
|
|
import { Card } from '../../components/ui/card';
|
|
import { Button } from '../../components/ui/button';
|
|
import { Input } from '../../components/ui/input';
|
|
import { Label } from '../../components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '../../components/ui/select';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '../../components/ui/dialog';
|
|
import { Badge } from '../../components/ui/badge';
|
|
import api from '../../utils/api';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
DollarSign,
|
|
CreditCard,
|
|
TrendingUp,
|
|
Heart,
|
|
Search,
|
|
Loader2,
|
|
Calendar,
|
|
Edit,
|
|
XCircle,
|
|
Download,
|
|
FileDown,
|
|
AlertTriangle,
|
|
Info
|
|
} from 'lucide-react';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '../../components/ui/dropdown-menu';
|
|
|
|
const AdminSubscriptions = () => {
|
|
const { hasPermission } = useAuth();
|
|
const [subscriptions, setSubscriptions] = useState([]);
|
|
const [filteredSubscriptions, setFilteredSubscriptions] = useState([]);
|
|
const [plans, setPlans] = useState([]);
|
|
const [stats, setStats] = useState({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState('all');
|
|
const [planFilter, setPlanFilter] = useState('all');
|
|
const [exporting, setExporting] = useState(false);
|
|
|
|
// Edit subscription dialog state
|
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
const [selectedSubscription, setSelectedSubscription] = useState(null);
|
|
const [editFormData, setEditFormData] = useState({
|
|
status: '',
|
|
end_date: ''
|
|
});
|
|
const [isUpdating, setIsUpdating] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
filterSubscriptions();
|
|
}, [searchQuery, statusFilter, planFilter, subscriptions]);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [subsResponse, statsResponse, plansResponse] = await Promise.all([
|
|
api.get('/admin/subscriptions'),
|
|
api.get('/admin/subscriptions/stats'),
|
|
api.get('/admin/subscriptions/plans')
|
|
]);
|
|
|
|
setSubscriptions(subsResponse.data);
|
|
setStats(statsResponse.data);
|
|
setPlans(plansResponse.data);
|
|
} catch (error) {
|
|
console.error('Failed to fetch subscription data:', error);
|
|
toast.error('Failed to load subscription data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filterSubscriptions = () => {
|
|
let filtered = [...subscriptions];
|
|
|
|
// Search filter (member name or email)
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
filtered = filtered.filter(sub =>
|
|
sub.user.first_name.toLowerCase().includes(query) ||
|
|
sub.user.last_name.toLowerCase().includes(query) ||
|
|
sub.user.email.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
// Status filter
|
|
if (statusFilter !== 'all') {
|
|
filtered = filtered.filter(sub => sub.status === statusFilter);
|
|
}
|
|
|
|
// Plan filter
|
|
if (planFilter !== 'all') {
|
|
filtered = filtered.filter(sub => sub.plan.id === planFilter);
|
|
}
|
|
|
|
setFilteredSubscriptions(filtered);
|
|
};
|
|
|
|
const handleEdit = (subscription) => {
|
|
setSelectedSubscription(subscription);
|
|
setEditFormData({
|
|
status: subscription.status,
|
|
end_date: subscription.end_date ? new Date(subscription.end_date).toISOString().split('T')[0] : ''
|
|
});
|
|
setEditDialogOpen(true);
|
|
};
|
|
|
|
const handleSaveSubscription = async () => {
|
|
if (!selectedSubscription) return;
|
|
|
|
// Check if status is changing
|
|
const statusChanged = editFormData.status !== selectedSubscription.status;
|
|
|
|
if (statusChanged) {
|
|
// Get status change consequences
|
|
let warningMessage = '';
|
|
let confirmText = '';
|
|
|
|
if (editFormData.status === 'cancelled') {
|
|
warningMessage = `⚠️ CRITICAL: Cancelling this subscription will:
|
|
|
|
• Set the user's status to INACTIVE
|
|
• Remove their member access immediately
|
|
• Stop all future billing
|
|
• This action affects: ${selectedSubscription.user.first_name} ${selectedSubscription.user.last_name} (${selectedSubscription.user.email})
|
|
|
|
Current Status: ${selectedSubscription.status.toUpperCase()}
|
|
New Status: CANCELLED
|
|
|
|
Are you absolutely sure you want to proceed?`;
|
|
confirmText = 'Yes, Cancel Subscription';
|
|
} else if (editFormData.status === 'expired') {
|
|
warningMessage = `⚠️ WARNING: Setting this subscription to EXPIRED will:
|
|
|
|
• Set the user's status to INACTIVE
|
|
• Remove their member access
|
|
• Mark the subscription as ended
|
|
• This action affects: ${selectedSubscription.user.first_name} ${selectedSubscription.user.last_name} (${selectedSubscription.user.email})
|
|
|
|
Current Status: ${selectedSubscription.status.toUpperCase()}
|
|
New Status: EXPIRED
|
|
|
|
Are you sure you want to proceed?`;
|
|
confirmText = 'Yes, Mark as Expired';
|
|
} else if (editFormData.status === 'active') {
|
|
warningMessage = `✓ Activating this subscription will:
|
|
|
|
• Set the user's status to ACTIVE
|
|
• Grant full member access
|
|
• Resume billing if applicable
|
|
• This action affects: ${selectedSubscription.user.first_name} ${selectedSubscription.user.last_name} (${selectedSubscription.user.email})
|
|
|
|
Current Status: ${selectedSubscription.status.toUpperCase()}
|
|
New Status: ACTIVE
|
|
|
|
Proceed with activation?`;
|
|
confirmText = 'Yes, Activate Subscription';
|
|
}
|
|
|
|
// Show confirmation dialog
|
|
const confirmed = window.confirm(warningMessage);
|
|
if (!confirmed) {
|
|
return; // User cancelled
|
|
}
|
|
}
|
|
|
|
setIsUpdating(true);
|
|
try {
|
|
await api.put(`/admin/subscriptions/${selectedSubscription.id}`, {
|
|
status: editFormData.status,
|
|
end_date: editFormData.end_date ? new Date(editFormData.end_date).toISOString() : null
|
|
});
|
|
|
|
toast.success('Subscription updated successfully');
|
|
setEditDialogOpen(false);
|
|
fetchData(); // Refresh data
|
|
} catch (error) {
|
|
console.error('Failed to update subscription:', error);
|
|
toast.error(error.response?.data?.detail || 'Failed to update subscription');
|
|
} finally {
|
|
setIsUpdating(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelSubscription = async (subscriptionId) => {
|
|
if (!window.confirm('Are you sure you want to cancel this subscription? This will also set the user status to inactive.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.post(`/admin/subscriptions/${subscriptionId}/cancel`);
|
|
toast.success('Subscription cancelled successfully');
|
|
fetchData(); // Refresh data
|
|
} catch (error) {
|
|
console.error('Failed to cancel subscription:', error);
|
|
toast.error(error.response?.data?.detail || 'Failed to cancel subscription');
|
|
}
|
|
};
|
|
|
|
const handleExport = async (exportType) => {
|
|
setExporting(true);
|
|
try {
|
|
const params = exportType === 'current' ? {
|
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
plan_id: planFilter !== 'all' ? planFilter : undefined,
|
|
search: searchQuery || undefined
|
|
} : {};
|
|
|
|
const response = await api.get('/admin/subscriptions/export', {
|
|
params,
|
|
responseType: 'blob'
|
|
});
|
|
|
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', `subscriptions_export_${new Date().toISOString().split('T')[0]}.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
toast.success('Subscriptions exported successfully');
|
|
} catch (error) {
|
|
console.error('Failed to export subscriptions:', error);
|
|
toast.error('Failed to export subscriptions');
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
const formatPrice = (cents) => {
|
|
return `$${(cents / 100).toFixed(2)}`;
|
|
};
|
|
|
|
const formatDate = (dateString) => {
|
|
if (!dateString) return 'N/A';
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
};
|
|
|
|
const getStatusBadgeVariant = (status) => {
|
|
const variants = {
|
|
active: 'default',
|
|
cancelled: 'destructive',
|
|
expired: 'secondary'
|
|
};
|
|
return variants[status] || 'outline';
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<Loader2 className="h-12 w-12 animate-spin text-var(--purple-lavender)" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-3xl font-semibold text-var(--purple-ink)" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Subscription Management
|
|
</h1>
|
|
<p className="text-var(--purple-lavender) mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
View and manage all member subscriptions
|
|
</p>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid md:grid-cols-4 gap-6">
|
|
<Card className="p-6 bg-background rounded-2xl border-2 border-var(--neutral-800)">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Total Subscriptions
|
|
</p>
|
|
<p className="text-3xl font-bold text-var(--purple-ink) mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{stats.total || 0}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-var(--neutral-800)/20 rounded-full">
|
|
<CreditCard className="h-6 w-6 text-var(--purple-lavender)" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6 bg-background rounded-2xl border-2 border-var(--neutral-800)">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Active Members
|
|
</p>
|
|
<p className="text-3xl font-bold text-var(--green-light) mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{stats.active || 0}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-var(--green-light)/10 rounded-full">
|
|
<TrendingUp className="h-6 w-6 text-var(--green-light)" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6 bg-background rounded-2xl border-2 border-var(--neutral-800)">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Total Revenue
|
|
</p>
|
|
<p className="text-3xl font-bold text-var(--purple-ink) mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{formatPrice(stats.total_revenue || 0)}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-var(--neutral-800)/20 rounded-full">
|
|
<DollarSign className="h-6 w-6 text-var(--purple-lavender)" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6 bg-background rounded-2xl border-2 border-var(--neutral-800)">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Total Donations
|
|
</p>
|
|
<p className="text-3xl font-bold text-var(--orange-light) mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{formatPrice(stats.total_donations || 0)}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-var(--orange-light)/10 rounded-full">
|
|
<Heart className="h-6 w-6 text-var(--orange-light)" />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Search & Filter Bar */}
|
|
<Card className="p-6 bg-background rounded-2xl border-2 border-var(--neutral-800)">
|
|
<div className="grid md:grid-cols-3 gap-4">
|
|
{/* Search */}
|
|
<div className="md:col-span-1">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-var(--purple-lavender)" />
|
|
<Input
|
|
placeholder="Search by name or email..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-10 rounded-xl border-2 border-var(--neutral-800) focus:border-var(--purple-lavender)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Filter */}
|
|
<div>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="rounded-xl border-2 border-var(--neutral-800)">
|
|
<SelectValue placeholder="All Statuses" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Statuses</SelectItem>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
<SelectItem value="expired">Expired</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Plan Filter */}
|
|
<div>
|
|
<Select value={planFilter} onValueChange={setPlanFilter}>
|
|
<SelectTrigger className="rounded-xl border-2 border-var(--neutral-800)">
|
|
<SelectValue placeholder="All Plans" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Plans</SelectItem>
|
|
{plans.map(plan => (
|
|
<SelectItem key={plan.id} value={plan.id}>{plan.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-center justify-between">
|
|
<div className="text-sm text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
|
|
</div>
|
|
|
|
{/* Export Dropdown */}
|
|
{hasPermission('subscriptions.export') && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
disabled={exporting}
|
|
className="bg-var(--green-light) text-white hover:bg-var(--green-soft) rounded-full px-6 py-2 flex items-center gap-2"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
{exporting ? 'Exporting...' : 'Export'}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-56 bg-background rounded-xl border-2 border-var(--neutral-800) shadow-lg">
|
|
<DropdownMenuItem
|
|
onClick={() => handleExport('all')}
|
|
className="cursor-pointer hover:bg-var(--lavender-300) rounded-lg p-3"
|
|
>
|
|
<FileDown className="h-4 w-4 mr-2 text-var(--purple-lavender)" />
|
|
<span className="text-var(--purple-ink)">Export All Subscriptions</span>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => handleExport('current')}
|
|
className="cursor-pointer hover:bg-var(--lavender-300) rounded-lg p-3"
|
|
>
|
|
<FileDown className="h-4 w-4 mr-2 text-var(--purple-lavender)" />
|
|
<span className="text-var(--purple-ink)">Export Current View</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Subscriptions Table */}
|
|
<Card className="bg-background rounded-2xl border-2 border-var(--neutral-800) overflow-hidden">
|
|
{/* Mobile Card View */}
|
|
<div className="md:hidden p-4 space-y-4">
|
|
{filteredSubscriptions.length > 0 ? (
|
|
filteredSubscriptions.map((sub) => (
|
|
<Card key={sub.id} className="p-4 border border-var(--neutral-800) bg-var(--lavender-400)/30">
|
|
<div className="space-y-3">
|
|
{/* Member Info */}
|
|
<div className="flex justify-between items-start border-b border-var(--neutral-800) pb-3">
|
|
<div className="flex-1">
|
|
<p className="font-semibold text-var(--purple-ink)" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{sub.user.first_name} {sub.user.last_name}
|
|
</p>
|
|
<p className="text-sm text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{sub.user.email}
|
|
</p>
|
|
</div>
|
|
<Badge variant={getStatusBadgeVariant(sub.status)}>{sub.status}</Badge>
|
|
</div>
|
|
|
|
{/* Plan & Period */}
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<p className="text-xs text-var(--purple-lavender) mb-1">Plan</p>
|
|
<p className="font-medium text-var(--purple-ink)">{sub.plan.name}</p>
|
|
<p className="text-xs text-var(--purple-lavender)">{sub.plan.billing_cycle}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-var(--purple-lavender) mb-1">Period</p>
|
|
<p className="text-var(--purple-ink)">
|
|
{new Date(sub.current_period_start).toLocaleDateString()} -
|
|
{new Date(sub.current_period_end).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Pricing */}
|
|
<div className="grid grid-cols-3 gap-2 text-sm bg-background/50 p-3 rounded">
|
|
<div>
|
|
<p className="text-xs text-var(--purple-lavender) mb-1">Base Fee</p>
|
|
<p className="font-medium text-var(--purple-ink)">
|
|
${(sub.base_fee_cents / 100).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-var(--purple-lavender) mb-1">Donation</p>
|
|
<p className="font-medium text-var(--purple-ink)">
|
|
${(sub.donation_cents / 100).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-var(--purple-lavender) mb-1">Total</p>
|
|
<p className="font-semibold text-var(--purple-ink)">
|
|
${(sub.total_cents / 100).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 pt-2">
|
|
{hasPermission('subscriptions.edit') && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleEdit(sub)}
|
|
className="flex-1 text-var(--purple-lavender) hover:bg-var(--neutral-800)"
|
|
>
|
|
<Edit className="h-4 w-4 mr-2" />
|
|
Edit
|
|
</Button>
|
|
)}
|
|
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleCancelSubscription(sub.id)}
|
|
className="flex-1 text-red-600 hover:bg-red-50"
|
|
>
|
|
<XCircle className="h-4 w-4 mr-2" />
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))
|
|
) : (
|
|
<div className="p-12 text-center text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
No subscriptions found
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Desktop Table View */}
|
|
<div className="hidden md:block overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="bg-var(--neutral-800)/20 border-b border-var(--neutral-800)">
|
|
<th className="text-left p-4 text-var(--purple-ink) font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Member
|
|
</th>
|
|
<th className="text-left p-4 text-var(--purple-ink) font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Plan
|
|
</th>
|
|
<th className="text-left p-4 text-var(--purple-ink) font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Status
|
|
</th>
|
|
<th className="text-left p-4 text-var(--purple-ink) font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Period
|
|
</th>
|
|
<th className="text-right p-4 text-var(--purple-ink) font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Base Fee
|
|
</th>
|
|
<th className="text-right p-4 text-var(--purple-ink) font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Donation
|
|
</th>
|
|
<th className="text-right p-4 text-var(--purple-ink) font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Total
|
|
</th>
|
|
<th className="text-center p-4 text-var(--purple-ink) font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredSubscriptions.length > 0 ? (
|
|
filteredSubscriptions.map((sub) => (
|
|
<tr key={sub.id} className="border-b border-var(--neutral-800) hover:bg-var(--lavender-400) transition-colors">
|
|
<td className="p-4">
|
|
<div className="font-medium text-var(--purple-ink)" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{sub.user.first_name} {sub.user.last_name}
|
|
</div>
|
|
<div className="text-sm text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{sub.user.email}
|
|
</div>
|
|
</td>
|
|
<td className="p-4">
|
|
<div className="text-var(--purple-ink)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{sub.plan.name}
|
|
</div>
|
|
<div className="text-xs text-var(--purple-lavender)">
|
|
{sub.plan.billing_cycle}
|
|
</div>
|
|
</td>
|
|
<td className="p-4">
|
|
<Badge variant={getStatusBadgeVariant(sub.status)}>
|
|
{sub.status}
|
|
</Badge>
|
|
</td>
|
|
<td className="p-4">
|
|
<div className="text-sm text-var(--purple-ink)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
<div>{formatDate(sub.start_date)}</div>
|
|
<div className="text-xs text-var(--purple-lavender)">to {formatDate(sub.end_date)}</div>
|
|
</div>
|
|
</td>
|
|
<td className="p-4 text-right text-var(--purple-ink)" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{formatPrice(sub.base_subscription_cents || 0)}
|
|
</td>
|
|
<td className="p-4 text-right text-var(--orange-light)" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{formatPrice(sub.donation_cents || 0)}
|
|
</td>
|
|
<td className="p-4 text-right font-semibold text-var(--purple-ink)" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{formatPrice(sub.amount_paid_cents || 0)}
|
|
</td>
|
|
<td className="p-4">
|
|
<div className="flex items-center justify-center gap-2">
|
|
{hasPermission('subscriptions.edit') && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleEdit(sub)}
|
|
className="text-var(--purple-lavender) hover:bg-var(--neutral-800)"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleCancelSubscription(sub.id)}
|
|
className="text-red-600 hover:bg-red-50"
|
|
>
|
|
<XCircle className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan="8" className="p-12 text-center text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
No subscriptions found
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Edit Subscription Dialog */}
|
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
|
<DialogContent className="sm:max-w-[500px] bg-background rounded-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-2xl font-semibold text-var(--purple-ink)" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Edit Subscription
|
|
</DialogTitle>
|
|
<DialogDescription className="text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Update subscription status or end date for {selectedSubscription?.user.first_name} {selectedSubscription?.user.last_name}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6 py-4">
|
|
{/* Status */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="status" className="text-var(--purple-ink) font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Status
|
|
</Label>
|
|
<Select
|
|
value={editFormData.status}
|
|
onValueChange={(value) => setEditFormData({ ...editFormData, status: value })}
|
|
>
|
|
<SelectTrigger className="rounded-xl border-2 border-var(--neutral-800)">
|
|
<SelectValue placeholder="Select status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
<SelectItem value="expired">Expired</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Warning Box - Show when status is different */}
|
|
{selectedSubscription && editFormData.status !== selectedSubscription.status && (
|
|
<div className={`mt-3 p-4 rounded-xl border-2 ${editFormData.status === 'cancelled'
|
|
? 'bg-red-50 border-red-300'
|
|
: editFormData.status === 'expired'
|
|
? 'bg-orange-50 border-orange-300'
|
|
: 'bg-green-50 border-green-300'
|
|
}`}>
|
|
<div className="flex items-start gap-3">
|
|
{editFormData.status === 'cancelled' || editFormData.status === 'expired' ? (
|
|
<AlertTriangle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
|
|
) : (
|
|
<Info className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
|
)}
|
|
<div className="flex-1">
|
|
<p className="font-semibold text-sm mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{editFormData.status === 'cancelled' && 'Critical: This will cancel the subscription'}
|
|
{editFormData.status === 'expired' && 'Warning: This will mark subscription as expired'}
|
|
{editFormData.status === 'active' && 'This will activate the subscription'}
|
|
</p>
|
|
<ul className="text-xs space-y-1 list-disc list-inside" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{editFormData.status === 'cancelled' && (
|
|
<>
|
|
<li>User status will be set to INACTIVE</li>
|
|
<li>Member access will be removed immediately</li>
|
|
<li>All future billing will be stopped</li>
|
|
</>
|
|
)}
|
|
{editFormData.status === 'expired' && (
|
|
<>
|
|
<li>User status will be set to INACTIVE</li>
|
|
<li>Member access will be removed</li>
|
|
<li>Subscription will be marked as ended</li>
|
|
</>
|
|
)}
|
|
{editFormData.status === 'active' && (
|
|
<>
|
|
<li>User status will be set to ACTIVE</li>
|
|
<li>Full member access will be granted</li>
|
|
<li>Billing will resume if applicable</li>
|
|
</>
|
|
)}
|
|
</ul>
|
|
<p className="text-xs mt-2 font-medium">
|
|
Current: <span className="font-bold">{selectedSubscription.status.toUpperCase()}</span> →
|
|
New: <span className="font-bold">{editFormData.status.toUpperCase()}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* End Date */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="end_date" className="text-var(--purple-ink) font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
End Date
|
|
</Label>
|
|
<div className="relative">
|
|
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-var(--purple-lavender)" />
|
|
<Input
|
|
id="end_date"
|
|
type="date"
|
|
value={editFormData.end_date}
|
|
onChange={(e) => setEditFormData({ ...editFormData, end_date: e.target.value })}
|
|
className="pl-12 rounded-xl border-2 border-var(--neutral-800) focus:border-var(--purple-lavender)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setEditDialogOpen(false)}
|
|
className="rounded-full border-2 border-var(--neutral-800)"
|
|
disabled={isUpdating}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={handleSaveSubscription}
|
|
disabled={isUpdating}
|
|
className="bg-var(--green-light) text-white hover:bg-var(--green-mint) rounded-full"
|
|
>
|
|
{isUpdating ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
'Save Changes'
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminSubscriptions;
|