feat: add pricing checkout flow, MFA type fixes, and Wave 13 audit docs
- 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>
This commit is contained in:
276
apps/web/components/subscription/checkout-modal.tsx
Normal file
276
apps/web/components/subscription/checkout-modal.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
1
apps/web/components/subscription/index.ts
Normal file
1
apps/web/components/subscription/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CheckoutModal } from './checkout-modal';
|
||||
Reference in New Issue
Block a user