- Pricing page: enhanced with checkout modal integration, plan comparison table, and subscription funnel - Payment return page: new VNPay/MoMo callback handler - Subscription components: new checkout-modal with payment method selection (VNPay, MoMo, ZaloPay) - API modules: type-safe PII encryption, improved error handling in MFA/auth/payments/analytics/search/notifications modules - Audit docs: comprehensive Wave 13 platform assessment, pricing audit, production readiness checklist - Updated PROJECT_TRACKER with Wave 13 status Co-Authored-By: Paperclip <noreply@paperclip.ing>
277 lines
9.0 KiB
TypeScript
277 lines
9.0 KiB
TypeScript
'use client';
|
|
|
|
import { AlertCircle, CreditCard, Loader2, Smartphone, Wallet } from 'lucide-react';
|
|
import { useCallback, useState } from 'react';
|
|
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({
|
|
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 returnUrl = `${window.location.origin}${window.location.pathname.replace(/\/pricing$/, '')}/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ử lý...
|
|
</>
|
|
) : (
|
|
`Thanh toán ${formatVND(price)}`
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|