Subscription module: add gateway capability flow and UX fixes

This commit is contained in:
Dwindi Ramadhana
2026-06-02 00:38:42 +07:00
parent fec786daa6
commit df969b442d
15 changed files with 2375 additions and 138 deletions

View File

@@ -19,6 +19,9 @@ import { toast } from 'sonner';
import SEOHead from '@/components/SEOHead';
import { formatPrice } from '@/lib/currency';
const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
interface SubscriptionOrder {
id: number;
order_id: number;
@@ -43,7 +46,11 @@ interface Subscription {
end_date: string | null;
last_payment_date: string | null;
payment_method: string;
payment_method_title: string;
pause_count: number;
max_pause_count?: number;
pauses_remaining?: number | null;
paused_at?: string | null;
can_pause: boolean;
can_resume: boolean;
can_cancel: boolean;
@@ -51,12 +58,12 @@ interface Subscription {
}
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',
'pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
'active': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
'on-hold': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
'cancelled': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
'expired': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
'pending-cancel': 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
};
const statusLabels: Record<string, string> = {
@@ -124,7 +131,7 @@ export default function SubscriptionDetail() {
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}`);
navigate(`/checkout/pay/${response.order_id}`);
}
} catch (error: any) {
toast.error(error.message || 'Failed to renew');
@@ -166,7 +173,7 @@ export default function SubscriptionDetail() {
{/* Back button */}
<Link
to="/my-account/subscriptions"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Subscriptions
@@ -179,8 +186,8 @@ export default function SubscriptionDetail() {
<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 className="text-gray-500 dark:text-gray-400 mt-1">
Started {formatDate(subscription.start_date)}
</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyles[subscription.status] || 'bg-gray-100'}`}>
@@ -189,7 +196,7 @@ export default function SubscriptionDetail() {
</div>
{/* Product Info Card */}
<div className="bg-white rounded-lg border p-6">
<div className="bg-card rounded-lg border p-6">
<div className="flex items-start gap-4">
{subscription.product_image ? (
<img
@@ -198,16 +205,16 @@ export default function SubscriptionDetail() {
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 className="w-20 h-20 bg-gray-100 dark:bg-gray-700 rounded flex items-center justify-center">
<Package className="h-10 w-10 text-gray-400 dark:text-gray-500" />
</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-gray-500 dark:text-gray-400">{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">
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
/{subscription.billing_period}
</span>
</p>
@@ -216,65 +223,111 @@ export default function SubscriptionDetail() {
</div>
{/* Billing Details */}
<div className="bg-white rounded-lg border p-6">
<div className="bg-card 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>
<p className="text-sm text-gray-500 dark:text-gray-400">Start Date</p>
<p className="font-medium">{formatDate(subscription.start_date)}</p>
</div>
{subscription.next_payment_date && (
<div>
<p className="text-sm text-gray-500">Next Payment</p>
<p className="text-sm text-gray-500 dark:text-gray-400">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()}
<Calendar className="h-4 w-4 text-gray-400 dark:text-gray-500" />
{formatDate(subscription.next_payment_date!)}
</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="text-sm text-gray-500 dark:text-gray-400">Trial Ends</p>
<p className="font-medium text-blue-600">
{new Date(subscription.trial_end_date).toLocaleDateString()}
{formatDate(subscription.trial_end_date!)}
</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>
<p className="text-sm text-gray-500 dark:text-gray-400">End Date</p>
<p className="font-medium">{formatDate(subscription.end_date!)}</p>
</div>
)}
{subscription.status === 'on-hold' && subscription.paused_at && (
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Paused At</p>
<p className="font-medium text-blue-600">{formatDate(subscription.paused_at)}</p>
</div>
)}
<div>
<p className="text-sm text-gray-500">Payment Method</p>
<p className="text-sm text-gray-500 dark:text-gray-400">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'}
<CreditCard className="h-4 w-4 text-gray-400 dark:text-gray-500" />
{subscription.payment_method_title || 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>
<p className="text-sm text-gray-500 dark:text-gray-400">Times Paused</p>
<p className="font-medium">
{subscription.pause_count}
{subscription.pauses_remaining !== null && subscription.pauses_remaining !== undefined && (
<span className="text-sm text-gray-500 dark:text-gray-400 font-normal">
{' '}/ {subscription.max_pause_count}
</span>
)}
</p>
</div>
</div>
</div>
{/* Actions */}
{(subscription.can_pause || subscription.can_resume || subscription.can_cancel) && (
<div className="bg-white rounded-lg border p-6">
<div className="bg-card 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_pause && (() => {
// H2: disable the pause button when the customer has reached the
// server-enforced limit, so they don't get a generic 500 on click.
const limitReached = subscription.pauses_remaining !== null
&& subscription.pauses_remaining !== undefined
&& subscription.pauses_remaining <= 0;
const tooltip = limitReached
? `You have used all ${subscription.max_pause_count} allowed pauses for this subscription.`
: subscription.pauses_remaining !== null && subscription.pauses_remaining !== undefined
? `${subscription.pauses_remaining} pause${subscription.pauses_remaining === 1 ? '' : 's'} remaining.`
: undefined;
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={actionLoading || limitReached}
title={tooltip}
>
<Pause className="h-4 w-4 mr-2" />
Pause Subscription
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Pause Subscription?</AlertDialogTitle>
<AlertDialogDescription>
Pausing will place your subscription on hold until you manually resume it.
When you resume, your next payment date will be recalculated based on your billing cycle.
<br /><br />
{tooltip}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => handleAction('pause')}>
Pause
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
})()}
{subscription.can_resume && (
<Button
variant="outline"
@@ -301,7 +354,7 @@ export default function SubscriptionDetail() {
{pendingRenewalOrder && (
<Button
className='bg-green-600 hover:bg-green-700'
onClick={() => navigate(`/order-pay/${pendingRenewalOrder.order_id}`)}
onClick={() => navigate(`/checkout/pay/${pendingRenewalOrder.order_id}`)}
>
<CreditCard className="h-4 w-4 mr-2" />
Pay Now (#{pendingRenewalOrder.order_id})
@@ -346,7 +399,7 @@ export default function SubscriptionDetail() {
{/* Related Orders */}
{subscription.orders && subscription.orders.length > 0 && (
<div className="bg-white rounded-lg border p-6">
<div className="bg-card rounded-lg border p-6">
<h3 className="font-semibold mb-4 flex items-center gap-2">
<FileText className="h-5 w-5" />
Payment History
@@ -356,16 +409,16 @@ export default function SubscriptionDetail() {
<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"
className="flex items-center justify-between p-3 rounded border border-border hover:bg-muted/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">
<span className="font-medium text-foreground">Order #{order.order_id}</span>
<span className="text-xs px-2 py-0.5 bg-muted text-muted-foreground rounded">
{orderTypeLabels[order.order_type] || order.order_type}
</span>
</div>
<div className="text-sm text-gray-500">
{new Date(order.created_at).toLocaleDateString()}
<div className="text-sm text-muted-foreground">
{formatDate(order.created_at)}
</div>
</Link>
))}

View File

@@ -40,6 +40,8 @@ interface OrderDetailsResponse extends BaseResponse {
start_date: string;
next_payment_date: string | null;
end_date: string | null;
payment_method?: string;
gateway_supports_auto_renew?: boolean;
};
}
@@ -130,6 +132,35 @@ const OrderPay: React.FC = () => {
}
};
// C1: Compute the projected next billing date for an early renewal.
// The server-side logic in SubscriptionManager.php copies the stored next_payment_date as
// the base when it is still in the future; otherwise it falls back to `now`. We mirror
// that here so the customer sees the same date the system will set after payment.
const computeProjectedNextPaymentDate = (sub: NonNullable<OrderDetailsResponse['subscription']>): Date => {
const now = new Date();
const storedNext = sub.next_payment_date ? new Date(sub.next_payment_date) : null;
const baseDate = storedNext && storedNext.getTime() > now.getTime() ? storedNext : now;
const interval = Math.max(1, sub.billing_interval || 1);
const projected = new Date(baseDate);
switch (sub.billing_period) {
case 'day':
projected.setDate(projected.getDate() + interval);
break;
case 'week':
projected.setDate(projected.getDate() + interval * 7);
break;
case 'month':
projected.setMonth(projected.getMonth() + interval);
break;
case 'year':
projected.setFullYear(projected.getFullYear() + interval);
break;
default:
projected.setMonth(projected.getMonth() + interval);
}
return projected;
};
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>;
@@ -143,23 +174,48 @@ const OrderPay: React.FC = () => {
<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>
{isRenewal && !order.subscription && (() => {
// C1: When the order is a renewal, the customer is paying for the *upcoming*
// period. After payment, SubscriptionManager will shift next_payment_date forward
// by the billing interval (using the stored next_payment_date as the base if it
// is still in the future, otherwise `now`). We surface that projected date here
// so the customer is not surprised when their next charge lands sooner than the
// original cycle.
const projected = order.subscription
? computeProjectedNextPaymentDate(order.subscription)
: null;
const projectedLabel = projected
? projected.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
: null;
// §9 — Manual vs auto copy. If the gateway is manual, frame the renewal
// as "complete the payment to continue" rather than "will renew automatically".
const isAuto = false;
return (
<div className={`border-l-4 p-4 mb-6 ${isAuto ? 'bg-blue-50 border-blue-500' : 'bg-amber-50 border-amber-500'}`}>
<div className="flex">
<div className="flex-shrink-0">
<svg className={`h-5 w-5 ${isAuto ? 'text-blue-400' : 'text-amber-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 ${isAuto ? 'text-blue-700' : 'text-amber-800'}`}>
{isAuto ? (
<>This is a payment for your <span className="font-bold">subscription renewal</span>. After this payment, your subscription will renew automatically on the date shown below.</>
) : (
<>This is a <span className="font-bold">manual subscription renewal</span>. Your saved payment method cannot be charged automatically for this gateway, so please complete the payment to continue your subscription.</>
)}
</p>
{projectedLabel && (
<p className={`text-sm mt-1 ${isAuto ? 'text-blue-700' : 'text-amber-800'}`}>
Your next billing date will be <span className="font-bold">{projectedLabel}</span>.
</p>
)}
</div>
</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>