finalizing subscription moduile, ready to test

This commit is contained in:
Dwindi Ramadhana
2026-01-29 11:54:42 +07:00
parent 6d2136d3b5
commit d80f34c8b9
34 changed files with 5619 additions and 468 deletions

View File

@@ -23,6 +23,8 @@ import ProductTags from '@/routes/Products/Tags';
import ProductAttributes from '@/routes/Products/Attributes';
import Licenses from '@/routes/Products/Licenses';
import LicenseDetail from '@/routes/Products/Licenses/Detail';
import SubscriptionsIndex from '@/routes/Subscriptions';
import SubscriptionDetail from '@/routes/Subscriptions/Detail';
import CouponsIndex from '@/routes/Marketing/Coupons';
import CouponNew from '@/routes/Marketing/Coupons/New';
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
@@ -31,7 +33,7 @@ import CustomerNew from '@/routes/Customers/New';
import CustomerEdit from '@/routes/Customers/Edit';
import CustomerDetail from '@/routes/Customers/Detail';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2, PanelLeftClose, PanelLeft, HelpCircle } from 'lucide-react';
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2, PanelLeftClose, PanelLeft, HelpCircle, ExternalLink, Repeat } from 'lucide-react';
import { Toaster } from 'sonner';
import { useShortcuts } from "@/hooks/useShortcuts";
import { CommandPalette } from "@/components/CommandPalette";
@@ -156,6 +158,7 @@ function Sidebar({ collapsed, onToggle }: SidebarProps) {
'palette': Palette,
'settings': SettingsIcon,
'help-circle': HelpCircle,
'repeat': Repeat,
};
// Get navigation tree from backend
@@ -211,6 +214,7 @@ function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
'mail': Mail,
'palette': Palette,
'settings': SettingsIcon,
'repeat': Repeat,
};
// Get navigation tree from backend
@@ -476,6 +480,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
) : (
<div className="font-semibold">{siteTitle}</div>
)}
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="ml-2 inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
title={__('Visit Store')}
>
<ExternalLink className="w-4 h-4" />
<span className="hidden sm:inline">{__('Store')}</span>
</a>
</div>
<div className="flex items-center gap-3">
<div className="text-sm opacity-70 hidden sm:block">{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}</div>
@@ -577,6 +592,10 @@ function AppRoutes() {
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
<Route path="/orders/:id/label" element={<OrderLabel />} />
{/* Subscriptions */}
<Route path="/subscriptions" element={<SubscriptionsIndex />} />
<Route path="/subscriptions/:id" element={<SubscriptionDetail />} />
{/* Coupons (under Marketing) */}
<Route path="/coupons" element={<CouponsIndex />} />
<Route path="/coupons/new" element={<CouponNew />} />

View File

@@ -16,7 +16,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-[999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-[99999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -28,19 +28,45 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
>(({ className, ...props }, ref) => {
// Get or create portal container inside the app for proper CSS scoping
const getPortalContainer = () => {
const appContainer = document.getElementById('woonoow-admin-app');
if (!appContainer) return document.body;
let portalRoot = document.getElementById('woonoow-dialog-portal');
if (!portalRoot) {
portalRoot = document.createElement('div');
portalRoot.id = 'woonoow-dialog-portal';
// Copy theme class from documentElement for proper CSS variable inheritance
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
portalRoot.className = themeClass;
appContainer.appendChild(portalRoot);
} else {
// Update theme class in case it changed
const themeClass = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
if (!portalRoot.classList.contains(themeClass)) {
portalRoot.classList.remove('light', 'dark');
portalRoot.classList.add(themeClass);
}
}
return portalRoot;
};
return (
<AlertDialogPortal container={getPortalContainer()}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
);
})
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({

View File

@@ -122,6 +122,15 @@ export default function MorePage() {
{/* Exit Fullscreen / Logout */}
<div className=" py-6 space-y-3">
<Button
onClick={() => window.open(window.WNW_CONFIG?.storeUrl || '/store/', '_blank')}
variant="outline"
className="w-full justify-start gap-3"
>
<ExternalLink className="w-5 h-5" />
{__('Visit Store')}
</Button>
{isStandalone && (
<Button
onClick={() => window.location.href = window.WNW_CONFIG?.wpAdminUrl || '/wp-admin'}

View File

@@ -40,6 +40,12 @@ export type ProductFormData = {
licensing_enabled?: boolean;
license_activation_limit?: string;
license_duration_days?: string;
// Subscription
subscription_enabled?: boolean;
subscription_period?: 'day' | 'week' | 'month' | 'year';
subscription_interval?: string;
subscription_trial_days?: string;
subscription_signup_fee?: string;
};
type Props = {
@@ -89,6 +95,12 @@ export function ProductFormTabbed({
const [licensingEnabled, setLicensingEnabled] = useState(initial?.licensing_enabled || false);
const [licenseActivationLimit, setLicenseActivationLimit] = useState(initial?.license_activation_limit || '');
const [licenseDurationDays, setLicenseDurationDays] = useState(initial?.license_duration_days || '');
// Subscription state
const [subscriptionEnabled, setSubscriptionEnabled] = useState(initial?.subscription_enabled || false);
const [subscriptionPeriod, setSubscriptionPeriod] = useState<'day' | 'week' | 'month' | 'year'>(initial?.subscription_period || 'month');
const [subscriptionInterval, setSubscriptionInterval] = useState(initial?.subscription_interval || '1');
const [subscriptionTrialDays, setSubscriptionTrialDays] = useState(initial?.subscription_trial_days || '');
const [subscriptionSignupFee, setSubscriptionSignupFee] = useState(initial?.subscription_signup_fee || '');
const [submitting, setSubmitting] = useState(false);
// Update form state when initial data changes (for edit mode)
@@ -119,6 +131,12 @@ export function ProductFormTabbed({
setLicensingEnabled(initial.licensing_enabled || false);
setLicenseActivationLimit(initial.license_activation_limit || '');
setLicenseDurationDays(initial.license_duration_days || '');
// Subscription
setSubscriptionEnabled(initial.subscription_enabled || false);
setSubscriptionPeriod(initial.subscription_period || 'month');
setSubscriptionInterval(initial.subscription_interval || '1');
setSubscriptionTrialDays(initial.subscription_trial_days || '');
setSubscriptionSignupFee(initial.subscription_signup_fee || '');
}
}, [initial, mode]);
@@ -181,6 +199,12 @@ export function ProductFormTabbed({
licensing_enabled: licensingEnabled,
license_activation_limit: licensingEnabled ? licenseActivationLimit : undefined,
license_duration_days: licensingEnabled ? licenseDurationDays : undefined,
// Subscription
subscription_enabled: subscriptionEnabled,
subscription_period: subscriptionEnabled ? subscriptionPeriod : undefined,
subscription_interval: subscriptionEnabled ? subscriptionInterval : undefined,
subscription_trial_days: subscriptionEnabled ? subscriptionTrialDays : undefined,
subscription_signup_fee: subscriptionEnabled ? subscriptionSignupFee : undefined,
};
await onSubmit(payload);
@@ -237,6 +261,16 @@ export function ProductFormTabbed({
setLicenseActivationLimit={setLicenseActivationLimit}
licenseDurationDays={licenseDurationDays}
setLicenseDurationDays={setLicenseDurationDays}
subscriptionEnabled={subscriptionEnabled}
setSubscriptionEnabled={setSubscriptionEnabled}
subscriptionPeriod={subscriptionPeriod}
setSubscriptionPeriod={setSubscriptionPeriod}
subscriptionInterval={subscriptionInterval}
setSubscriptionInterval={setSubscriptionInterval}
subscriptionTrialDays={subscriptionTrialDays}
setSubscriptionTrialDays={setSubscriptionTrialDays}
subscriptionSignupFee={subscriptionSignupFee}
setSubscriptionSignupFee={setSubscriptionSignupFee}
/>
</FormSection>

View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key } from 'lucide-react';
import { DollarSign, Upload, X, Image as ImageIcon, Copy, Check, Key, Repeat } from 'lucide-react';
import { toast } from 'sonner';
import { getStoreCurrency } from '@/lib/currency';
import { RichTextEditor } from '@/components/RichTextEditor';
@@ -50,6 +50,17 @@ type GeneralTabProps = {
setLicenseActivationLimit?: (value: string) => void;
licenseDurationDays?: string;
setLicenseDurationDays?: (value: string) => void;
// Subscription
subscriptionEnabled?: boolean;
setSubscriptionEnabled?: (value: boolean) => void;
subscriptionPeriod?: 'day' | 'week' | 'month' | 'year';
setSubscriptionPeriod?: (value: 'day' | 'week' | 'month' | 'year') => void;
subscriptionInterval?: string;
setSubscriptionInterval?: (value: string) => void;
subscriptionTrialDays?: string;
setSubscriptionTrialDays?: (value: string) => void;
subscriptionSignupFee?: string;
setSubscriptionSignupFee?: (value: string) => void;
};
export function GeneralTab({
@@ -84,6 +95,16 @@ export function GeneralTab({
setLicenseActivationLimit,
licenseDurationDays,
setLicenseDurationDays,
subscriptionEnabled,
setSubscriptionEnabled,
subscriptionPeriod,
setSubscriptionPeriod,
subscriptionInterval,
setSubscriptionInterval,
subscriptionTrialDays,
setSubscriptionTrialDays,
subscriptionSignupFee,
setSubscriptionSignupFee,
}: GeneralTabProps) {
const savingsPercent =
salePrice && regularPrice && parseFloat(salePrice) < parseFloat(regularPrice)
@@ -481,6 +502,92 @@ export function GeneralTab({
)}
</>
)}
{/* Subscription option */}
{setSubscriptionEnabled && (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="subscription-enabled"
checked={subscriptionEnabled || false}
onCheckedChange={(checked) => setSubscriptionEnabled(checked as boolean)}
/>
<Label htmlFor="subscription-enabled" className="cursor-pointer font-normal flex items-center gap-1">
<Repeat className="h-3 w-3" />
{__('Enable subscription for this product')}
</Label>
</div>
{/* Subscription settings panel */}
{subscriptionEnabled && (
<div className="ml-6 p-3 bg-muted/50 rounded-lg space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">{__('Billing Period')}</Label>
<Select
value={subscriptionPeriod || 'month'}
onValueChange={(v: any) => setSubscriptionPeriod?.(v)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="day">{__('Day')}</SelectItem>
<SelectItem value="week">{__('Week')}</SelectItem>
<SelectItem value="month">{__('Month')}</SelectItem>
<SelectItem value="year">{__('Year')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">{__('Billing Interval')}</Label>
<Input
type="number"
min="1"
placeholder="1"
value={subscriptionInterval || '1'}
onChange={(e) => setSubscriptionInterval?.(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('e.g., 1 = every month, 3 = every 3 months')}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">{__('Free Trial Days')}</Label>
<Input
type="number"
min="0"
placeholder={__('0 = no trial')}
value={subscriptionTrialDays || ''}
onChange={(e) => setSubscriptionTrialDays?.(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label className="text-xs">{__('Sign-up Fee')}</Label>
<Input
type="number"
min="0"
step="0.01"
placeholder="0.00"
value={subscriptionSignupFee || ''}
onChange={(e) => setSubscriptionSignupFee?.(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{__('One-time fee charged on first order')}
</p>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
@@ -527,6 +634,6 @@ export function GeneralTab({
</>
)}
</CardContent>
</Card>
</Card >
);
}

View File

@@ -0,0 +1,401 @@
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;
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',
};
const statusLabels: Record<string, string> = {
'pending': __('Pending'),
'active': __('Active'),
'on-hold': __('On Hold'),
'cancelled': __('Cancelled'),
'expired': __('Expired'),
'pending-cancel': __('Pending Cancel'),
};
const orderTypeLabels: Record<string, string> = {
'parent': __('Initial Order'),
'renewal': __('Renewal'),
'switch': __('Plan Switch'),
'resubscribe': __('Resubscribe'),
};
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">
{window.WNW_STORE?.currency_symbol}{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 || __('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) => (
<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">{order.order_status?.replace('wc-', '')}</span>
</TableCell>
<TableCell>
{new Date(order.created_at).toLocaleDateString()}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,332 @@
import React, { useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Calendar, User, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { __ } from '@/lib/i18n';
import { toast } from 'sonner';
interface Subscription {
id: number;
user_id: number;
order_id: number;
product_id: number;
product_name: string;
user_name: string;
user_email: string;
status: 'pending' | 'active' | 'on-hold' | 'cancelled' | 'expired' | 'pending-cancel';
billing_schedule: string;
recurring_amount: string;
next_payment_date: string | null;
created_at: string;
can_pause: boolean;
can_resume: boolean;
can_cancel: boolean;
}
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',
};
const statusLabels: Record<string, string> = {
'pending': __('Pending'),
'active': __('Active'),
'on-hold': __('On Hold'),
'cancelled': __('Cancelled'),
'expired': __('Expired'),
'pending-cancel': __('Pending Cancel'),
};
async function fetchSubscriptions(params: Record<string, string>) {
const url = new URL(window.WNW_API.root + '/subscriptions');
Object.entries(params).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
});
const res = await fetch(url.toString(), {
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
});
if (!res.ok) throw new Error('Failed to fetch subscriptions');
return res.json();
}
async function subscriptionAction(id: number, action: 'cancel' | 'pause' | 'resume' | 'renew', 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 SubscriptionsIndex() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const { setPageHeader, clearPageHeader } = usePageHeader();
const status = searchParams.get('status') || '';
const page = parseInt(searchParams.get('page') || '1');
useEffect(() => {
setPageHeader(__('Subscriptions'));
return () => clearPageHeader();
}, [setPageHeader, clearPageHeader]);
const { data, isLoading, error } = useQuery({
queryKey: ['subscriptions', { status, page }],
queryFn: () => fetchSubscriptions({ status, page: String(page), per_page: '20' }),
});
const actionMutation = useMutation({
mutationFn: ({ id, action, reason }: { id: number; action: 'cancel' | 'pause' | 'resume' | 'renew'; reason?: string }) =>
subscriptionAction(id, action, reason),
onSuccess: (_, { action }) => {
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
toast.success(__(`Subscription ${action}d successfully`));
},
onError: (error: Error) => {
toast.error(error.message);
},
});
const handleAction = (id: number, action: 'cancel' | 'pause' | 'resume' | 'renew') => {
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
return;
}
actionMutation.mutate({ id, action });
};
const handleStatusFilter = (value: string) => {
const params = new URLSearchParams(searchParams);
if (value === 'all') {
params.delete('status');
} else {
params.set('status', value);
}
params.delete('page');
setSearchParams(params);
};
const subscriptions: Subscription[] = data?.subscriptions || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / 20);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Select value={status || 'all'} onValueChange={handleStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={__('Filter by status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{__('All Statuses')}</SelectItem>
<SelectItem value="active">{__('Active')}</SelectItem>
<SelectItem value="on-hold">{__('On Hold')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
<SelectItem value="expired">{__('Expired')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-muted-foreground">
{__('Total')}: {total} {__('subscriptions')}
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">{__('ID')}</TableHead>
<TableHead>{__('Customer')}</TableHead>
<TableHead>{__('Product')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Billing')}</TableHead>
<TableHead>{__('Next Payment')}</TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
[...Array(5)].map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-12" /></TableCell>
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
<TableCell><Skeleton className="h-4 w-40" /></TableCell>
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-8" /></TableCell>
</TableRow>
))
) : subscriptions.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Repeat className="w-8 h-8 opacity-50" />
<p>{__('No subscriptions found')}</p>
</div>
</TableCell>
</TableRow>
) : (
subscriptions.map((sub) => (
<TableRow key={sub.id}>
<TableCell className="font-medium">#{sub.id}</TableCell>
<TableCell>
<div>
<div className="font-medium">{sub.user_name}</div>
<div className="text-sm text-muted-foreground">{sub.user_email}</div>
</div>
</TableCell>
<TableCell>{sub.product_name}</TableCell>
<TableCell>
<Badge className={statusColors[sub.status] || 'bg-gray-100'}>
{statusLabels[sub.status] || sub.status}
</Badge>
</TableCell>
<TableCell>
<div className="text-sm">
{sub.billing_schedule}
</div>
</TableCell>
<TableCell>
{sub.next_payment_date ? (
<div className="text-sm">
{new Date(sub.next_payment_date).toLocaleDateString()}
</div>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/subscriptions/${sub.id}`)}>
<Eye className="w-4 h-4 mr-2" />
{__('View Details')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{sub.can_pause && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'pause')}>
<Pause className="w-4 h-4 mr-2" />
{__('Pause')}
</DropdownMenuItem>
)}
{sub.can_resume && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'resume')}>
<Play className="w-4 h-4 mr-2" />
{__('Resume')}
</DropdownMenuItem>
)}
{sub.status === 'active' && (
<DropdownMenuItem onClick={() => handleAction(sub.id, 'renew')}>
<RefreshCw className="w-4 h-4 mr-2" />
{__('Renew Now')}
</DropdownMenuItem>
)}
{sub.can_cancel && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAction(sub.id, 'cancel')}
className="text-red-600"
>
<XCircle className="w-4 h-4 mr-2" />
{__('Cancel')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => {
const params = new URLSearchParams(searchParams);
params.set('page', String(page - 1));
setSearchParams(params);
}}
>
{__('Previous')}
</Button>
<span className="text-sm text-muted-foreground">
{__('Page')} {page} {__('of')} {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => {
const params = new URLSearchParams(searchParams);
params.set('page', String(page + 1));
setSearchParams(params);
}}
>
{__('Next')}
</Button>
</div>
)}
</div>
);
}