Files
WooNooW/admin-spa/src/routes/Subscriptions/Detail.tsx

424 lines
18 KiB
TypeScript

import React, { useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Play, Pause, XCircle, RefreshCw, Calendar, User, Package, CreditCard, Clock, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Skeleton } from '@/components/ui/skeleton';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { __ } from '@/lib/i18n';
import { toast } from 'sonner';
interface SubscriptionOrder {
id: number;
subscription_id: number;
order_id: number;
order_type: 'parent' | 'renewal' | 'switch' | 'resubscribe';
order_status: string;
created_at: string;
}
interface Subscription {
id: number;
user_id: number;
order_id: number;
product_id: number;
variation_id: number | null;
product_name: string;
product_image: string;
user_name: string;
user_email: string;
status: string;
billing_period: string;
billing_interval: number;
billing_schedule: string;
recurring_amount: string;
start_date: string;
trial_end_date: string | null;
next_payment_date: string | null;
end_date: string | null;
last_payment_date: string | null;
payment_method: string;
payment_method_title?: string;
pause_count: number;
failed_payment_count: number;
cancel_reason: string | null;
created_at: string;
can_pause: boolean;
can_resume: boolean;
can_cancel: boolean;
orders: SubscriptionOrder[];
}
const statusColors: Record<string, string> = {
'pending': 'bg-yellow-100 text-yellow-800',
'active': 'bg-green-100 text-green-800',
'on-hold': 'bg-blue-100 text-blue-800',
'cancelled': 'bg-gray-100 text-gray-800',
'expired': 'bg-red-100 text-red-800',
'pending-cancel': 'bg-orange-100 text-orange-800',
'draft': 'bg-gray-100 text-gray-600',
};
const statusLabels: Record<string, string> = {
'pending': __('Pending'),
'active': __('Active'),
'on-hold': __('On Hold'),
'cancelled': __('Cancelled'),
'expired': __('Expired'),
'pending-cancel': __('Pending Cancel'),
'draft': __('Draft'),
};
const orderTypeLabels: Record<string, string> = {
'parent': __('Initial Order'),
'renewal': __('Renewal'),
'switch': __('Plan Switch'),
'resubscribe': __('Resubscribe'),
};
const formatPrice = (amount: string | number) => {
const val = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(val)) return amount;
// Simple formatting using browser's locale but keeping currency from store
try {
return new Intl.NumberFormat(window.WNW_STORE?.locale || 'en-US', {
style: 'currency',
currency: window.WNW_STORE?.currency || 'USD',
minimumFractionDigits: window.WNW_STORE?.decimals || 2,
}).format(val);
} catch (e) {
return (window.WNW_STORE?.currency_symbol || '$') + val;
}
};
async function fetchSubscription(id: string) {
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}`, {
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
});
if (!res.ok) throw new Error('Failed to fetch subscription');
return res.json();
}
async function subscriptionAction(id: number, action: string, reason?: string) {
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.WNW_API.nonce,
},
body: JSON.stringify({ reason }),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || `Failed to ${action} subscription`);
}
return res.json();
}
export default function SubscriptionDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { setPageHeader, clearPageHeader } = usePageHeader();
const { data: subscription, isLoading, error } = useQuery<Subscription>({
queryKey: ['subscription', id],
queryFn: () => fetchSubscription(id!),
enabled: !!id,
});
useEffect(() => {
if (subscription) {
setPageHeader(__('Subscription') + ' #' + subscription.id);
}
return () => clearPageHeader();
}, [subscription, setPageHeader, clearPageHeader]);
const actionMutation = useMutation({
mutationFn: ({ action, reason }: { action: string; reason?: string }) =>
subscriptionAction(parseInt(id!), action, reason),
onSuccess: (_, { action }) => {
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
toast.success(__(`Subscription ${action}d successfully`));
},
onError: (error: Error) => {
toast.error(error.message);
},
});
const handleAction = (action: string) => {
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
return;
}
actionMutation.mutate({ action });
};
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<div className="grid gap-6 md:grid-cols-2">
<Skeleton className="h-48" />
<Skeleton className="h-48" />
</div>
<Skeleton className="h-64" />
</div>
);
}
if (error || !subscription) {
return (
<div className="text-center py-12">
<p className="text-red-500">{__('Failed to load subscription')}</p>
<Button variant="outline" className="mt-4" onClick={() => navigate('/subscriptions')}>
<ArrowLeft className="w-4 h-4 mr-2" />
{__('Back to Subscriptions')}
</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Back button and actions */}
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => navigate('/subscriptions')}>
<ArrowLeft className="w-4 h-4 mr-2" />
{__('Back')}
</Button>
<div className="flex items-center gap-2">
{subscription.can_pause && (
<Button
variant="outline"
onClick={() => handleAction('pause')}
disabled={actionMutation.isPending}
>
<Pause className="w-4 h-4 mr-2" />
{__('Pause')}
</Button>
)}
{subscription.can_resume && (
<Button
variant="outline"
onClick={() => handleAction('resume')}
disabled={actionMutation.isPending}
>
<Play className="w-4 h-4 mr-2" />
{__('Resume')}
</Button>
)}
{subscription.status === 'active' && (
<Button
variant="outline"
onClick={() => handleAction('renew')}
disabled={actionMutation.isPending}
>
<RefreshCw className="w-4 h-4 mr-2" />
{__('Renew Now')}
</Button>
)}
{subscription.can_cancel && (
<Button
variant="destructive"
onClick={() => handleAction('cancel')}
disabled={actionMutation.isPending}
>
<XCircle className="w-4 h-4 mr-2" />
{__('Cancel')}
</Button>
)}
</div>
</div>
{/* Status and product info */}
<div className="grid gap-6 md:grid-cols-2">
{/* Subscription Info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{__('Subscription Details')}</CardTitle>
<Badge className={statusColors[subscription.status] || 'bg-gray-100'}>
{statusLabels[subscription.status] || subscription.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-4">
{subscription.product_image ? (
<img
src={subscription.product_image}
alt={subscription.product_name}
className="w-16 h-16 object-cover rounded"
/>
) : (
<div className="w-16 h-16 bg-muted rounded flex items-center justify-center">
<Package className="w-8 h-8 text-muted-foreground" />
</div>
)}
<div>
<h3 className="font-medium">{subscription.product_name}</h3>
<p className="text-sm text-muted-foreground">
{subscription.billing_schedule}
</p>
<p className="text-lg font-semibold mt-1">
{formatPrice(subscription.recurring_amount)}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
<div>
<div className="text-sm text-muted-foreground">{__('Start Date')}</div>
<div>{new Date(subscription.start_date).toLocaleDateString()}</div>
</div>
{subscription.next_payment_date && (
<div>
<div className="text-sm text-muted-foreground">{__('Next Payment')}</div>
<div>{new Date(subscription.next_payment_date).toLocaleDateString()}</div>
</div>
)}
{subscription.trial_end_date && (
<div>
<div className="text-sm text-muted-foreground">{__('Trial End')}</div>
<div>{new Date(subscription.trial_end_date).toLocaleDateString()}</div>
</div>
)}
{subscription.end_date && (
<div>
<div className="text-sm text-muted-foreground">{__('End Date')}</div>
<div>{new Date(subscription.end_date).toLocaleDateString()}</div>
</div>
)}
</div>
{subscription.cancel_reason && (
<div className="pt-4 border-t">
<div className="text-sm text-muted-foreground">{__('Cancel Reason')}</div>
<div className="text-red-600">{subscription.cancel_reason}</div>
</div>
)}
</CardContent>
</Card>
{/* Customer Info */}
<Card>
<CardHeader>
<CardTitle>{__('Customer')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-muted rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-muted-foreground" />
</div>
<div>
<div className="font-medium">{subscription.user_name}</div>
<div className="text-sm text-muted-foreground">{subscription.user_email}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
<div>
<div className="text-sm text-muted-foreground">{__('Payment Method')}</div>
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4" />
{subscription.payment_method_title || subscription.payment_method || __('Not set')}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">{__('Pause Count')}</div>
<div>{subscription.pause_count}</div>
</div>
<div>
<div className="text-sm text-muted-foreground">{__('Failed Payments')}</div>
<div className={subscription.failed_payment_count > 0 ? 'text-red-600' : ''}>
{subscription.failed_payment_count}
</div>
</div>
<div>
<div className="text-sm text-muted-foreground">{__('Parent Order')}</div>
<Link
to={`/orders/${subscription.order_id}`}
className="text-primary hover:underline"
>
#{subscription.order_id}
</Link>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Related Orders */}
<Card>
<CardHeader>
<CardTitle>{__('Related Orders')}</CardTitle>
<CardDescription>{__('All orders associated with this subscription')}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Order')}</TableHead>
<TableHead>{__('Type')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Date')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscription.orders?.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
{__('No orders found')}
</TableCell>
</TableRow>
) : (
subscription.orders?.map((order) => {
const rawStatus = order.order_status?.replace('wc-', '') || 'pending';
return (
<TableRow key={order.id}>
<TableCell>
<Link
to={`/orders/${order.order_id}`}
className="text-primary hover:underline font-medium"
>
#{order.order_id}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline">
{orderTypeLabels[order.order_type] || order.order_type}
</Badge>
</TableCell>
<TableCell>
<span className="capitalize">{statusLabels[rawStatus] || rawStatus}</span>
</TableCell>
<TableCell>
{new Date(order.created_at).toLocaleDateString()}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}