424 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|