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:
Ho Ngoc Hai
2026-04-12 20:17:11 +07:00
parent 51c4ecbf4e
commit db7147a95d
66 changed files with 6530 additions and 283 deletions

View File

@@ -0,0 +1,242 @@
'use client';
import { CheckCircle, Clock, Loader2, XCircle } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Link } from '@/i18n/navigation';
import { formatVND } from '@/lib/currency';
import { paymentApi, type PaymentStatusDto } from '@/lib/payment-api';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const POLL_INTERVAL_MS = 3000;
const MAX_POLLS = 20; // max ~60 seconds
const STATUS_CONFIG: Record<
string,
{
icon: React.ReactNode;
title: string;
description: string;
color: string;
}
> = {
COMPLETED: {
icon: <CheckCircle className="h-12 w-12" />,
title: 'Thanh toán thành công!',
description: 'Gói dịch vụ của bạn đã được kích hoạt.',
color: 'text-green-600',
},
FAILED: {
icon: <XCircle className="h-12 w-12" />,
title: 'Thanh toán thất bại',
description: 'Giao dịch không thành công. Vui lòng thử lại.',
color: 'text-red-600',
},
PENDING: {
icon: <Clock className="h-12 w-12" />,
title: 'Đang xử lý thanh toán',
description: 'Giao dịch đang được xử lý. Vui lòng chờ...',
color: 'text-yellow-600',
},
CANCELLED: {
icon: <XCircle className="h-12 w-12" />,
title: 'Giao dịch đã hủy',
description: 'Bạn đã hủy giao dịch thanh toán.',
color: 'text-muted-foreground',
},
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function PaymentReturnPage() {
const searchParams = useSearchParams();
const paymentId = searchParams.get('paymentId') ?? searchParams.get('vnp_TxnRef') ?? searchParams.get('orderId');
const [payment, setPayment] = useState<PaymentStatusDto | null>(null);
const [loading, setLoading] = useState(true);
const [pollCount, setPollCount] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchStatus = useCallback(async () => {
if (!paymentId) {
setLoading(false);
return;
}
try {
const result = await paymentApi.getPaymentStatus(paymentId);
setPayment(result);
// Stop polling if terminal status
if (result.status === 'COMPLETED' || result.status === 'FAILED' || result.status === 'CANCELLED') {
setLoading(false);
return;
}
// Continue polling if still pending
setPollCount((c) => {
if (c >= MAX_POLLS) {
setLoading(false);
return c;
}
timerRef.current = setTimeout(fetchStatus, POLL_INTERVAL_MS);
return c + 1;
});
} catch {
// If we can't fetch status, stop polling after some attempts
setPollCount((c) => {
if (c >= 5) {
setLoading(false);
return c;
}
timerRef.current = setTimeout(fetchStatus, POLL_INTERVAL_MS);
return c + 1;
});
}
}, [paymentId]);
useEffect(() => {
fetchStatus();
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [fetchStatus]);
const status = payment?.status ?? (loading ? 'PENDING' : 'FAILED');
const config = STATUS_CONFIG[status] ?? {
icon: <Clock className="h-12 w-12" />,
title: 'Đang xử lý thanh toán',
description: 'Giao dịch đang được xử lý. Vui lòng chờ...',
color: 'text-yellow-600',
};
// No paymentId at all
if (!paymentId && !loading) {
return (
<div className="flex min-h-[60vh] items-center justify-center px-4">
<Card className="w-full max-w-md text-center">
<CardHeader>
<div className="mx-auto mb-4 text-muted-foreground">
<XCircle className="h-12 w-12" />
</div>
<CardTitle>Không tìm thấy giao dịch</CardTitle>
<CardDescription>
Không thông tin giao dịch thanh toán.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-center">
<Link href={'/pricing' as const}>
<Button variant="outline">Xem bảng giá</Button>
</Link>
<Link href={'/dashboard' as const}>
<Button>Về trang chủ</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-[60vh] items-center justify-center px-4">
<Card className="w-full max-w-md text-center">
<CardHeader>
<div className={`mx-auto mb-4 ${config.color}`}>
{loading && status === 'PENDING' ? (
<Loader2 className="h-12 w-12 animate-spin" />
) : (
config.icon
)}
</div>
<CardTitle>{config.title}</CardTitle>
<CardDescription>{config.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Payment details */}
{payment && (
<div className="space-y-2 rounded-lg border bg-muted/50 p-4 text-sm">
{payment.amountVND && (
<div className="flex justify-between">
<span className="text-muted-foreground">Số tiền</span>
<span className="font-semibold">{formatVND(payment.amountVND)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Phương thức</span>
<span className="font-medium">{payment.provider}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> giao dịch</span>
<span className="font-mono text-xs">
{payment.providerTxId ?? payment.id.slice(0, 12)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Thời gian</span>
<span className="font-medium">
{new Date(payment.updatedAt).toLocaleString('vi-VN')}
</span>
</div>
</div>
)}
{/* Polling indicator */}
{loading && status === 'PENDING' && (
<p className="text-xs text-muted-foreground">
Đang kiểm tra trạng thái thanh toán... ({pollCount}/{MAX_POLLS})
</p>
)}
{/* Actions */}
<div className="flex flex-col gap-2 sm:flex-row sm:justify-center">
{status === 'COMPLETED' && (
<>
<Link href={'/dashboard/subscription' as const}>
<Button>Xem gói dịch vụ</Button>
</Link>
<Link href={'/dashboard' as const}>
<Button variant="outline">Về bảng điều khiển</Button>
</Link>
</>
)}
{(status === 'FAILED' || status === 'CANCELLED') && (
<>
<Link href={'/pricing' as const}>
<Button>Thử lại</Button>
</Link>
<Link href={'/dashboard' as const}>
<Button variant="outline">Về bảng điều khiển</Button>
</Link>
</>
)}
{!loading && status === 'PENDING' && (
<>
<Button onClick={fetchStatus} variant="outline">
Kiểm tra lại
</Button>
<Link href={'/dashboard' as const}>
<Button variant="outline">Về bảng điều khiển</Button>
</Link>
</>
)}
</div>
</CardContent>
</Card>
</div>
);
}