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:
242
apps/web/app/[locale]/(public)/payment/return/page.tsx
Normal file
242
apps/web/app/[locale]/(public)/payment/return/page.tsx
Normal 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 có 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">Mã 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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Check, Crown, Rocket, Shield, X, Zap } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { CheckoutModal } from '@/components/subscription/checkout-modal';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -13,8 +14,9 @@ import {
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { formatVND } from '@/lib/currency';
|
||||
import { usePlans } from '@/lib/hooks/use-subscription';
|
||||
import { usePlans, useBillingHistory } from '@/lib/hooks/use-subscription';
|
||||
import type { PlanDto } from '@/lib/subscription-api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -197,10 +199,16 @@ function getFeatureValue(
|
||||
export default function PricingPage() {
|
||||
const t = useTranslations('pricing');
|
||||
const { data: plansData, isLoading, error } = usePlans();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { data: billing } = useBillingHistory();
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>(
|
||||
'monthly',
|
||||
);
|
||||
|
||||
// Checkout modal state
|
||||
const [checkoutPlan, setCheckoutPlan] = useState<PlanDto | null>(null);
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
|
||||
const plans = (plansData ?? (error ? FALLBACK_PLANS : []))
|
||||
.slice()
|
||||
.sort(
|
||||
@@ -208,6 +216,51 @@ export default function PricingPage() {
|
||||
PLAN_TIER_ORDER.indexOf(a.tier) - PLAN_TIER_ORDER.indexOf(b.tier),
|
||||
);
|
||||
|
||||
// Current subscription info for logged-in users
|
||||
const currentTier = billing?.subscription?.planTier ?? 'FREE';
|
||||
const currentTierIndex = PLAN_TIER_ORDER.indexOf(currentTier);
|
||||
|
||||
const handleSelectPlan = (plan: PlanDto) => {
|
||||
if (plan.tier === 'ENTERPRISE') return; // Enterprise uses contact form
|
||||
if (plan.tier === 'FREE') return; // Free doesn't need payment
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to register with plan context
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckoutPlan(plan);
|
||||
setCheckoutOpen(true);
|
||||
};
|
||||
|
||||
const getPlanCta = (plan: PlanDto) => {
|
||||
const tierIndex = PLAN_TIER_ORDER.indexOf(plan.tier);
|
||||
|
||||
if (plan.tier === 'FREE') {
|
||||
if (isAuthenticated && currentTier === 'FREE') {
|
||||
return { label: t('ctaCurrentPlan'), disabled: true, variant: 'outline' as const };
|
||||
}
|
||||
return { label: t('ctaFree'), disabled: false, variant: 'outline' as const };
|
||||
}
|
||||
|
||||
if (plan.tier === 'ENTERPRISE') {
|
||||
return { label: t('ctaEnterprise'), disabled: false, variant: 'outline' as const };
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
if (plan.tier === currentTier) {
|
||||
return { label: t('ctaCurrentPlan'), disabled: true, variant: 'outline' as const };
|
||||
}
|
||||
if (tierIndex > currentTierIndex) {
|
||||
return { label: t('ctaUpgrade'), disabled: false, variant: 'default' as const };
|
||||
}
|
||||
// Downgrade not supported from pricing page
|
||||
return { label: t('ctaDowngrade'), disabled: true, variant: 'outline' as const };
|
||||
}
|
||||
|
||||
return { label: t('ctaUpgrade'), disabled: false, variant: 'default' as const };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background">
|
||||
{/* Hero section */}
|
||||
@@ -223,6 +276,17 @@ export default function PricingPage() {
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
|
||||
{/* Current plan indicator for logged-in users */}
|
||||
{isAuthenticated && billing?.subscription && (
|
||||
<div className="mx-auto mt-4 max-w-md">
|
||||
<Badge variant="default" className="px-3 py-1 text-sm">
|
||||
{t('currentPlanBadge', {
|
||||
plan: t(`tiers.${currentTier}`),
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing cycle toggle */}
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
<Button
|
||||
@@ -256,17 +320,21 @@ export default function PricingPage() {
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{plans.map((plan) => {
|
||||
const isPopular = plan.tier === 'AGENT_PRO';
|
||||
const isCurrent = isAuthenticated && plan.tier === currentTier;
|
||||
const price =
|
||||
billingCycle === 'monthly'
|
||||
? plan.priceMonthlyVND
|
||||
: plan.priceYearlyVND;
|
||||
|
||||
const cta = getPlanCta(plan);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
'relative flex flex-col transition-shadow hover:shadow-lg',
|
||||
isPopular && 'border-primary shadow-md ring-1 ring-primary',
|
||||
isCurrent && !isPopular && 'border-green-500 ring-1 ring-green-500',
|
||||
)}
|
||||
>
|
||||
{isPopular && (
|
||||
@@ -276,6 +344,13 @@ export default function PricingPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<div className="absolute -top-3 right-4">
|
||||
<Badge className="bg-green-600 text-white">
|
||||
{t('currentPlan')}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<CardHeader className="pb-2">
|
||||
<div
|
||||
className={cn(
|
||||
@@ -378,19 +453,47 @@ export default function PricingPage() {
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<Link href={'/register' as const} className="w-full">
|
||||
{plan.tier === 'FREE' && !isAuthenticated ? (
|
||||
<Link href={'/register' as const} className="w-full">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
{t('ctaFree')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : plan.tier === 'ENTERPRISE' ? (
|
||||
<Link href={'/' as const} className="w-full">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
{t('ctaEnterprise')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : !isAuthenticated ? (
|
||||
<Link href={'/register' as const} className="w-full">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={isPopular ? 'default' : 'outline'}
|
||||
size="lg"
|
||||
>
|
||||
{t('ctaUpgrade')}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={isPopular ? 'default' : 'outline'}
|
||||
variant={cta.variant}
|
||||
size="lg"
|
||||
disabled={cta.disabled}
|
||||
onClick={() => handleSelectPlan(plan)}
|
||||
>
|
||||
{plan.tier === 'FREE'
|
||||
? t('ctaFree')
|
||||
: plan.tier === 'ENTERPRISE'
|
||||
? t('ctaEnterprise')
|
||||
: t('ctaUpgrade')}
|
||||
{cta.label}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -422,9 +525,15 @@ export default function PricingPage() {
|
||||
className={cn(
|
||||
'px-4 py-3 text-center text-sm font-semibold',
|
||||
plan.tier === 'AGENT_PRO' && 'bg-primary/5',
|
||||
isAuthenticated && plan.tier === currentTier && 'bg-green-50',
|
||||
)}
|
||||
>
|
||||
{t(`tiers.${plan.tier}`)}
|
||||
<span>{t(`tiers.${plan.tier}`)}</span>
|
||||
{isAuthenticated && plan.tier === currentTier && (
|
||||
<Badge variant="secondary" className="ml-1 text-[10px]">
|
||||
{t('currentPlan')}
|
||||
</Badge>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -443,6 +552,7 @@ export default function PricingPage() {
|
||||
className={cn(
|
||||
'px-4 py-3 text-center text-sm',
|
||||
plan.tier === 'AGENT_PRO' && 'bg-primary/5',
|
||||
isAuthenticated && plan.tier === currentTier && 'bg-green-50',
|
||||
)}
|
||||
>
|
||||
{typeof val === 'boolean' ? (
|
||||
@@ -473,9 +583,15 @@ export default function PricingPage() {
|
||||
{t('ctaDescription')}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link href={'/register' as const}>
|
||||
<Button size="lg">{t('ctaRegister')}</Button>
|
||||
</Link>
|
||||
{isAuthenticated ? (
|
||||
<Link href={'/dashboard/subscription' as const}>
|
||||
<Button size="lg">{t('ctaManageSubscription')}</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={'/register' as const}>
|
||||
<Button size="lg">{t('ctaRegister')}</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={'/' as const}>
|
||||
<Button variant="outline" size="lg">
|
||||
{t('ctaLearnMore')}
|
||||
@@ -484,6 +600,16 @@ export default function PricingPage() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Checkout modal */}
|
||||
<CheckoutModal
|
||||
open={checkoutOpen}
|
||||
onOpenChange={setCheckoutOpen}
|
||||
plan={checkoutPlan}
|
||||
billingCycle={billingCycle}
|
||||
isUpgrade={isAuthenticated && currentTierIndex > 0}
|
||||
currentTier={currentTier}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user