Major improvements to WooNooW Page Editor system: Schema & Architecture: - Canonical section schema with unified sectionSchema.ts - Normalized feature-grid to use items (not features) - Standardized default values across all section types - Schema versioning with automatic migration on read Backend (PHP): - Enhanced PlaceholderRenderer with typed output contracts - Added fallback behavior for empty/invalid dynamic sources - Added caching support for post data resolution - New SchemaMigration class for backward compatibility - New Features class for feature flags - Enhanced PageSSR with full style support - Removed controller-level special-casing for related_posts Frontend (Admin SPA): - Updated CanvasRenderer with schema-aware transformation - Enhanced InspectorPanel with canonical schema metadata - Added new section renderers Frontend (Customer SPA): - New section components: BentoCategoryGrid, MarqueeBanner, ProductCarousel, ShoppableImage - Updated FeatureGridSection for items prop contract Testing: - Add PHP tests: SchemaMigrationTest, PlaceholderRendererTest, PageSSRTest - Add TypeScript tests: schema-integration, feature-grid-regression - Add parity tests for React vs SSR content matching - Add CI script: check-schema-drift.mjs - Add VERIFICATION_CHECKLIST.md Documentation: - RELEASE_NOTES-v1.0.md with full release notes - docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md - docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
456 lines
20 KiB
TypeScript
456 lines
20 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 {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
|
import { __ } from '@/lib/i18n';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/lib/api';
|
|
|
|
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 api.get(`/subscriptions/${id}`);
|
|
return res;
|
|
}
|
|
|
|
async function subscriptionAction(id: number, action: string, reason?: string) {
|
|
const res = await api.post(`/subscriptions/${id}/${action}`, { reason });
|
|
return res;
|
|
}
|
|
|
|
export default function SubscriptionDetail() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
|
const [showCancelDialog, setShowCancelDialog] = React.useState(false);
|
|
|
|
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`));
|
|
setShowCancelDialog(false);
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(error.message);
|
|
setShowCancelDialog(false);
|
|
},
|
|
});
|
|
|
|
const handleAction = (action: string) => {
|
|
if (action === 'cancel') {
|
|
setShowCancelDialog(true);
|
|
return;
|
|
}
|
|
actionMutation.mutate({ action });
|
|
};
|
|
|
|
const confirmCancel = () => {
|
|
actionMutation.mutate({ action: 'cancel' });
|
|
};
|
|
|
|
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>
|
|
|
|
{/* Cancel Confirmation Dialog (replaces native confirm()) */}
|
|
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{__('Cancel Subscription')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{__('Are you sure you want to cancel this subscription?')}
|
|
<br />
|
|
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel
|
|
onClick={() => setShowCancelDialog(false)}
|
|
disabled={actionMutation.isPending}
|
|
>
|
|
{__('Keep Subscription')}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={confirmCancel}
|
|
disabled={actionMutation.isPending}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{actionMutation.isPending ? __('Cancelling...') : __('Cancel Subscription')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|