Subscription module: add gateway capability flow and UX fixes
This commit is contained in:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user