finalizing subscription moduile, ready to test
This commit is contained in:
@@ -19,6 +19,7 @@ import Wishlist from './pages/Wishlist';
|
||||
import Login from './pages/Login';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import OrderPay from './pages/OrderPay';
|
||||
import { DynamicPageRenderer } from './pages/DynamicPage';
|
||||
|
||||
// Create QueryClient instance
|
||||
@@ -101,6 +102,8 @@ function AppRoutes() {
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
||||
<Route path="/checkout/order-received/:orderId" element={<ThankYou />} />
|
||||
<Route path="/checkout/pay/:orderId" element={<OrderPay />} />
|
||||
|
||||
{/* Wishlist - Public route accessible to guests */}
|
||||
<Route path="/wishlist" element={<Wishlist />} />
|
||||
|
||||
86
customer-spa/src/components/SubscriptionTimeline.tsx
Normal file
86
customer-spa/src/components/SubscriptionTimeline.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SubscriptionData {
|
||||
id: number;
|
||||
status: string;
|
||||
billing_period: string;
|
||||
billing_interval: number;
|
||||
start_date: string;
|
||||
next_payment_date: string | null;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
subscription: SubscriptionData;
|
||||
}
|
||||
|
||||
const SubscriptionTimeline: React.FC<Props> = ({ subscription }) => {
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const isMonth = subscription.billing_period === 'month';
|
||||
const intervalLabel = `${subscription.billing_interval} ${subscription.billing_period}${subscription.billing_interval > 1 ? 's' : ''}`;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold mb-6">Subscription Timeline</h2>
|
||||
|
||||
<div className="relative">
|
||||
{/* Connecting Line */}
|
||||
<div className="absolute top-4 left-4 right-4 h-0.5 bg-gray-200" aria-hidden="true" />
|
||||
|
||||
<div className="relative flex justify-between">
|
||||
{/* Start Node */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-8 h-8 rounded-full bg-green-100 border-2 border-green-500 flex items-center justify-center z-10 shrink-0">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-500" />
|
||||
</div>
|
||||
<div className="mt-3 text-center">
|
||||
<div className="text-sm font-medium text-gray-900">Started</div>
|
||||
<div className="text-xs text-gray-500">{formatDate(subscription.start_date)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Info (Interval) */}
|
||||
<div className="hidden sm:flex flex-col items-center justify-start pt-1">
|
||||
<div className="bg-white px-2 text-xs text-gray-400 font-medium uppercase tracking-wider">
|
||||
Every {intervalLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Payment (Active/Due) Node */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-8 h-8 rounded-full bg-blue-100 border-2 border-blue-600 flex items-center justify-center z-10 shrink-0 animate-pulse-ring">
|
||||
{/* Pulse Effect */}
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-20 animate-ping"></span>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-blue-600" />
|
||||
</div>
|
||||
<div className="mt-3 text-center">
|
||||
<div className="text-sm font-medium text-blue-700 font-bold">Payment Due</div>
|
||||
<div className="text-xs text-blue-600 font-medium">Now</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Future Node */}
|
||||
<div className="flex flex-col items-center opacity-60">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 border-2 border-gray-300 flex items-center justify-center z-10 shrink-0">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-gray-300" />
|
||||
</div>
|
||||
<div className="mt-3 text-center">
|
||||
<div className="text-sm font-medium text-gray-500">Next Renewal</div>
|
||||
<div className="text-xs text-gray-400">{subscription.next_payment_date ? formatDate(subscription.next_payment_date) : '...'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionTimeline;
|
||||
@@ -115,9 +115,19 @@ export default function OrderDetails() {
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Order #{order.order_number}</h1>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
|
||||
{order.status.replace('-', ' ').toUpperCase()}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{['pending', 'failed', 'on-hold'].includes(order.status) && (
|
||||
<Link
|
||||
to={`/checkout/pay/${order.id}`}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors shadow-sm"
|
||||
>
|
||||
Pay Now
|
||||
</Link>
|
||||
)}
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(order.status)}`}>
|
||||
{order.status.replace('-', ' ').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 mb-6">
|
||||
|
||||
377
customer-spa/src/pages/Account/SubscriptionDetail.tsx
Normal file
377
customer-spa/src/pages/Account/SubscriptionDetail.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ArrowLeft, Repeat, Pause, Play, XCircle, Calendar, Package, CreditCard, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
|
||||
interface SubscriptionOrder {
|
||||
id: number;
|
||||
order_id: number;
|
||||
order_type: 'parent' | 'renewal' | 'switch' | 'resubscribe';
|
||||
order_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_image: 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;
|
||||
can_pause: boolean;
|
||||
can_resume: boolean;
|
||||
can_cancel: boolean;
|
||||
orders: SubscriptionOrder[];
|
||||
}
|
||||
|
||||
const statusStyles: 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',
|
||||
};
|
||||
|
||||
export default function SubscriptionDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const { data: subscription, isLoading, error } = useQuery<Subscription>({
|
||||
queryKey: ['account-subscription', id],
|
||||
queryFn: () => api.get(`/account/subscriptions/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: (action: string) => api.post(`/account/subscriptions/${id}/${action}`),
|
||||
onSuccess: (_, action) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-subscription', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
|
||||
const actionLabels: Record<string, string> = {
|
||||
'pause': 'paused',
|
||||
'resume': 'resumed',
|
||||
'cancel': 'cancelled',
|
||||
};
|
||||
toast.success(`Subscription ${actionLabels[action] || action} successfully`);
|
||||
setActionLoading(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Action failed');
|
||||
setActionLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAction = (action: string) => {
|
||||
setActionLoading(true);
|
||||
actionMutation.mutate(action);
|
||||
};
|
||||
|
||||
const handleRenew = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const response = await api.post<{ order_id: number; status: string }>(`/account/subscriptions/${id}/renew`);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['account-subscription', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
|
||||
|
||||
toast.success('Renewal order created');
|
||||
|
||||
if (response.order_id) {
|
||||
// Determine destination based on functionality
|
||||
// If manual payment required or just improved UX, go to payment page
|
||||
navigate(`/order-pay/${response.order_id}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to renew');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Find pending renewal order
|
||||
const pendingRenewalOrder = subscription?.orders?.find(
|
||||
o => o.order_type === 'renewal' &&
|
||||
['pending', 'wc-pending', 'on-hold', 'wc-on-hold', 'failed', 'wc-failed'].includes(o.order_status)
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</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('/my-account/subscriptions')}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Subscriptions
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SEOHead title={`Subscription #${subscription.id}`} description="Subscription details" />
|
||||
|
||||
{/* Back button */}
|
||||
<Link
|
||||
to="/my-account/subscriptions"
|
||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Subscriptions
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Repeat className="h-6 w-6" />
|
||||
Subscription #{subscription.id}
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
Started {new Date(subscription.start_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyles[subscription.status] || 'bg-gray-100'}`}>
|
||||
{statusLabels[subscription.status] || subscription.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Product Info Card */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
{subscription.product_image ? (
|
||||
<img
|
||||
src={subscription.product_image}
|
||||
alt={subscription.product_name}
|
||||
className="w-20 h-20 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gray-100 rounded flex items-center justify-center">
|
||||
<Package className="h-10 w-10 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold">{subscription.product_name}</h2>
|
||||
<p className="text-gray-500">{subscription.billing_schedule}</p>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
{formatPrice(subscription.recurring_amount)}
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
/{subscription.billing_period}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing Details */}
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="font-semibold mb-4">Billing Details</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Start Date</p>
|
||||
<p className="font-medium">{new Date(subscription.start_date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{subscription.next_payment_date && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Next Payment</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
{new Date(subscription.next_payment_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{subscription.trial_end_date && new Date(subscription.trial_end_date) > new Date() && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Trial Ends</p>
|
||||
<p className="font-medium text-blue-600">
|
||||
{new Date(subscription.trial_end_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{subscription.end_date && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">End Date</p>
|
||||
<p className="font-medium">{new Date(subscription.end_date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Payment Method</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<CreditCard className="h-4 w-4 text-gray-400" />
|
||||
{subscription.payment_method || 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Times Paused</p>
|
||||
<p className="font-medium">{subscription.pause_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(subscription.can_pause || subscription.can_resume || subscription.can_cancel) && (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="font-semibold mb-4">Manage Subscription</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{subscription.can_pause && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('pause')}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Pause Subscription
|
||||
</Button>
|
||||
)}
|
||||
{subscription.can_resume && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('resume')}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Resume Subscription
|
||||
</Button>
|
||||
)}
|
||||
{/* Early Renewal Button */}
|
||||
{subscription.status === 'active' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRenew}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Repeat className="h-4 w-4 mr-2" />
|
||||
Renew Early
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Pay Pending Order Button */}
|
||||
{pendingRenewalOrder && (
|
||||
<Button
|
||||
className='bg-green-600 hover:bg-green-700'
|
||||
onClick={() => navigate(`/order-pay/${pendingRenewalOrder.order_id}`)}
|
||||
>
|
||||
<CreditCard className="h-4 w-4 mr-2" />
|
||||
Pay Now (#{pendingRenewalOrder.order_id})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{subscription.can_cancel && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 hover:text-red-600 border-red-200 hover:border-red-300"
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-2" />
|
||||
Cancel Subscription
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Subscription</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to cancel this subscription?
|
||||
You will lose access at the end of your current billing period.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep Subscription</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleAction('cancel')}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Yes, Cancel
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Orders */}
|
||||
{subscription.orders && subscription.orders.length > 0 && (
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="font-semibold mb-4 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Payment History
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{subscription.orders.map((order) => (
|
||||
<Link
|
||||
key={order.id}
|
||||
to={`/my-account/orders/${order.order_id}`}
|
||||
className="flex items-center justify-between p-3 rounded border hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">Order #{order.order_id}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-100 rounded">
|
||||
{orderTypeLabels[order.order_type] || order.order_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
customer-spa/src/pages/Account/Subscriptions.tsx
Normal file
244
customer-spa/src/pages/Account/Subscriptions.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Repeat, ChevronRight, Pause, Play, XCircle, Calendar, Package } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
|
||||
interface Subscription {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_image: string;
|
||||
status: 'pending' | 'active' | 'on-hold' | 'cancelled' | 'expired' | 'pending-cancel';
|
||||
billing_schedule: string;
|
||||
recurring_amount: string;
|
||||
next_payment_date: string | null;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
can_pause: boolean;
|
||||
can_resume: boolean;
|
||||
can_cancel: boolean;
|
||||
}
|
||||
|
||||
const statusStyles: 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',
|
||||
};
|
||||
|
||||
export default function Subscriptions() {
|
||||
const queryClient = useQueryClient();
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
|
||||
const { data: subscriptions = [], isLoading } = useQuery<Subscription[]>({
|
||||
queryKey: ['account-subscriptions'],
|
||||
queryFn: () => api.get('/account/subscriptions'),
|
||||
});
|
||||
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: ({ id, action }: { id: number; action: string }) =>
|
||||
api.post(`/account/subscriptions/${id}/${action}`),
|
||||
onSuccess: (_, { action }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['account-subscriptions'] });
|
||||
const actionLabels: Record<string, string> = {
|
||||
'pause': 'paused',
|
||||
'resume': 'resumed',
|
||||
'cancel': 'cancelled',
|
||||
};
|
||||
toast.success(`Subscription ${actionLabels[action] || action} successfully`);
|
||||
setActionLoading(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Action failed');
|
||||
setActionLoading(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAction = (id: number, action: string) => {
|
||||
setActionLoading(id);
|
||||
actionMutation.mutate({ id, action });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SEOHead title="My Subscriptions" description="Manage your subscriptions" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Repeat className="h-6 w-6" />
|
||||
My Subscriptions
|
||||
</h1>
|
||||
<p className="text-gray-500">
|
||||
Manage your recurring subscriptions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{subscriptions.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<Repeat className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500">You don't have any subscriptions yet.</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Purchase a subscription product to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{subscriptions.map((sub) => (
|
||||
<div key={sub.id} className="bg-white rounded-lg border overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Product Image */}
|
||||
{sub.product_image ? (
|
||||
<img
|
||||
src={sub.product_image}
|
||||
alt={sub.product_name}
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-gray-100 rounded flex items-center justify-center">
|
||||
<Package className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{sub.product_name}</h3>
|
||||
<p className="text-sm text-gray-500">{sub.billing_schedule}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusStyles[sub.status] || 'bg-gray-100'}`}>
|
||||
{statusLabels[sub.status] || sub.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Amount: </span>
|
||||
<span className="font-medium">
|
||||
{formatPrice(sub.recurring_amount)}
|
||||
</span>
|
||||
</div>
|
||||
{sub.next_payment_date && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-500">Next: </span>
|
||||
<span className="font-medium">
|
||||
{new Date(sub.next_payment_date).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 pt-4 border-t flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{sub.can_pause && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAction(sub.id, 'pause')}
|
||||
disabled={actionLoading === sub.id}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-1" />
|
||||
Pause
|
||||
</Button>
|
||||
)}
|
||||
{sub.can_resume && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAction(sub.id, 'resume')}
|
||||
disabled={actionLoading === sub.id}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
{sub.can_cancel && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-500 hover:text-red-600"
|
||||
disabled={actionLoading === sub.id}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Cancel Subscription</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to cancel this subscription?
|
||||
You will lose access at the end of your current billing period.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep Subscription</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleAction(sub.id, 'cancel')}
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
Yes, Cancel
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/my-account/subscriptions/${sub.id}`}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
>
|
||||
View Details
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ReactNode, useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key } from 'lucide-react';
|
||||
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key, Repeat } from 'lucide-react';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { api } from '@/lib/api/client';
|
||||
import {
|
||||
@@ -50,17 +50,19 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
||||
const allMenuItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', path: '/my-account', icon: LayoutDashboard },
|
||||
{ id: 'orders', label: 'Orders', path: '/my-account/orders', icon: ShoppingBag },
|
||||
{ id: 'subscriptions', label: 'Subscriptions', path: '/my-account/subscriptions', icon: Repeat },
|
||||
{ id: 'licenses', label: 'Licenses', path: '/my-account/licenses', icon: Key },
|
||||
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
|
||||
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
||||
{ id: 'licenses', label: 'Licenses', path: '/my-account/licenses', icon: Key },
|
||||
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
||||
];
|
||||
|
||||
// Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
|
||||
const menuItems = allMenuItems.filter(item => {
|
||||
if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
|
||||
if (item.id === 'licenses') return isEnabled('licensing');
|
||||
if (item.id === 'subscriptions') return isEnabled('subscription');
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import Addresses from './Addresses';
|
||||
import Wishlist from './Wishlist';
|
||||
import AccountDetails from './AccountDetails';
|
||||
import Licenses from './Licenses';
|
||||
import Subscriptions from './Subscriptions';
|
||||
import SubscriptionDetail from './SubscriptionDetail';
|
||||
|
||||
export default function Account() {
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
@@ -32,6 +34,8 @@ export default function Account() {
|
||||
<Route path="addresses" element={<Addresses />} />
|
||||
<Route path="wishlist" element={<Wishlist />} />
|
||||
<Route path="licenses" element={<Licenses />} />
|
||||
<Route path="subscriptions" element={<Subscriptions />} />
|
||||
<Route path="subscriptions/:id" element={<SubscriptionDetail />} />
|
||||
<Route path="account-details" element={<AccountDetails />} />
|
||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||
</Routes>
|
||||
|
||||
254
customer-spa/src/pages/OrderPay/index.tsx
Normal file
254
customer-spa/src/pages/OrderPay/index.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { toast } from 'sonner';
|
||||
import SubscriptionTimeline from '../../components/SubscriptionTimeline';
|
||||
|
||||
// Define types based on CheckoutController response
|
||||
// Define types based on CheckoutController response
|
||||
interface BaseResponse {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OrderDetailsResponse extends BaseResponse {
|
||||
id: number;
|
||||
number: string;
|
||||
status: string;
|
||||
created_via: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
currency_symbol: string;
|
||||
items: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
qty: number;
|
||||
total: number;
|
||||
image?: string;
|
||||
}>;
|
||||
available_gateways: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}>;
|
||||
subscription?: {
|
||||
id: number;
|
||||
status: string;
|
||||
billing_period: string;
|
||||
billing_interval: number;
|
||||
start_date: string;
|
||||
next_payment_date: string | null;
|
||||
end_date: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PaymentResponse extends BaseResponse {
|
||||
redirect?: string;
|
||||
messages?: string;
|
||||
}
|
||||
|
||||
const OrderPay: React.FC = () => {
|
||||
const { orderId } = useParams<{ orderId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const orderKey = searchParams.get('key');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [order, setOrder] = useState<OrderDetailsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGateway, setSelectedGateway] = useState<string>('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (orderId) {
|
||||
fetchOrder();
|
||||
}
|
||||
}, [orderId]);
|
||||
|
||||
const fetchOrder = async () => {
|
||||
try {
|
||||
const endpoint = `/checkout/order/${orderId}${orderKey ? `?key=${orderKey}` : ''}`;
|
||||
const response = await api.get<OrderDetailsResponse>(endpoint);
|
||||
|
||||
if (response.error) {
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
if (response.ok) {
|
||||
setOrder(response as OrderDetailsResponse);
|
||||
if (response.available_gateways?.length > 0) {
|
||||
setSelectedGateway(response.available_gateways[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch order', error);
|
||||
toast.error('Failed to load order details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayment = async () => {
|
||||
if (!selectedGateway) {
|
||||
toast.error('Please select a payment method');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await api.post<PaymentResponse>(`/checkout/pay-order/${orderId}`, {
|
||||
payment_method: selectedGateway,
|
||||
key: orderKey
|
||||
});
|
||||
|
||||
if (response.ok && response.redirect) {
|
||||
window.location.href = response.redirect;
|
||||
} else {
|
||||
toast.error(response.error || 'Payment failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment error', error);
|
||||
toast.error('An error occurred while processing payment');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to format price with proper currency locale
|
||||
const formatPrice = (amount: number, currency: string) => {
|
||||
try {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch (e) {
|
||||
// Fallback
|
||||
return `${currency} ${amount.toFixed(0)}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-center">Loading order details...</div>;
|
||||
if (!order) return <div className="p-8 text-center text-red-500">Order not found</div>;
|
||||
|
||||
const isRenewal = order.created_via === 'subscription_renewal';
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Complete Payment</h1>
|
||||
|
||||
{order.subscription && (
|
||||
<SubscriptionTimeline subscription={order.subscription} />
|
||||
)}
|
||||
|
||||
{isRenewal && !order.subscription && (
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-blue-700">
|
||||
This is a payment for your <span className="font-bold">subscription renewal</span>.
|
||||
Completing this payment will extend your subscription period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Order Summary <span className="text-gray-400 font-normal">#{order.number}</span></h2>
|
||||
<div className="space-y-4">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="flex justify-between items-center border-b pb-4 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-4">
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.name} className="w-16 h-16 object-cover rounded bg-gray-100" />
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-gray-100 rounded flex items-center justify-center text-gray-400">
|
||||
<span className="text-xs">No img</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-lg">{item.name}</div>
|
||||
<div className="text-sm text-gray-500">Qty: {item.qty}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-medium text-lg">
|
||||
{formatPrice(item.total, order.currency)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<div className="flex justify-between items-center text-gray-600 mb-2">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(order.total, order.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center font-bold text-xl mt-4">
|
||||
<span>Total to Pay</span>
|
||||
<span className="text-primary">{formatPrice(order.total, order.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Select Payment Method</h2>
|
||||
|
||||
{order.available_gateways.length === 0 ? (
|
||||
<div className="p-4 bg-yellow-50 text-yellow-700 rounded-md border border-yellow-200">
|
||||
No payment methods are available for this order. Please contact support.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 mb-6">
|
||||
{order.available_gateways.map((gateway) => (
|
||||
<label
|
||||
key={gateway.id}
|
||||
className={`flex items-start p-4 border rounded-lg cursor-pointer transition-all ${selectedGateway === gateway.id
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment_method"
|
||||
value={gateway.id}
|
||||
checked={selectedGateway === gateway.id}
|
||||
onChange={(e) => setSelectedGateway(e.target.value)}
|
||||
className="h-4 w-4 text-primary border-gray-300 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<span className="block font-medium text-gray-900 text-base">
|
||||
{gateway.title || gateway.id}
|
||||
</span>
|
||||
{gateway.description && (
|
||||
<span className="block text-gray-500 mt-1">
|
||||
{gateway.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handlePayment}
|
||||
disabled={processing || !selectedGateway}
|
||||
className="w-full bg-primary text-white py-4 px-6 rounded-lg font-bold text-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
|
||||
>
|
||||
{processing ? 'Processing Payment...' : `Pay ${formatPrice(order.total, order.currency)}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderPay;
|
||||
Reference in New Issue
Block a user