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:
@@ -2,18 +2,13 @@
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { CheckoutModal } from '@/components/subscription/checkout-modal';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { formatVND } from '@/lib/currency';
|
||||
import { usePlans, useBillingHistory, useQuota, subscriptionKeys } from '@/lib/hooks/use-subscription';
|
||||
import {
|
||||
subscriptionApi,
|
||||
@@ -21,13 +16,6 @@ import {
|
||||
type QuotaCheckResult,
|
||||
} from '@/lib/subscription-api';
|
||||
|
||||
function formatVND(amount: string | number): string {
|
||||
const num = typeof amount === 'string' ? Number(amount) : amount;
|
||||
if (num === 0) return 'Miễn phí';
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu đ`;
|
||||
return num.toLocaleString('vi-VN') + ' đ';
|
||||
}
|
||||
|
||||
const PLAN_TIER_ORDER = ['FREE', 'AGENT_PRO', 'INVESTOR', 'ENTERPRISE'];
|
||||
const PLAN_TIER_LABELS: Record<string, string> = {
|
||||
FREE: 'Miễn phí',
|
||||
@@ -43,18 +31,35 @@ const STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondar
|
||||
EXPIRED: { label: 'Hết hạn', variant: 'secondary' },
|
||||
};
|
||||
|
||||
const PAYMENT_STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
COMPLETED: { label: 'Thành công', variant: 'default' },
|
||||
FAILED: { label: 'Thất bại', variant: 'destructive' },
|
||||
PENDING: { label: 'Chờ xử lý', variant: 'secondary' },
|
||||
CANCELLED: { label: 'Đã hủy', variant: 'outline' },
|
||||
};
|
||||
|
||||
const PAYMENT_TYPE_LABELS: Record<string, string> = {
|
||||
SUBSCRIPTION: 'Đăng ký gói',
|
||||
LISTING_FEE: 'Phí đăng tin',
|
||||
DEPOSIT: 'Đặt cọc',
|
||||
FEATURED_LISTING: 'Tin nổi bật',
|
||||
};
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: plansData, isLoading: plansLoading } = usePlans();
|
||||
const { data: billing, isLoading: billingLoading } = useBillingHistory();
|
||||
const { data: listingsQuota } = useQuota('listings');
|
||||
const { data: savedSearchesQuota } = useQuota('saved_searches');
|
||||
const [upgradeTarget, setUpgradeTarget] = useState<PlanDto | null>(null);
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [cancelProcessing, setCancelProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('plan');
|
||||
|
||||
// Checkout modal state
|
||||
const [checkoutPlan, setCheckoutPlan] = useState<PlanDto | null>(null);
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
|
||||
const loading = plansLoading || billingLoading;
|
||||
const plans = (plansData ?? []).slice().sort(
|
||||
(a, b) => PLAN_TIER_ORDER.indexOf(a.tier) - PLAN_TIER_ORDER.indexOf(b.tier),
|
||||
@@ -69,22 +74,21 @@ export default function SubscriptionPage() {
|
||||
? STATUS_MAP[billing.subscription.status] ?? { label: 'Đang hoạt động', variant: 'default' as const }
|
||||
: null;
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (!upgradeTarget) return;
|
||||
setProcessing(true);
|
||||
const handleUpgrade = (plan: PlanDto) => {
|
||||
setCheckoutPlan(plan);
|
||||
setCheckoutOpen(true);
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setCancelProcessing(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (billing?.subscription) {
|
||||
await subscriptionApi.upgradeSubscription(upgradeTarget.tier);
|
||||
} else {
|
||||
await subscriptionApi.createSubscription(upgradeTarget.tier, billingCycle);
|
||||
}
|
||||
await subscriptionApi.cancelSubscription('Hủy từ trang quản lý');
|
||||
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.billing() });
|
||||
setUpgradeTarget(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Nâng cấp thất bại');
|
||||
setError(e instanceof Error ? e.message : 'Hủy gói thất bại');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
setCancelProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,7 +126,7 @@ export default function SubscriptionPage() {
|
||||
<TabsContent value="plan" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
Gói {PLAN_TIER_LABELS[currentTier] ?? currentTier}
|
||||
@@ -133,10 +137,12 @@ export default function SubscriptionPage() {
|
||||
: 'Bạn đang sử dụng gói miễn phí'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{subStatus && <Badge variant={subStatus.variant}>{subStatus.label}</Badge>}
|
||||
<div className="flex items-center gap-2">
|
||||
{subStatus && <Badge variant={subStatus.variant}>{subStatus.label}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Quota usage */}
|
||||
{quotas.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
@@ -155,7 +161,7 @@ export default function SubscriptionPage() {
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-2 rounded-full ${pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-primary'}`}
|
||||
className={`h-2 rounded-full transition-all ${pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-primary'}`}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -164,6 +170,28 @@ export default function SubscriptionPage() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="flex flex-col gap-2 border-t pt-4 sm:flex-row">
|
||||
{currentTier !== 'ENTERPRISE' && (
|
||||
<Button onClick={() => setActiveTab('plans')}>
|
||||
Nâng cấp gói
|
||||
</Button>
|
||||
)}
|
||||
{billing?.subscription && billing.subscription.status === 'ACTIVE' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelProcessing}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
{cancelProcessing ? 'Đang xử lý...' : 'Hủy gói'}
|
||||
</Button>
|
||||
)}
|
||||
<Link href={'/pricing' as const}>
|
||||
<Button variant="outline">Xem bảng giá</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
@@ -201,12 +229,17 @@ export default function SubscriptionPage() {
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className={isCurrent ? 'border-primary ring-1 ring-primary' : ''}
|
||||
className={isCurrent ? 'border-green-500 ring-1 ring-green-500' : ''}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{PLAN_TIER_LABELS[plan.tier] ?? plan.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">
|
||||
{PLAN_TIER_LABELS[plan.tier] ?? plan.name}
|
||||
</CardTitle>
|
||||
{isCurrent && (
|
||||
<Badge className="bg-green-600 text-white">Hiện tại</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
{formatVND(price)}
|
||||
@@ -234,15 +267,6 @@ export default function SubscriptionPage() {
|
||||
: plan.maxSavedSearches}
|
||||
</span>
|
||||
</div>
|
||||
{plan.features &&
|
||||
Object.entries(plan.features).map(([key, val]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="font-medium">
|
||||
{typeof val === 'boolean' ? (val ? '✓' : '✗') : String(val)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isCurrent ? (
|
||||
@@ -250,9 +274,17 @@ export default function SubscriptionPage() {
|
||||
Gói hiện tại
|
||||
</Button>
|
||||
) : isUpgrade ? (
|
||||
<Button className="w-full" onClick={() => setUpgradeTarget(plan)}>
|
||||
Nâng cấp
|
||||
</Button>
|
||||
plan.tier === 'ENTERPRISE' ? (
|
||||
<Link href={'/pricing' as const} className="block w-full">
|
||||
<Button variant="outline" className="w-full">
|
||||
Liên hệ tư vấn
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button className="w-full" onClick={() => handleUpgrade(plan)}>
|
||||
Nâng cấp
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button variant="outline" className="w-full" disabled>
|
||||
—
|
||||
@@ -279,39 +311,30 @@ export default function SubscriptionPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{billing.payments.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{p.type}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(p.createdAt).toLocaleDateString('vi-VN')} — {p.provider}
|
||||
</p>
|
||||
{billing.payments.map((p) => {
|
||||
const pStatus = PAYMENT_STATUS_MAP[p.status] ?? { label: p.status, variant: 'secondary' as const };
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{PAYMENT_TYPE_LABELS[p.type] ?? p.type}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(p.createdAt).toLocaleDateString('vi-VN')} — {p.provider}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">{formatVND(p.amountVND)}</p>
|
||||
<Badge variant={pStatus.variant}>
|
||||
{pStatus.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">{formatVND(p.amountVND)}</p>
|
||||
<Badge
|
||||
variant={
|
||||
p.status === 'COMPLETED'
|
||||
? 'default'
|
||||
: p.status === 'FAILED'
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{p.status === 'COMPLETED'
|
||||
? 'Thành công'
|
||||
: p.status === 'FAILED'
|
||||
? 'Thất bại'
|
||||
: p.status === 'PENDING'
|
||||
? 'Chờ xử lý'
|
||||
: p.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -320,52 +343,15 @@ export default function SubscriptionPage() {
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* Upgrade dialog */}
|
||||
<Dialog open={!!upgradeTarget} onOpenChange={(o) => !o && setUpgradeTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Nâng cấp lên {PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Xác nhận nâng cấp gói dịch vụ. Bạn sẽ được chuyển hướng đến trang thanh toán.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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</span>
|
||||
<span className="font-medium">
|
||||
{PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Chu kỳ</span>
|
||||
<span className="font-medium">
|
||||
{billingCycle === 'monthly' ? 'Hàng tháng' : 'Hàng năm'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Giá</span>
|
||||
<span className="font-semibold text-primary">
|
||||
{upgradeTarget &&
|
||||
formatVND(
|
||||
billingCycle === 'monthly'
|
||||
? upgradeTarget.priceMonthlyVND
|
||||
: upgradeTarget.priceYearlyVND,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUpgradeTarget(null)}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button onClick={handleUpgrade} disabled={processing}>
|
||||
{processing ? 'Đang xử lý...' : 'Xác nhận nâng cấp'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* Checkout modal */}
|
||||
<CheckoutModal
|
||||
open={checkoutOpen}
|
||||
onOpenChange={setCheckoutOpen}
|
||||
plan={checkoutPlan}
|
||||
billingCycle={billingCycle}
|
||||
isUpgrade={currentTierIndex > 0}
|
||||
currentTier={currentTier}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user