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:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
isEncrypted,
|
||||
type FieldEncryptionConfig,
|
||||
} from './field-encryption';
|
||||
import { type LoggerService } from './logger.service';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration types
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
276
apps/web/components/subscription/checkout-modal.tsx
Normal file
276
apps/web/components/subscription/checkout-modal.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, CreditCard, Loader2, Smartphone, Wallet } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { formatVND } from '@/lib/currency';
|
||||
import {
|
||||
paymentApi,
|
||||
type CreatePaymentPayload,
|
||||
} from '@/lib/payment-api';
|
||||
import { subscriptionApi, type PlanDto } from '@/lib/subscription-api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PaymentProvider = CreatePaymentPayload['provider'];
|
||||
|
||||
interface CheckoutModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
plan: PlanDto | null;
|
||||
billingCycle: 'monthly' | 'yearly';
|
||||
/** If true, this is an upgrade from an existing subscription */
|
||||
isUpgrade?: boolean;
|
||||
/** Current plan tier — used for display context during upgrade */
|
||||
currentTier?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAYMENT_PROVIDERS: {
|
||||
id: PaymentProvider;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'VNPAY',
|
||||
label: 'VNPay',
|
||||
icon: <CreditCard className="h-5 w-5" />,
|
||||
description: 'Thẻ ATM, Visa, MasterCard, QR Code',
|
||||
},
|
||||
{
|
||||
id: 'MOMO',
|
||||
label: 'MoMo',
|
||||
icon: <Smartphone className="h-5 w-5" />,
|
||||
description: 'Ví MoMo',
|
||||
},
|
||||
{
|
||||
id: 'ZALOPAY',
|
||||
label: 'ZaloPay',
|
||||
icon: <Wallet className="h-5 w-5" />,
|
||||
description: 'Ví ZaloPay, thẻ ngân hàng',
|
||||
},
|
||||
];
|
||||
|
||||
const PLAN_TIER_LABELS: Record<string, string> = {
|
||||
FREE: 'Miễn phí',
|
||||
AGENT_PRO: 'Môi giới Pro',
|
||||
INVESTOR: 'Nhà đầu tư',
|
||||
ENTERPRISE: 'Doanh nghiệp',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CheckoutModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
plan,
|
||||
billingCycle,
|
||||
isUpgrade = false,
|
||||
currentTier,
|
||||
}: CheckoutModalProps) {
|
||||
const [provider, setProvider] = useState<PaymentProvider>('VNPAY');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const price = plan
|
||||
? billingCycle === 'monthly'
|
||||
? plan.priceMonthlyVND
|
||||
: plan.priceYearlyVND
|
||||
: '0';
|
||||
|
||||
const handleCheckout = useCallback(async () => {
|
||||
if (!plan || Number(price) === 0) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Step 1: Create or upgrade subscription
|
||||
if (isUpgrade) {
|
||||
await subscriptionApi.upgradeSubscription(plan.tier);
|
||||
} else {
|
||||
await subscriptionApi.createSubscription(plan.tier, billingCycle);
|
||||
}
|
||||
|
||||
// Step 2: Create payment and redirect to gateway
|
||||
const returnUrl = `${window.location.origin}${window.location.pathname.replace(/\/pricing$/, '')}/payment/return`;
|
||||
|
||||
const idempotencyKey = `sub-${plan.tier}-${billingCycle}-${Date.now()}`;
|
||||
|
||||
const result = await paymentApi.createPayment({
|
||||
provider,
|
||||
type: 'SUBSCRIPTION',
|
||||
amountVND: Number(price),
|
||||
description: `${isUpgrade ? 'Nâng cấp' : 'Đăng ký'} gói ${PLAN_TIER_LABELS[plan.tier] ?? plan.name} — ${billingCycle === 'monthly' ? 'hàng tháng' : 'hàng năm'}`,
|
||||
returnUrl,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
// Redirect to payment gateway
|
||||
if (result.paymentUrl) {
|
||||
window.location.href = result.paymentUrl;
|
||||
}
|
||||
} catch (e) {
|
||||
const message =
|
||||
e instanceof Error ? e.message : 'Thanh toán thất bại. Vui lòng thử lại.';
|
||||
setError(message);
|
||||
setProcessing(false);
|
||||
}
|
||||
}, [plan, price, billingCycle, isUpgrade, provider]);
|
||||
|
||||
if (!plan) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !processing && onOpenChange(o)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isUpgrade ? 'Nâng cấp gói dịch vụ' : 'Đăng ký gói dịch vụ'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isUpgrade && currentTier
|
||||
? `Nâng cấp từ ${PLAN_TIER_LABELS[currentTier] ?? currentTier} lên ${PLAN_TIER_LABELS[plan.tier] ?? plan.name}`
|
||||
: `Chọn phương thức thanh toán để đăng ký gói ${PLAN_TIER_LABELS[plan.tier] ?? plan.name}`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Order summary */}
|
||||
<div className="space-y-2 rounded-lg border bg-muted/50 p-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Gói dịch vụ</span>
|
||||
<span className="font-medium">
|
||||
{PLAN_TIER_LABELS[plan.tier] ?? plan.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Chu kỳ thanh toán</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{billingCycle === 'monthly' ? 'Hàng tháng' : 'Hàng năm'}
|
||||
</span>
|
||||
{billingCycle === 'yearly' && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
-17%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium">Tổng cộng</span>
|
||||
<span className="text-lg font-bold text-primary">
|
||||
{formatVND(price)}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
/{billingCycle === 'monthly' ? 'tháng' : 'năm'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment method selection */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">Phương thức thanh toán</p>
|
||||
<div className="space-y-2">
|
||||
{PAYMENT_PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
disabled={processing}
|
||||
onClick={() => setProvider(p.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-muted/50',
|
||||
provider === p.id
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border',
|
||||
processing && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg',
|
||||
provider === p.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{p.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{p.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'h-4 w-4 rounded-full border-2',
|
||||
provider === p.id
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{provider === p.id && (
|
||||
<div className="h-full w-full rounded-full bg-primary-foreground scale-[0.4]" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="mt-1 font-medium underline"
|
||||
>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={processing}
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button onClick={handleCheckout} disabled={processing}>
|
||||
{processing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Đang xử lý...
|
||||
</>
|
||||
) : (
|
||||
`Thanh toán ${formatVND(price)}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
apps/web/components/subscription/index.ts
Normal file
1
apps/web/components/subscription/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CheckoutModal } from './checkout-modal';
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user