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

@@ -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 />} />

View 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;

View File

@@ -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">

View 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>
);
}

View 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>
);
}

View File

@@ -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;
});

View File

@@ -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>

View 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;