Files
goodgo-platform/apps/web/components/subscription/checkout-modal.tsx
2026-05-07 13:08:20 +07:00

288 lines
9.4 KiB
TypeScript

'use client';
import { AlertCircle, CreditCard, Loader2, Smartphone, Wallet } from 'lucide-react';
import { useCallback, useState } from 'react';
import { ComponentErrorBoundary } from '@/components/error-boundary';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { formatVND } from '@/lib/currency';
import {
paymentApi,
type CreatePaymentPayload,
} from '@/lib/payment-api';
import { subscriptionApi, type PlanDto } from '@/lib/subscription-api';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type PaymentProvider = CreatePaymentPayload['provider'];
interface CheckoutModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
plan: PlanDto | null;
billingCycle: 'monthly' | 'yearly';
/** If true, this is an upgrade from an existing subscription */
isUpgrade?: boolean;
/** Current plan tier — used for display context during upgrade */
currentTier?: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PAYMENT_PROVIDERS: {
id: PaymentProvider;
label: string;
icon: React.ReactNode;
description: string;
}[] = [
{
id: 'VNPAY',
label: 'VNPay',
icon: <CreditCard className="h-5 w-5" />,
description: 'Thẻ ATM, Visa, MasterCard, QR Code',
},
{
id: 'MOMO',
label: 'MoMo',
icon: <Smartphone className="h-5 w-5" />,
description: 'Ví MoMo',
},
{
id: 'ZALOPAY',
label: 'ZaloPay',
icon: <Wallet className="h-5 w-5" />,
description: 'Ví ZaloPay, thẻ ngân hàng',
},
];
const PLAN_TIER_LABELS: Record<string, string> = {
FREE: 'Miễn phí',
AGENT_PRO: 'Môi giới Pro',
INVESTOR: 'Nhà đầu tư',
ENTERPRISE: 'Doanh nghiệp',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function CheckoutModal(props: CheckoutModalProps) {
return (
<ComponentErrorBoundary label="thanh toán">
<CheckoutModalInner {...props} />
</ComponentErrorBoundary>
);
}
function CheckoutModalInner({
open,
onOpenChange,
plan,
billingCycle,
isUpgrade = false,
currentTier,
}: CheckoutModalProps) {
const [provider, setProvider] = useState<PaymentProvider>('VNPAY');
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const price = plan
? billingCycle === 'monthly'
? plan.priceMonthlyVND
: plan.priceYearlyVND
: '0';
const handleCheckout = useCallback(async () => {
if (!plan || Number(price) === 0) return;
setProcessing(true);
setError(null);
try {
// Step 1: Create or upgrade subscription
if (isUpgrade) {
await subscriptionApi.upgradeSubscription(plan.tier);
} else {
await subscriptionApi.createSubscription(plan.tier, billingCycle);
}
// Step 2: Create payment and redirect to gateway
const localeMatch = window.location.pathname.match(/^\/(vi|en)(\/|$)/);
const localePrefix = localeMatch?.[1] ? `/${localeMatch[1]}` : '';
const returnUrl = `${window.location.origin}${localePrefix}/payment/return`;
const idempotencyKey = `sub-${plan.tier}-${billingCycle}-${Date.now()}`;
const result = await paymentApi.createPayment({
provider,
type: 'SUBSCRIPTION',
amountVND: Number(price),
description: `${isUpgrade ? 'Nâng cấp' : 'Đăng ký'} gói ${PLAN_TIER_LABELS[plan.tier] ?? plan.name}${billingCycle === 'monthly' ? 'hàng tháng' : 'hàng năm'}`,
returnUrl,
idempotencyKey,
});
// Redirect to payment gateway
if (result.paymentUrl) {
window.location.href = result.paymentUrl;
}
} catch (e) {
const message =
e instanceof Error ? e.message : 'Thanh toán thất bại. Vui lòng thử lại.';
setError(message);
setProcessing(false);
}
}, [plan, price, billingCycle, isUpgrade, provider]);
if (!plan) return null;
return (
<Dialog open={open} onOpenChange={(o) => !processing && onOpenChange(o)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isUpgrade ? 'Nâng cấp gói dịch vụ' : 'Đăng ký gói dịch vụ'}
</DialogTitle>
<DialogDescription>
{isUpgrade && currentTier
? `Nâng cấp từ ${PLAN_TIER_LABELS[currentTier] ?? currentTier} lên ${PLAN_TIER_LABELS[plan.tier] ?? plan.name}`
: `Chọn phương thức thanh toán để đăng ký gói ${PLAN_TIER_LABELS[plan.tier] ?? plan.name}`}
</DialogDescription>
</DialogHeader>
{/* Order summary */}
<div className="space-y-2 rounded-lg border bg-muted/50 p-4 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Gói dịch vụ</span>
<span className="font-medium">
{PLAN_TIER_LABELS[plan.tier] ?? plan.name}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Chu kỳ thanh toán</span>
<div className="flex items-center gap-2">
<span className="font-medium">
{billingCycle === 'monthly' ? 'Hàng tháng' : 'Hàng năm'}
</span>
{billingCycle === 'yearly' && (
<Badge variant="secondary" className="text-xs">
-17%
</Badge>
)}
</div>
</div>
<div className="border-t pt-2">
<div className="flex justify-between">
<span className="font-medium">Tổng cộng</span>
<span className="text-lg font-bold text-primary">
{formatVND(price)}
<span className="text-xs font-normal text-muted-foreground">
/{billingCycle === 'monthly' ? 'tháng' : 'năm'}
</span>
</span>
</div>
</div>
</div>
{/* Payment method selection */}
<div className="space-y-3">
<p className="text-sm font-medium">Phương thức thanh toán</p>
<div className="space-y-2">
{PAYMENT_PROVIDERS.map((p) => (
<button
key={p.id}
type="button"
disabled={processing}
onClick={() => setProvider(p.id)}
className={cn(
'flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-muted/50',
provider === p.id
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border',
processing && 'opacity-50 cursor-not-allowed',
)}
>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg',
provider === p.id
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground',
)}
>
{p.icon}
</div>
<div className="flex-1">
<p className="text-sm font-medium">{p.label}</p>
<p className="text-xs text-muted-foreground">{p.description}</p>
</div>
<div
className={cn(
'h-4 w-4 rounded-full border-2',
provider === p.id
? 'border-primary bg-primary'
: 'border-muted-foreground/30',
)}
>
{provider === p.id && (
<div className="h-full w-full rounded-full bg-primary-foreground scale-[0.4]" />
)}
</div>
</button>
))}
</div>
</div>
{/* Error message */}
{error && (
<div className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<p>{error}</p>
<button
onClick={() => setError(null)}
className="mt-1 font-medium underline"
>
Đóng
</button>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
>
Hủy
</Button>
<Button onClick={handleCheckout} disabled={processing}>
{processing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Đang xử ...
</>
) : (
`Thanh toán ${formatVND(price)}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}