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

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { type CommandBus } from '@nestjs/cqrs';
import { OnEvent } from '@nestjs/event-emitter';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
import { RecalculateQualityScoreCommand } from '../commands/recalculate-quality-score/recalculate-quality-score.command';
@Injectable()

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import { AgentEntity } from '../../domain/entities/agent.entity';
import {
type AgentDashboardData,

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type MarketIndex as PrismaMarketIndex, type PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import { MarketIndexEntity, type MarketIndexProps } from '../../domain/entities/market-index.entity';
import {
type IMarketIndexRepository,

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Prisma, type Valuation as PrismaValuation } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import { ValuationEntity, type ValuationProps } from '../../domain/entities/valuation.entity';
import { type IValuationRepository } from '../../domain/repositories/valuation.repository';

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
export interface AiPredictRequest {
area: number;

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { type PrismaService, type LoggerService } from '@modules/shared';
import { PrismaService, LoggerService } from '@modules/shared';
import {
type IAVMService,
type AVMParams,
@@ -11,7 +11,7 @@ import {
type IAiServiceClient,
type AiPredictRequest,
} from './ai-service.client';
import { type PrismaAVMService } from './prisma-avm.service';
import { PrismaAVMService } from './prisma-avm.service';
@Injectable()
export class HttpAVMService implements IAVMService {

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import {
type IAVMService,
type AVMParams,

View File

@@ -1,8 +1,8 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService, UnauthorizedException, ValidationException } from '@modules/shared';
import { DomainException, LoggerService, UnauthorizedException, ValidationException } from '@modules/shared';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { type MfaService } from '../../../infrastructure/services/mfa.service';
import { MfaService } from '../../../infrastructure/services/mfa.service';
import { DisableMfaCommand } from './disable-mfa.command';
@CommandHandler(DisableMfaCommand)

View File

@@ -1,13 +1,13 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService, UnauthorizedException } from '@modules/shared';
import { DomainException, LoggerService, UnauthorizedException } from '@modules/shared';
import {
MFA_CHALLENGE_REPOSITORY,
type IMfaChallengeRepository,
} from '../../../domain/repositories/mfa-challenge.repository';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { type MfaService } from '../../../infrastructure/services/mfa.service';
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { MfaService } from '../../../infrastructure/services/mfa.service';
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { UseBackupCodeCommand } from './use-backup-code.command';
@CommandHandler(UseBackupCodeCommand)

View File

@@ -1,13 +1,13 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService, UnauthorizedException } from '@modules/shared';
import { DomainException, LoggerService, UnauthorizedException } from '@modules/shared';
import {
MFA_CHALLENGE_REPOSITORY,
type IMfaChallengeRepository,
} from '../../../domain/repositories/mfa-challenge.repository';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { type MfaService } from '../../../infrastructure/services/mfa.service';
import { type TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { MfaService } from '../../../infrastructure/services/mfa.service';
import { TokenService, type TokenPair } from '../../../infrastructure/services/token.service';
import { VerifyMfaChallengeCommand } from './verify-mfa-challenge.command';
@CommandHandler(VerifyMfaChallengeCommand)

View File

@@ -1,8 +1,8 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService, ValidationException } from '@modules/shared';
import { DomainException, LoggerService, ValidationException } from '@modules/shared';
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
import { type MfaService } from '../../../infrastructure/services/mfa.service';
import { MfaService } from '../../../infrastructure/services/mfa.service';
import { VerifyMfaSetupCommand } from './verify-mfa-setup.command';
export interface VerifyMfaSetupResultDto {

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import {
type IMfaChallengeRepository,
type MfaChallengeRecord,

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import {
type IRefreshTokenRepository,
type RefreshTokenRecord,

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Prisma, type User as PrismaUser } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import { UserEntity, type UserProps } from '../../domain/entities/user.entity';
import { type IUserRepository } from '../../domain/repositories/user.repository';
import { Email } from '../../domain/value-objects/email.vo';

View File

@@ -1,6 +1,6 @@
import { randomBytes, createHash } from 'crypto';
import { Inject, Injectable } from '@nestjs/common';
import { type JwtService } from '@nestjs/jwt';
import { JwtService } from '@nestjs/jwt';
import {
REFRESH_TOKEN_REPOSITORY,
type IRefreshTokenRepository,

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
import { type OAuthService, type OAuthUserProfile } from '../services/oauth.service';
import { type TokenPair } from '../services/token.service';

View File

@@ -1,7 +1,7 @@
import { Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common';
import { type Reflector } from '@nestjs/core';
import { type UserRole } from '@prisma/client';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()

View File

@@ -1,5 +1,5 @@
import { type Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
import { type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Listing as PrismaListing, type ListingStatus } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import { ListingEntity, type ListingProps } from '../../domain/entities/listing.entity';
import { type ListingDetailData, type ListingSearchItem, type ListingSellerItem } from '../../domain/repositories/listing-read.dto';
import { type IListingRepository, type ListingSearchParams, type PaginatedResult } from '../../domain/repositories/listing.repository';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Prisma, type Property as PrismaProperty, type PropertyMedia as PrismaMedia } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import { PropertyMediaEntity, type PropertyMediaProps } from '../../domain/entities/property-media.entity';
import { PropertyEntity, type PropertyProps } from '../../domain/entities/property.entity';
import { type IPropertyRepository } from '../../domain/repositories/property.repository';

View File

@@ -9,7 +9,7 @@ import {
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
export const MEDIA_STORAGE_SERVICE = Symbol('MEDIA_STORAGE_SERVICE');

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type PropertyType } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import {
type DuplicateCandidate,
type DuplicateCheckParams,

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type EventBusService, type LoggerService } from '@modules/shared';
import { DomainException, EventBusService, LoggerService } from '@modules/shared';
import { NotificationSentEvent } from '../../../domain/events/notification-sent.event';
import {
NOTIFICATION_PREFERENCE_REPOSITORY,
@@ -10,9 +10,9 @@ import {
NOTIFICATION_REPOSITORY,
type INotificationRepository,
} from '../../../domain/repositories/notification.repository';
import { type EmailService } from '../../../infrastructure/services/email.service';
import { type FcmService } from '../../../infrastructure/services/fcm.service';
import { type TemplateService } from '../../../infrastructure/services/template.service';
import { EmailService } from '../../../infrastructure/services/email.service';
import { FcmService } from '../../../infrastructure/services/fcm.service';
import { TemplateService } from '../../../infrastructure/services/template.service';
import { SendNotificationCommand } from './send-notification.command';
@CommandHandler(SendNotificationCommand)

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import { type NotificationPreferenceEntity } from '../../domain/entities/notification-preference.entity';
import { type INotificationPreferenceRepository } from '../../domain/repositories/notification-preference.repository';
import { type NotificationChannel } from '../../domain/value-objects/notification-channel.vo';

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { type Prisma } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import { type NotificationEntity, type NotificationStatus } from '../../domain/entities/notification.entity';
import {
type INotificationRepository,

View File

@@ -1,6 +1,6 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
export interface SendEmailDto {
to: string;

View File

@@ -6,7 +6,7 @@ import {
messaging,
type ServiceAccount,
} from 'firebase-admin';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
export interface SendPushDto {
token: string;

View File

@@ -20,7 +20,7 @@ import {
NOTIFICATION_PREFERENCE_REPOSITORY,
type INotificationPreferenceRepository,
} from '../../domain';
import { type TemplateService } from '../../infrastructure/services/template.service';
import { TemplateService } from '../../infrastructure/services/template.service';
class UpdatePreferenceDto {
@ApiProperty({ enum: PrismaChannel, description: 'Notification channel' })

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Prisma, type Payment as PrismaPayment, type PaymentStatus } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { PrismaService } from '@modules/shared';
import { PaymentEntity, type PaymentProps } from '../../domain/entities/payment.entity';
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
import { Money } from '../../domain/value-objects/money.vo';

View File

@@ -1,8 +1,8 @@
import * as crypto from 'crypto';
import { Injectable } from '@nestjs/common';
import { type ConfigService } from '@nestjs/config';
import { ConfigService } from '@nestjs/config';
import { type PaymentProvider } from '@prisma/client';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
import {
type IPaymentGateway,
type CreatePaymentUrlParams,

View File

@@ -1,12 +1,12 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { type PaymentProvider } from '@prisma/client';
import { type MomoService } from './momo.service';
import { MomoService } from './momo.service';
import {
type IPaymentGateway,
type IPaymentGatewayFactory,
} from './payment-gateway.interface';
import { type VnpayService } from './vnpay.service';
import { type ZalopayService } from './zalopay.service';
import { VnpayService } from './vnpay.service';
import { ZalopayService } from './zalopay.service';
@Injectable()
export class PaymentGatewayFactory implements IPaymentGatewayFactory {

View File

@@ -1,8 +1,8 @@
import * as crypto from 'crypto';
import { Injectable } from '@nestjs/common';
import { type ConfigService } from '@nestjs/config';
import { ConfigService } from '@nestjs/config';
import { type PaymentProvider } from '@prisma/client';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
import {
type IPaymentGateway,
type CreatePaymentUrlParams,

View File

@@ -1,8 +1,8 @@
import * as crypto from 'crypto';
import { Injectable } from '@nestjs/common';
import { type ConfigService } from '@nestjs/config';
import { ConfigService } from '@nestjs/config';
import { type PaymentProvider } from '@prisma/client';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
import {
type IPaymentGateway,
type CreatePaymentUrlParams,

View File

@@ -1,7 +1,7 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService } from '@modules/shared';
import { type ListingIndexerService } from '../../../infrastructure/services/listing-indexer.service';
import { DomainException, LoggerService } from '@modules/shared';
import { ListingIndexerService } from '../../../infrastructure/services/listing-indexer.service';
import { ReindexAllCommand } from './reindex-all.command';
export interface ReindexResult {

View File

@@ -1,7 +1,7 @@
import { InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, type LoggerService } from '@modules/shared';
import { type ListingIndexerService } from '../../../infrastructure/services/listing-indexer.service';
import { DomainException, LoggerService } from '@modules/shared';
import { ListingIndexerService } from '../../../infrastructure/services/listing-indexer.service';
import { SyncListingCommand } from './sync-listing.command';
@CommandHandler(SyncListingCommand)

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { CacheService, CachePrefix, type LoggerService } from '@modules/shared';
import { type ListingIndexerService } from '../services/listing-indexer.service';
import { CacheService, CachePrefix, LoggerService } from '@modules/shared';
import { ListingIndexerService } from '../services/listing-indexer.service';
@Injectable()
export class ListingApprovedEventHandler {

View File

@@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { type ListingStatusChangedEvent } from '@modules/listings';
import { CacheService, CachePrefix, type LoggerService } from '@modules/shared';
import { type ListingIndexerService } from '../services/listing-indexer.service';
import { CacheService, CachePrefix, LoggerService } from '@modules/shared';
import { ListingIndexerService } from '../services/listing-indexer.service';
@Injectable()
export class ListingStatusChangedHandler {

View File

@@ -1,6 +1,6 @@
import { Injectable, type OnModuleInit } from '@nestjs/common';
import { Client as TypesenseClient } from 'typesense';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
@Injectable()
export class TypesenseClientService implements OnModuleInit {

View File

@@ -1,14 +1,14 @@
import { Injectable } from '@nestjs/common';
import { type Client as TypesenseClient } from 'typesense';
import { type CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
import {
type ISearchRepository,
type ListingDocument,
type SearchParams,
type SearchResult,
} from '../../domain/repositories/search.repository';
import { type TypesenseClientService } from './typesense-client.service';
import { TypesenseClientService } from './typesense-client.service';
const COLLECTION_NAME = 'listings';

View File

@@ -1,7 +1,7 @@
import { Module, type OnModuleInit } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { makeCounterProvider } from '@willsoto/nestjs-prometheus';
import { type LoggerService } from '@modules/shared';
import { LoggerService } from '@modules/shared';
import { CreateSavedSearchHandler } from './application/commands/create-saved-search/create-saved-search.handler';
import { DeleteSavedSearchHandler } from './application/commands/delete-saved-search/delete-saved-search.handler';
import { ReindexAllHandler } from './application/commands/reindex-all/reindex-all.handler';

View File

@@ -16,7 +16,7 @@ import {
isEncrypted,
type FieldEncryptionConfig,
} from './field-encryption';
import { type LoggerService } from './logger.service';
import { LoggerService } from './logger.service';
// ---------------------------------------------------------------------------
// Configuration types

View File

@@ -9,7 +9,7 @@ import { Prisma } from '@prisma/client';
import { type Request, type Response } from 'express';
import { DomainException, type ErrorResponseBody } from '../../domain/domain-exception';
import { ErrorCode } from '../../domain/error-codes';
import { type LoggerService } from '../logger.service';
import { LoggerService } from '../logger.service';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {

View File

@@ -11,8 +11,8 @@ import {
ENDPOINT_RATE_LIMIT_KEY,
type EndpointRateLimitOptions,
} from '../decorators/endpoint-rate-limit.decorator';
import { type LoggerService } from '../logger.service';
import { type RedisService } from '../redis.service';
import { LoggerService } from '../logger.service';
import { RedisService } from '../redis.service';
/** Express request extended with optional JWT user payload. */
interface AuthenticatedRequest extends Request {

View File

@@ -7,8 +7,8 @@ import {
} from '@nestjs/common';
import { type Reflector } from '@nestjs/core';
import { type UserRole } from '@prisma/client';
import { type LoggerService } from '../logger.service';
import { type RedisService } from '../redis.service';
import { LoggerService } from '../logger.service';
import { RedisService } from '../redis.service';
/**
* Role-based rate limits (requests per window).

View File

@@ -1,6 +1,6 @@
import { Injectable, type NestMiddleware } from '@nestjs/common';
import { type NextFunction, type Request, type Response } from 'express';
import { type LoggerService } from '../logger.service';
import { LoggerService } from '../logger.service';
@Injectable()
export class RequestLoggingMiddleware implements NestMiddleware {

View File

@@ -4,7 +4,7 @@ import { PrismaClient } from '@prisma/client';
import pg from 'pg';
import { createEncryptionExtension } from './encryption-middleware';
import { FieldEncryptionService } from './field-encryption.service';
import { type LoggerService } from './logger.service';
import { LoggerService } from './logger.service';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {

View File

@@ -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)}>
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ệ 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>
);
}

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>
);
}

View File

@@ -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>
);
}

View 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ử ...
</>
) : (
`Thanh toán ${formatVND(price)}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1 @@
export { CheckoutModal } from './checkout-modal';

View File

@@ -188,6 +188,11 @@
"ctaFree": "Register for free",
"ctaUpgrade": "Get started",
"ctaEnterprise": "Contact sales",
"ctaCurrentPlan": "Current plan",
"ctaDowngrade": "Downgrade",
"ctaManageSubscription": "Manage subscription",
"currentPlan": "Current",
"currentPlanBadge": "You are on the {plan} plan",
"comparisonTitle": "Compare plans in detail",
"comparisonSubtitle": "See all features for each plan",
"feature": "Feature",

View File

@@ -188,6 +188,11 @@
"ctaFree": "Đăng ký miễn phí",
"ctaUpgrade": "Bắt đầu ngay",
"ctaEnterprise": "Liên hệ tư vấn",
"ctaCurrentPlan": "Gói hiện tại",
"ctaDowngrade": "Hạ gói",
"ctaManageSubscription": "Quản lý gói dịch vụ",
"currentPlan": "Hiện tại",
"currentPlanBadge": "Bạn đang sử dụng gói {plan}",
"comparisonTitle": "So sánh chi tiết các gói",
"comparisonSubtitle": "Xem đầy đủ tính năng của từng gói dịch vụ",
"feature": "Tính năng",

File diff suppressed because one or more lines are too long