From 7ab5e1205154d89d14d16e3d9f0649a8f9693d9e Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 18 Dec 2025 18:12:20 +0900 Subject: [PATCH] Add Residence Card Submission and Verification Features - Introduced ResidenceCardSubmission model to handle user submissions of residence cards, including status tracking and file management. - Updated User model to include a relation to ResidenceCardSubmission for better user data management. - Enhanced the checkout process to require residence card submission for SIM orders, improving compliance and verification. - Integrated VerificationModule into the application, updating relevant modules and routes to support new verification features. - Refactored various components and services to utilize the new residence card functionality, ensuring a seamless user experience. - Updated public-facing views to guide users through the residence card submission process, enhancing clarity and accessibility. --- .../migration.sql | 27 + apps/bff/prisma/schema.prisma | 25 + apps/bff/src/app.module.ts | 2 + apps/bff/src/core/config/router.config.ts | 2 + .../http/guards/global-auth.guard.ts | 98 +- apps/bff/src/modules/orders/orders.module.ts | 2 + .../orders/services/checkout.service.ts | 8 +- .../services/order-validator.service.ts | 16 +- .../verification/residence-card.controller.ts | 73 ++ .../verification/residence-card.service.ts | 87 ++ .../verification/verification.module.ts | 12 + apps/portal/next-env.d.ts | 2 +- .../shop/internet/configure/page.tsx | 8 +- .../(public)/(catalog)/shop/internet/page.tsx | 8 +- .../src/app/(public)/(catalog)/shop/page.tsx | 8 +- .../(catalog)/shop/sim/configure/page.tsx | 8 +- .../app/(public)/(catalog)/shop/sim/page.tsx | 8 +- .../app/(public)/(catalog)/shop/vpn/page.tsx | 8 +- apps/portal/src/app/account/shop/layout.tsx | 11 + .../templates/CatalogShell/CatalogShell.tsx | 34 +- .../auth/components/LoginForm/LoginForm.tsx | 6 +- .../auth/components/SignupForm/SignupForm.tsx | 6 +- .../features/auth/utils/route-protection.ts | 2 +- .../catalog/components/base/ShopTabs.tsx | 48 + .../RedirectAuthenticatedToAccountShop.tsx | 37 + .../internet/InternetImportantNotes.tsx | 22 + .../components/internet/InternetPlanCard.tsx | 21 +- .../catalog/components/sim/SimPlanCard.tsx | 32 +- .../components/sim/SimPlanTypeSection.tsx | 25 +- .../catalog/containers/SimConfigure.tsx | 2 +- .../catalog/hooks/useInternetConfigure.ts | 4 +- .../features/catalog/hooks/useSimConfigure.ts | 4 +- .../features/catalog/views/InternetPlans.tsx | 97 +- .../catalog/views/PublicCatalogHome.tsx | 11 +- .../catalog/views/PublicInternetConfigure.tsx | 84 +- .../catalog/views/PublicInternetPlans.tsx | 83 +- .../catalog/views/PublicSimConfigure.tsx | 60 +- .../features/catalog/views/PublicSimPlans.tsx | 36 +- .../features/catalog/views/SimConfigure.tsx | 2 +- .../src/features/catalog/views/SimPlans.tsx | 101 +++ .../components/AccountCheckoutContainer.tsx | 854 ++++++++++++++++++ .../checkout/components/CheckoutEntry.tsx | 13 +- .../checkout/components/steps/AddressStep.tsx | 101 ++- .../checkout/components/steps/PaymentStep.tsx | 123 ++- .../checkout/components/steps/ReviewStep.tsx | 77 +- .../services/checkout-params.service.ts | 5 +- .../checkout/stores/checkout.store.ts | 18 +- .../dashboard/hooks/useDashboardTasks.ts | 41 +- .../dashboard/views/DashboardView.tsx | 52 +- .../hooks/useResidenceCardVerification.ts | 25 + .../services/verification.service.ts | 37 + apps/portal/src/lib/api/index.ts | 3 + docs/portal-guides/README.md | 1 + docs/portal-guides/catalog-and-checkout.md | 2 + .../eligibility-and-verification.md | 156 ++++ 55 files changed, 2394 insertions(+), 244 deletions(-) create mode 100644 apps/bff/prisma/migrations/20251218060000_add_residence_card_submissions/migration.sql create mode 100644 apps/bff/src/modules/verification/residence-card.controller.ts create mode 100644 apps/bff/src/modules/verification/residence-card.service.ts create mode 100644 apps/bff/src/modules/verification/verification.module.ts create mode 100644 apps/portal/src/app/account/shop/layout.tsx create mode 100644 apps/portal/src/features/catalog/components/base/ShopTabs.tsx create mode 100644 apps/portal/src/features/catalog/components/common/RedirectAuthenticatedToAccountShop.tsx create mode 100644 apps/portal/src/features/catalog/components/internet/InternetImportantNotes.tsx create mode 100644 apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx create mode 100644 apps/portal/src/features/verification/hooks/useResidenceCardVerification.ts create mode 100644 apps/portal/src/features/verification/services/verification.service.ts create mode 100644 docs/portal-guides/eligibility-and-verification.md diff --git a/apps/bff/prisma/migrations/20251218060000_add_residence_card_submissions/migration.sql b/apps/bff/prisma/migrations/20251218060000_add_residence_card_submissions/migration.sql new file mode 100644 index 00000000..3651f8a8 --- /dev/null +++ b/apps/bff/prisma/migrations/20251218060000_add_residence_card_submissions/migration.sql @@ -0,0 +1,27 @@ +-- Add residence card verification storage + +CREATE TYPE "ResidenceCardStatus" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED'); + +CREATE TABLE "residence_card_submissions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "status" "ResidenceCardStatus" NOT NULL DEFAULT 'PENDING', + "filename" TEXT NOT NULL, + "mime_type" TEXT NOT NULL, + "size_bytes" INTEGER NOT NULL, + "content" BYTEA NOT NULL, + "submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewed_at" TIMESTAMP(3), + "reviewer_notes" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "residence_card_submissions_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "residence_card_submissions_user_id_key" ON "residence_card_submissions"("user_id"); + +ALTER TABLE "residence_card_submissions" +ADD CONSTRAINT "residence_card_submissions_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/apps/bff/prisma/schema.prisma b/apps/bff/prisma/schema.prisma index 9c127bbf..b54f38ae 100644 --- a/apps/bff/prisma/schema.prisma +++ b/apps/bff/prisma/schema.prisma @@ -36,6 +36,7 @@ model User { updatedAt DateTime @updatedAt @map("updated_at") auditLogs AuditLog[] idMapping IdMapping? + residenceCardSubmission ResidenceCardSubmission? @@map("users") } @@ -91,6 +92,30 @@ enum AuditAction { SYSTEM_MAINTENANCE } +enum ResidenceCardStatus { + PENDING + VERIFIED + REJECTED +} + +model ResidenceCardSubmission { + id String @id @default(uuid()) + userId String @unique @map("user_id") + status ResidenceCardStatus @default(PENDING) + filename String + mimeType String @map("mime_type") + sizeBytes Int @map("size_bytes") + content Bytes @db.ByteA + submittedAt DateTime @default(now()) @map("submitted_at") + reviewedAt DateTime? @map("reviewed_at") + reviewerNotes String? @map("reviewer_notes") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("residence_card_submissions") +} + // Per-SIM daily usage snapshot used to build full-month charts model SimUsageDaily { id Int @id @default(autoincrement()) diff --git a/apps/bff/src/app.module.ts b/apps/bff/src/app.module.ts index 8bb40128..b35b6089 100644 --- a/apps/bff/src/app.module.ts +++ b/apps/bff/src/app.module.ts @@ -36,6 +36,7 @@ import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.mo import { CurrencyModule } from "@bff/modules/currency/currency.module.js"; import { SupportModule } from "@bff/modules/support/support.module.js"; import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js"; +import { VerificationModule } from "@bff/modules/verification/verification.module.js"; // System Modules import { HealthModule } from "@bff/modules/health/health.module.js"; @@ -87,6 +88,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js"; CurrencyModule, SupportModule, RealtimeApiModule, + VerificationModule, // === SYSTEM MODULES === HealthModule, diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index 18322b1b..eda51f67 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -11,6 +11,7 @@ import { SecurityModule } from "@bff/core/security/security.module.js"; import { SupportModule } from "@bff/modules/support/support.module.js"; import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js"; import { CheckoutRegistrationModule } from "@bff/modules/checkout-registration/checkout-registration.module.js"; +import { VerificationModule } from "@bff/modules/verification/verification.module.js"; export const apiRoutes: Routes = [ { @@ -28,6 +29,7 @@ export const apiRoutes: Routes = [ { path: "", module: SecurityModule }, { path: "", module: RealtimeApiModule }, { path: "", module: CheckoutRegistrationModule }, + { path: "", module: VerificationModule }, ], }, ]; diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index fb7368a0..5eb17735 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -47,6 +47,16 @@ export class GlobalAuthGuard implements CanActivate { ]); if (isPublic) { + const token = extractAccessTokenFromRequest(request); + if (token) { + try { + await this.attachUserFromToken(request, token); + this.logger.debug(`Authenticated session detected on public route: ${route}`); + } catch (error) { + // Public endpoints should remain accessible even if the session is missing/expired/invalid. + this.logger.debug(`Ignoring invalid session on public route: ${route}`); + } + } this.logger.debug(`Public route accessed: ${route}`); return true; } @@ -61,45 +71,7 @@ export class GlobalAuthGuard implements CanActivate { throw new UnauthorizedException("Missing token"); } - const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>( - token - ); - - const tokenType = (payload as { type?: unknown }).type; - if (typeof tokenType === "string" && tokenType !== "access") { - throw new UnauthorizedException("Invalid access token"); - } - - if (!payload.sub || !payload.email) { - throw new UnauthorizedException("Invalid token payload"); - } - - // Explicit expiry buffer check to avoid tokens expiring mid-request - if (typeof payload.exp !== "number") { - throw new UnauthorizedException("Token missing expiration claim"); - } - const nowSeconds = Math.floor(Date.now() / 1000); - if (payload.exp < nowSeconds + 60) { - throw new UnauthorizedException("Token expired or expiring soon"); - } - - // Then check token blacklist - const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token); - if (isBlacklisted) { - this.logger.warn(`Blacklisted token attempted access to: ${route}`); - throw new UnauthorizedException("Token has been revoked"); - } - - const prismaUser = await this.usersFacade.findByIdInternal(payload.sub); - if (!prismaUser) { - throw new UnauthorizedException("User not found"); - } - if (prismaUser.email !== payload.email) { - throw new UnauthorizedException("Token subject does not match user record"); - } - - const profile: UserAuth = mapPrismaUserToDomain(prismaUser); - (request as RequestWithRoute & { user?: UserAuth }).user = profile; + await this.attachUserFromToken(request, token, route); this.logger.debug(`Authenticated access to: ${route}`); return true; @@ -168,4 +140,52 @@ export class GlobalAuthGuard implements CanActivate { const normalized = path.endsWith("/") ? path.slice(0, -1) : path; return normalized === "/auth/logout" || normalized === "/api/auth/logout"; } + + private async attachUserFromToken( + request: RequestWithRoute, + token: string, + route?: string + ): Promise { + const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>( + token + ); + + const tokenType = (payload as { type?: unknown }).type; + if (typeof tokenType === "string" && tokenType !== "access") { + throw new UnauthorizedException("Invalid access token"); + } + + if (!payload.sub || !payload.email) { + throw new UnauthorizedException("Invalid token payload"); + } + + // Explicit expiry buffer check to avoid tokens expiring mid-request + if (typeof payload.exp !== "number") { + throw new UnauthorizedException("Token missing expiration claim"); + } + const nowSeconds = Math.floor(Date.now() / 1000); + if (payload.exp < nowSeconds + 60) { + throw new UnauthorizedException("Token expired or expiring soon"); + } + + // Then check token blacklist + const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token); + if (isBlacklisted) { + if (route) { + this.logger.warn(`Blacklisted token attempted access to: ${route}`); + } + throw new UnauthorizedException("Token has been revoked"); + } + + const prismaUser = await this.usersFacade.findByIdInternal(payload.sub); + if (!prismaUser) { + throw new UnauthorizedException("User not found"); + } + if (prismaUser.email !== payload.email) { + throw new UnauthorizedException("Token subject does not match user record"); + } + + const profile: UserAuth = mapPrismaUserToDomain(prismaUser); + (request as RequestWithRoute & { user?: UserAuth }).user = profile; + } } diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index e9028f4b..d9be314f 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -8,6 +8,7 @@ import { CoreConfigModule } from "@bff/core/config/config.module.js"; import { DatabaseModule } from "@bff/core/database/database.module.js"; import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js"; +import { VerificationModule } from "@bff/modules/verification/verification.module.js"; // Clean modular order services import { OrderValidator } from "./services/order-validator.service.js"; @@ -39,6 +40,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js"; DatabaseModule, CatalogModule, CacheModule, + VerificationModule, OrderFieldConfigModule, ], controllers: [OrdersController, CheckoutController], diff --git a/apps/bff/src/modules/orders/services/checkout.service.ts b/apps/bff/src/modules/orders/services/checkout.service.ts index 40facd11..07d32765 100644 --- a/apps/bff/src/modules/orders/services/checkout.service.ts +++ b/apps/bff/src/modules/orders/services/checkout.service.ts @@ -237,9 +237,11 @@ export class CheckoutService { userId?: string ): Promise<{ items: CheckoutItem[] }> { const items: CheckoutItem[] = []; - const plans: SimCatalogProduct[] = userId - ? await this.simCatalogService.getPlansForUser(userId) - : await this.simCatalogService.getPlans(); + if (!userId) { + throw new BadRequestException("Please sign in to order SIM service."); + } + + const plans: SimCatalogProduct[] = await this.simCatalogService.getPlansForUser(userId); const rawActivationFees: SimActivationFeeCatalogItem[] = await this.simCatalogService.getActivationFees(); const activationFees = this.filterActivationFeesWithSku(rawActivationFees); diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index 277c40a8..6c58372a 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -16,6 +16,7 @@ type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw; import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js"; import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js"; import { PaymentValidatorService } from "./payment-validator.service.js"; +import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js"; /** * Handles all order validation logic - both format and business rules @@ -31,7 +32,8 @@ export class OrderValidator { private readonly whmcs: WhmcsConnectionOrchestratorService, private readonly pricebookService: OrderPricebookService, private readonly simCatalogService: SimCatalogService, - private readonly paymentValidator: PaymentValidatorService + private readonly paymentValidator: PaymentValidatorService, + private readonly residenceCards: ResidenceCardService ) {} /** @@ -269,6 +271,18 @@ export class OrderValidator { const _productMeta = await this.validateSKUs(businessValidatedBody.skus, pricebookId); if (businessValidatedBody.orderType === "SIM") { + const verification = await this.residenceCards.getStatusForUser(userId); + if (verification.status === "not_submitted") { + throw new BadRequestException( + "Residence card submission required for SIM orders. Please upload your residence card and try again." + ); + } + if (verification.status === "rejected") { + throw new BadRequestException( + "Your residence card submission was rejected. Please resubmit your residence card and try again." + ); + } + const activationFees = await this.simCatalogService.getActivationFees(); const activationSkus = new Set( activationFees diff --git a/apps/bff/src/modules/verification/residence-card.controller.ts b/apps/bff/src/modules/verification/residence-card.controller.ts new file mode 100644 index 00000000..dd8c8e84 --- /dev/null +++ b/apps/bff/src/modules/verification/residence-card.controller.ts @@ -0,0 +1,73 @@ +import { + BadRequestException, + Controller, + Get, + Post, + Req, + UseGuards, + UseInterceptors, + UploadedFile, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js"; +import { + ResidenceCardService, + type ResidenceCardVerificationDto, +} from "./residence-card.service.js"; + +const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB +const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]); + +type UploadedResidenceCard = { + originalname: string; + mimetype: string; + size: number; + buffer: Buffer; +}; + +@Controller("verification/residence-card") +@UseGuards(RateLimitGuard) +export class ResidenceCardController { + constructor(private readonly residenceCards: ResidenceCardService) {} + + @Get() + @RateLimit({ limit: 60, ttl: 60 }) + async getStatus(@Req() req: RequestWithUser): Promise { + return this.residenceCards.getStatusForUser(req.user.id); + } + + @Post() + @RateLimit({ limit: 3, ttl: 300 }) + @UseInterceptors( + FileInterceptor("file", { + limits: { fileSize: MAX_FILE_BYTES }, + fileFilter: (_req, file, cb) => { + if (!ALLOWED_MIME_TYPES.has(file.mimetype)) { + cb( + new BadRequestException("Unsupported file type. Please upload a JPG, PNG, or PDF."), + false + ); + return; + } + cb(null, true); + }, + }) + ) + async submit( + @Req() req: RequestWithUser, + @UploadedFile() file?: UploadedResidenceCard + ): Promise { + if (!file) { + throw new BadRequestException("Missing file upload."); + } + + return this.residenceCards.submitForUser({ + userId: req.user.id, + filename: file.originalname || "residence-card", + mimeType: file.mimetype, + sizeBytes: file.size, + content: file.buffer as unknown as Uint8Array, + }); + } +} diff --git a/apps/bff/src/modules/verification/residence-card.service.ts b/apps/bff/src/modules/verification/residence-card.service.ts new file mode 100644 index 00000000..74a19b5d --- /dev/null +++ b/apps/bff/src/modules/verification/residence-card.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "@bff/infra/database/prisma.service.js"; +import { ResidenceCardStatus, type ResidenceCardSubmission } from "@prisma/client"; + +type ResidenceCardStatusDto = "not_submitted" | "pending" | "verified" | "rejected"; + +export interface ResidenceCardVerificationDto { + status: ResidenceCardStatusDto; + filename: string | null; + mimeType: string | null; + sizeBytes: number | null; + submittedAt: string | null; + reviewedAt: string | null; + reviewerNotes: string | null; +} + +function mapStatus(status: ResidenceCardStatus): ResidenceCardStatusDto { + if (status === ResidenceCardStatus.VERIFIED) return "verified"; + if (status === ResidenceCardStatus.REJECTED) return "rejected"; + return "pending"; +} + +function toDto(record: ResidenceCardSubmission | null): ResidenceCardVerificationDto { + if (!record) { + return { + status: "not_submitted", + filename: null, + mimeType: null, + sizeBytes: null, + submittedAt: null, + reviewedAt: null, + reviewerNotes: null, + }; + } + return { + status: mapStatus(record.status), + filename: record.filename ?? null, + mimeType: record.mimeType ?? null, + sizeBytes: typeof record.sizeBytes === "number" ? record.sizeBytes : null, + submittedAt: record.submittedAt ? record.submittedAt.toISOString() : null, + reviewedAt: record.reviewedAt ? record.reviewedAt.toISOString() : null, + reviewerNotes: record.reviewerNotes ?? null, + }; +} + +@Injectable() +export class ResidenceCardService { + constructor(private readonly prisma: PrismaService) {} + + async getStatusForUser(userId: string): Promise { + const record = await this.prisma.residenceCardSubmission.findUnique({ where: { userId } }); + return toDto(record); + } + + async submitForUser(params: { + userId: string; + filename: string; + mimeType: string; + sizeBytes: number; + content: Uint8Array; + }): Promise { + const record = await this.prisma.residenceCardSubmission.upsert({ + where: { userId: params.userId }, + create: { + userId: params.userId, + status: ResidenceCardStatus.PENDING, + filename: params.filename, + mimeType: params.mimeType, + sizeBytes: params.sizeBytes, + content: params.content, + submittedAt: new Date(), + }, + update: { + status: ResidenceCardStatus.PENDING, + filename: params.filename, + mimeType: params.mimeType, + sizeBytes: params.sizeBytes, + content: params.content, + submittedAt: new Date(), + reviewedAt: null, + reviewerNotes: null, + }, + }); + + return toDto(record); + } +} diff --git a/apps/bff/src/modules/verification/verification.module.ts b/apps/bff/src/modules/verification/verification.module.ts new file mode 100644 index 00000000..ac3e871d --- /dev/null +++ b/apps/bff/src/modules/verification/verification.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "@bff/infra/database/prisma.module.js"; +import { ResidenceCardController } from "./residence-card.controller.js"; +import { ResidenceCardService } from "./residence-card.service.js"; + +@Module({ + imports: [PrismaModule], + controllers: [ResidenceCardController], + providers: [ResidenceCardService], + exports: [ResidenceCardService], +}) +export class VerificationModule {} diff --git a/apps/portal/next-env.d.ts b/apps/portal/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/portal/next-env.d.ts +++ b/apps/portal/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/portal/src/app/(public)/(catalog)/shop/internet/configure/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/internet/configure/page.tsx index d7c8b07d..9c763561 100644 --- a/apps/portal/src/app/(public)/(catalog)/shop/internet/configure/page.tsx +++ b/apps/portal/src/app/(public)/(catalog)/shop/internet/configure/page.tsx @@ -5,7 +5,13 @@ */ import { PublicInternetConfigureView } from "@/features/catalog/views/PublicInternetConfigure"; +import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop"; export default function PublicInternetConfigurePage() { - return ; + return ( + <> + + + + ); } diff --git a/apps/portal/src/app/(public)/(catalog)/shop/internet/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/internet/page.tsx index 7708aad3..2b2c557d 100644 --- a/apps/portal/src/app/(public)/(catalog)/shop/internet/page.tsx +++ b/apps/portal/src/app/(public)/(catalog)/shop/internet/page.tsx @@ -5,7 +5,13 @@ */ import { PublicInternetPlansView } from "@/features/catalog/views/PublicInternetPlans"; +import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop"; export default function PublicInternetPlansPage() { - return ; + return ( + <> + + + + ); } diff --git a/apps/portal/src/app/(public)/(catalog)/shop/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/page.tsx index f047f9f9..e1fc3a22 100644 --- a/apps/portal/src/app/(public)/(catalog)/shop/page.tsx +++ b/apps/portal/src/app/(public)/(catalog)/shop/page.tsx @@ -5,7 +5,13 @@ */ import { PublicCatalogHomeView } from "@/features/catalog/views/PublicCatalogHome"; +import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop"; export default function PublicCatalogPage() { - return ; + return ( + <> + + + + ); } diff --git a/apps/portal/src/app/(public)/(catalog)/shop/sim/configure/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/sim/configure/page.tsx index cdcc4eb9..3688ba08 100644 --- a/apps/portal/src/app/(public)/(catalog)/shop/sim/configure/page.tsx +++ b/apps/portal/src/app/(public)/(catalog)/shop/sim/configure/page.tsx @@ -5,7 +5,13 @@ */ import { PublicSimConfigureView } from "@/features/catalog/views/PublicSimConfigure"; +import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop"; export default function PublicSimConfigurePage() { - return ; + return ( + <> + + + + ); } diff --git a/apps/portal/src/app/(public)/(catalog)/shop/sim/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/sim/page.tsx index 1c58e494..6ba056eb 100644 --- a/apps/portal/src/app/(public)/(catalog)/shop/sim/page.tsx +++ b/apps/portal/src/app/(public)/(catalog)/shop/sim/page.tsx @@ -5,7 +5,13 @@ */ import { PublicSimPlansView } from "@/features/catalog/views/PublicSimPlans"; +import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop"; export default function PublicSimPlansPage() { - return ; + return ( + <> + + + + ); } diff --git a/apps/portal/src/app/(public)/(catalog)/shop/vpn/page.tsx b/apps/portal/src/app/(public)/(catalog)/shop/vpn/page.tsx index 0f58c3c2..47d3c37d 100644 --- a/apps/portal/src/app/(public)/(catalog)/shop/vpn/page.tsx +++ b/apps/portal/src/app/(public)/(catalog)/shop/vpn/page.tsx @@ -5,7 +5,13 @@ */ import { PublicVpnPlansView } from "@/features/catalog/views/PublicVpnPlans"; +import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop"; export default function PublicVpnPlansPage() { - return ; + return ( + <> + + + + ); } diff --git a/apps/portal/src/app/account/shop/layout.tsx b/apps/portal/src/app/account/shop/layout.tsx new file mode 100644 index 00000000..ead5f5f5 --- /dev/null +++ b/apps/portal/src/app/account/shop/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; +import { ShopTabs } from "@/features/catalog/components/base/ShopTabs"; + +export default function AccountShopLayout({ children }: { children: ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx b/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx index cdfbe46b..3cbce476 100644 --- a/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx +++ b/apps/portal/src/components/templates/CatalogShell/CatalogShell.tsx @@ -12,44 +12,14 @@ import { useEffect } from "react"; import Link from "next/link"; import { Logo } from "@/components/atoms/logo"; import { useAuthStore } from "@/features/auth/services/auth.store"; +import { ShopTabs } from "@/features/catalog/components/base/ShopTabs"; export interface CatalogShellProps { children: ReactNode; } export function CatalogNav() { - return ( -
-
- -
-
- ); + return ; } export function CatalogShell({ children }: CatalogShellProps) { diff --git a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx index 217cb5de..0d7755fe 100644 --- a/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx +++ b/apps/portal/src/features/auth/components/LoginForm/LoginForm.tsx @@ -7,6 +7,7 @@ import { useCallback } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { Button, Input, ErrorMessage } from "@/components/atoms"; import { FormField } from "@/components/molecules/FormField/FormField"; import { useLogin } from "../../hooks/use-auth"; @@ -41,7 +42,10 @@ export function LoginForm({ showForgotPasswordLink = true, className = "", }: LoginFormProps) { + const searchParams = useSearchParams(); const { login, loading, error, clearError } = useLogin(); + const redirect = searchParams?.get("next") || searchParams?.get("redirect"); + const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""; const handleLogin = useCallback( async ({ rememberMe: _rememberMe, ...formData }: LoginFormValues) => { @@ -143,7 +147,7 @@ export function LoginForm({

Don't have an account?{" "} Sign up diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx index 8efc594b..dd1f005b 100644 --- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx @@ -7,6 +7,7 @@ import { useState, useCallback } from "react"; import Link from "next/link"; +import { useSearchParams } from "next/navigation"; import { ErrorMessage } from "@/components/atoms"; import { useSignup } from "../../hooks/use-auth"; import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/auth"; @@ -114,8 +115,11 @@ const STEP_VALIDATION_SCHEMAS: Record<(typeof STEPS)[number]["key"], z.ZodTypeAn }; export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) { + const searchParams = useSearchParams(); const { signup, loading, error, clearError } = useSignup(); const [step, setStep] = useState(0); + const redirect = searchParams?.get("next") || searchParams?.get("redirect"); + const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""; const form = useZodForm({ schema: signupFormSchema, @@ -274,7 +278,7 @@ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormPro

Already have an account?{" "} Sign in diff --git a/apps/portal/src/features/auth/utils/route-protection.ts b/apps/portal/src/features/auth/utils/route-protection.ts index 7988c717..1726f135 100644 --- a/apps/portal/src/features/auth/utils/route-protection.ts +++ b/apps/portal/src/features/auth/utils/route-protection.ts @@ -1,7 +1,7 @@ import type { ReadonlyURLSearchParams } from "next/navigation"; export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): string { - const dest = searchParams.get("redirect") || "/account"; + const dest = searchParams.get("next") || searchParams.get("redirect") || "/account"; // prevent open redirects if (dest.startsWith("http://") || dest.startsWith("https://")) return "/account"; return dest; diff --git a/apps/portal/src/features/catalog/components/base/ShopTabs.tsx b/apps/portal/src/features/catalog/components/base/ShopTabs.tsx new file mode 100644 index 00000000..ee806812 --- /dev/null +++ b/apps/portal/src/features/catalog/components/base/ShopTabs.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +type BasePath = "/shop" | "/account/shop"; + +type Tab = { + label: string; + href: `${BasePath}` | `${BasePath}/${string}`; +}; + +export function ShopTabs({ basePath }: { basePath: BasePath }) { + const pathname = usePathname(); + + const tabs: Tab[] = [ + { label: "All Services", href: basePath }, + { label: "Internet", href: `${basePath}/internet` }, + { label: "SIM", href: `${basePath}/sim` }, + { label: "VPN", href: `${basePath}/vpn` }, + ]; + + const isActive = (href: string) => pathname === href || pathname.startsWith(`${href}/`); + + return ( +

+
+ +
+
+ ); +} diff --git a/apps/portal/src/features/catalog/components/common/RedirectAuthenticatedToAccountShop.tsx b/apps/portal/src/features/catalog/components/common/RedirectAuthenticatedToAccountShop.tsx new file mode 100644 index 00000000..80308644 --- /dev/null +++ b/apps/portal/src/features/catalog/components/common/RedirectAuthenticatedToAccountShop.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useEffect } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useAuthStore } from "@/features/auth/services/auth.store"; + +type Props = { + /** + * Absolute target path (no querystring). When omitted, the current pathname is transformed: + * `/shop/...` -> `/account/shop/...`. + */ + targetPath?: string; +}; + +export function RedirectAuthenticatedToAccountShop({ targetPath }: Props) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const isAuthenticated = useAuthStore(state => state.isAuthenticated); + const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth); + + useEffect(() => { + if (!hasCheckedAuth) return; + if (!isAuthenticated) return; + + const nextPath = + targetPath ?? + (pathname.startsWith("/shop") + ? pathname.replace(/^\/shop/, "/account/shop") + : "/account/shop"); + + const query = searchParams?.toString() ?? ""; + router.replace(query ? `${nextPath}?${query}` : nextPath); + }, [hasCheckedAuth, isAuthenticated, pathname, router, searchParams, targetPath]); + + return null; +} diff --git a/apps/portal/src/features/catalog/components/internet/InternetImportantNotes.tsx b/apps/portal/src/features/catalog/components/internet/InternetImportantNotes.tsx new file mode 100644 index 00000000..48f9d93a --- /dev/null +++ b/apps/portal/src/features/catalog/components/internet/InternetImportantNotes.tsx @@ -0,0 +1,22 @@ +"use client"; + +export function InternetImportantNotes() { + return ( +
+ + Important notes + +
+
    +
  • Theoretical internet speed is the same for all three packages
  • +
  • One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
  • +
  • + Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month + + ¥1,000-3,000 one-time) +
  • +
  • In-home technical assistance available (¥15,000 onsite visiting fee)
  • +
+
+
+ ); +} diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index 213320dc..4f627284 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -21,8 +21,12 @@ interface InternetPlanCardProps { installations: InternetInstallationCatalogItem[]; disabled?: boolean; disabledReason?: string; - /** Override the default configure href (default: /shop/internet/configure?plan=...) */ + /** Override the default configure href (default: /shop/internet/configure?planSku=...) */ configureHref?: string; + /** Override default "Configure Plan" action (used for public browse-only flows) */ + action?: { label: string; href: string }; + /** Optional small prefix above pricing (e.g. "Starting from") */ + pricingPrefix?: string; } // Tier-based styling using design tokens @@ -51,6 +55,8 @@ export function InternetPlanCard({ disabled, disabledReason, configureHref, + action, + pricingPrefix, }: InternetPlanCardProps) { const router = useRouter(); const shopBasePath = useShopBasePath(); @@ -180,6 +186,11 @@ export function InternetPlanCard({ {/* Pricing - Full width below */}
+ {pricingPrefix ? ( +
+ {pricingPrefix} +
+ ) : null} : undefined} onClick={() => { if (isDisabled) return; + if (action) { + router.push(action.href); + return; + } const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState(); resetInternetConfig(); setInternetConfig({ planSku: plan.sku, currentStep: 1 }); const href = configureHref ?? - `${shopBasePath}/internet/configure?plan=${encodeURIComponent(plan.sku)}`; + `${shopBasePath}/internet/configure?planSku=${encodeURIComponent(plan.sku)}`; router.push(href); }} > - {isDisabled ? disabledReason || "Not available" : "Configure Plan"} + {isDisabled ? disabledReason || "Not available" : (action?.label ?? "Configure Plan")}
diff --git a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx index fbc33c22..fa889fa6 100644 --- a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx @@ -9,11 +9,31 @@ import { CardPricing } from "@/features/catalog/components/base/CardPricing"; import { CardBadge } from "@/features/catalog/components/base/CardBadge"; import { useRouter } from "next/navigation"; import { useCatalogStore } from "@/features/catalog/services/catalog.store"; +import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; -export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) { +export type SimPlanCardAction = { label: string; href: string }; +export type SimPlanCardActionResolver = + | SimPlanCardAction + | ((plan: SimCatalogProduct) => SimPlanCardAction); + +export function SimPlanCard({ + plan, + isFamily, + action, + disabled, + disabledReason, +}: { + plan: SimCatalogProduct; + isFamily?: boolean; + action?: SimPlanCardActionResolver; + disabled?: boolean; + disabledReason?: string; +}) { const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0; const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount); const router = useRouter(); + const shopBasePath = useShopBasePath(); + const resolvedAction = typeof action === "function" ? action(plan) : action; return ( { + if (disabled) return; + if (resolvedAction) { + router.push(resolvedAction.href); + return; + } const { resetSimConfig, setSimConfig } = useCatalogStore.getState(); resetSimConfig(); setSimConfig({ planSku: plan.sku, currentStep: 1 }); - router.push(`/catalog/sim/configure?plan=${plan.sku}`); + router.push(`${shopBasePath}/sim/configure?planSku=${encodeURIComponent(plan.sku)}`); }} rightIcon={} > - Configure + {disabled ? disabledReason || "Not available" : (resolvedAction?.label ?? "Configure")} ); diff --git a/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx b/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx index f56ad849..697b86f2 100644 --- a/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimPlanTypeSection.tsx @@ -3,7 +3,7 @@ import React from "react"; import { UsersIcon } from "@heroicons/react/24/outline"; import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; -import { SimPlanCard } from "./SimPlanCard"; +import { SimPlanCard, type SimPlanCardActionResolver } from "./SimPlanCard"; export function SimPlanTypeSection({ title, @@ -11,12 +11,18 @@ export function SimPlanTypeSection({ icon, plans, showFamilyDiscount, + cardAction, + cardDisabled, + cardDisabledReason, }: { title: string; description: string; icon: React.ReactNode; plans: SimCatalogProduct[]; showFamilyDiscount: boolean; + cardAction?: SimPlanCardActionResolver; + cardDisabled?: boolean; + cardDisabledReason?: string; }) { if (plans.length === 0) return null; const regularPlans = plans.filter(p => !p.simHasFamilyDiscount); @@ -33,7 +39,13 @@ export function SimPlanTypeSection({
{regularPlans.map(plan => ( - + ))}
{showFamilyDiscount && familyPlans.length > 0 && ( @@ -47,7 +59,14 @@ export function SimPlanTypeSection({
{familyPlans.map(plan => ( - + ))}
diff --git a/apps/portal/src/features/catalog/containers/SimConfigure.tsx b/apps/portal/src/features/catalog/containers/SimConfigure.tsx index a750b84e..29147ac4 100644 --- a/apps/portal/src/features/catalog/containers/SimConfigure.tsx +++ b/apps/portal/src/features/catalog/containers/SimConfigure.tsx @@ -7,7 +7,7 @@ import { SimConfigureView } from "@/features/catalog/components/sim/SimConfigure export function SimConfigureContainer() { const searchParams = useSearchParams(); const router = useRouter(); - const planId = searchParams.get("plan") || undefined; + const planId = searchParams.get("planSku") || undefined; const vm = useSimConfigure(planId); diff --git a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts index 4c6cc5c4..94cbb7cb 100644 --- a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts @@ -45,7 +45,7 @@ export function useInternetConfigure(): UseInternetConfigureResult { const shopBasePath = useShopBasePath(); const searchParams = useSearchParams(); const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]); - const urlPlanSku = searchParams.get("plan"); + const urlPlanSku = searchParams.get("planSku"); // Get state from Zustand store (persisted) const configState = useCatalogStore(state => state.internet); @@ -67,7 +67,7 @@ export function useInternetConfigure(): UseInternetConfigureResult { // If URL has configuration params (back navigation from checkout), restore them const params = new URLSearchParams(paramsSignature); - const hasConfigParams = params.has("plan") ? params.size > 1 : params.size > 0; + const hasConfigParams = params.has("planSku") ? params.size > 1 : params.size > 0; const shouldRestore = hasConfigParams && lastRestoredSignatureRef.current !== paramsSignature && paramsSignature; if (shouldRestore) { diff --git a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts index 5fc6e713..f57f6de2 100644 --- a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts @@ -57,7 +57,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { const router = useRouter(); const shopBasePath = useShopBasePath(); const searchParams = useSearchParams(); - const urlPlanSku = searchParams.get("plan"); + const urlPlanSku = searchParams.get("planSku"); const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]); // Get state from Zustand store (persisted) @@ -81,7 +81,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { // If URL has configuration params (back navigation from checkout), restore them const params = new URLSearchParams(paramsSignature); - const hasConfigParams = params.has("plan") ? params.size > 1 : params.size > 0; + const hasConfigParams = params.has("planSku") ? params.size > 1 : params.size > 0; const shouldRestore = hasConfigParams && lastRestoredSignatureRef.current !== paramsSignature && paramsSignature; if (shouldRestore) { diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index 881bfa81..bf60f36b 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/atoms/button"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; +import { InternetImportantNotes } from "@/features/catalog/components/internet/InternetImportantNotes"; import { useInternetEligibility, useRequestInternetEligibilityCheck, @@ -59,22 +60,24 @@ export function InternetPlansContainer() { (user?.address?.country || user?.address?.countryCode) ); + useEffect(() => { + if (!user?.id) return; + if (eligibilityValue !== null) return; + const key = `cp:internet-eligibility:last:${user.id}`; + if (!localStorage.getItem(key)) { + localStorage.setItem(key, "PENDING"); + } + }, [eligibilityValue, user?.id]); + useEffect(() => { if (eligibilityQuery.isSuccess) { if (typeof eligibilityValue === "string" && eligibilityValue.trim().length > 0) { setEligibility(eligibilityValue); return; } - if (eligibilityValue === null) { - setEligibility(""); - return; - } + setEligibility(""); } - - if (plans.length > 0) { - setEligibility(plans[0].internetOfferingType || "Home 1G"); - } - }, [eligibilityQuery.isSuccess, eligibilityValue, plans]); + }, [eligibilityQuery.isSuccess, eligibilityValue]); const getEligibilityIcon = (offeringType?: string) => { const lower = (offeringType || "").toLowerCase(); @@ -90,6 +93,17 @@ export function InternetPlansContainer() { return "text-muted-foreground bg-muted border-border"; }; + const silverPlans: InternetPlanCatalogItem[] = useMemo( + () => + plans.filter( + p => + String(p.internetPlanTier || "") + .trim() + .toLowerCase() === "silver" + ) ?? [], + [plans] + ); + if (isLoading || error) { return ( - {eligibility && ( + {eligibilityQuery.isLoading ? ( +
+
+ Checking availability… +
+

+ We’re verifying whether our service is available at your residence. +

+
+ ) : requiresAvailabilityCheck ? ( +
+
+ Availability review in progress +
+

+ We’re reviewing service availability for your address. Once confirmed, we’ll unlock + your personalized internet plans. +

+
+ ) : eligibility ? (
{getEligibilityIcon(eligibility)} - Available for: {eligibility} + Eligible for: {eligibility}

Plans shown are tailored to your house type and local infrastructure.

- )} + ) : null} {requiresAvailabilityCheck && (

- Our team will verify NTT serviceability and update your eligible offerings. You can - request a check now; we’ll update your account once it’s confirmed. + Our team will verify NTT serviceability and update your eligible offerings. We’ll + notify you on your dashboard when review is complete.

{hasServiceAddress ? ( ) : (
diff --git a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx index 4f2a302d..4f29f16b 100644 --- a/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx +++ b/apps/portal/src/features/catalog/views/PublicInternetConfigure.tsx @@ -1,52 +1,60 @@ "use client"; -import { useRouter } from "next/navigation"; -import { logger } from "@/lib/logger"; -import { useInternetConfigure } from "@/features/catalog/hooks/useInternetConfigure"; -import { InternetConfigureView as InternetConfigureInnerView } from "@/features/catalog/components/internet/InternetConfigureView"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { Button } from "@/components/atoms/button"; +import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; +import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; +import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; /** * Public Internet Configure View * - * Configure internet plan for unauthenticated users. - * Navigates to public checkout instead of authenticated checkout. + * Public shop is browse-only. Users must create an account so we can verify internet availability + * for their service address before showing personalized plans and allowing configuration. */ export function PublicInternetConfigureView() { - const router = useRouter(); - const vm = useInternetConfigure(); + const shopBasePath = useShopBasePath(); - const handleConfirm = () => { - logger.debug("Public handleConfirm called, current state", { - plan: vm.plan?.sku, - mode: vm.mode, - installation: vm.selectedInstallation?.sku, - }); + return ( +
+ - const params = vm.buildCheckoutSearchParams(); - if (!params) { - logger.error("Cannot proceed to checkout: missing required configuration", { - plan: vm.plan?.sku, - mode: vm.mode, - installation: vm.selectedInstallation?.sku, - }); + - const missingItems: string[] = []; - if (!vm.plan) missingItems.push("plan selection"); - if (!vm.mode) missingItems.push("access mode"); - if (!vm.selectedInstallation) missingItems.push("installation option"); - - alert(`Please complete the following before proceeding:\n- ${missingItems.join("\n- ")}`); - return; - } - - logger.debug("Navigating to public checkout with params", { - params: params.toString(), - }); - // Navigate to public checkout - router.push(`/order?${params.toString()}`); - }; - - return ; + +
+

+ Internet plans depend on your residence and local infrastructure. Create an account so + we can review availability and unlock ordering. +

+
+ + +
+
+
+
+ ); } export default PublicInternetConfigureView; diff --git a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx index e80792e2..6f50607d 100644 --- a/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/PublicInternetPlans.tsx @@ -13,6 +13,8 @@ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; +import { Button } from "@/components/atoms/button"; +import { InternetImportantNotes } from "@/features/catalog/components/internet/InternetImportantNotes"; /** * Public Internet Plans View @@ -29,7 +31,25 @@ export function PublicInternetPlansView() { [data?.installations] ); - const eligibility = plans.length > 0 ? plans[0].internetOfferingType || "Home 1G" : ""; + const silverPlans: InternetPlanCatalogItem[] = useMemo( + () => + plans.filter( + p => + String(p.internetPlanTier || "") + .trim() + .toLowerCase() === "silver" + ) ?? [], + [plans] + ); + + const offeringTypes = useMemo(() => { + const set = new Set(); + for (const plan of silverPlans) { + const value = String(plan.internetOfferingType || "").trim(); + if (value) set.add(value); + } + return Array.from(set).sort((a, b) => a.localeCompare(b)); + }, [silverPlans]); const getEligibilityIcon = (offeringType?: string) => { const lower = (offeringType || "").toLowerCase(); @@ -90,50 +110,55 @@ export function PublicInternetPlansView() { title="Choose Your Internet Plan" description="High-speed fiber internet with reliable connectivity for your home or business." > - {eligibility && ( -
-
- {getEligibilityIcon(eligibility)} - Available for: {eligibility} -
-

- Plans shown are our standard offerings. Personalized plans available after sign-in. -

+
+

+ Prices shown are the Silver tier so + you can compare starting prices. Create an account to check internet availability for + your residence and unlock personalized plan options. +

+
+ {offeringTypes.map(type => ( +
+ {getEligibilityIcon(type)} + {type} +
+ ))}
- )} +
+ + +
+
- {plans.length > 0 ? ( + {silverPlans.length > 0 ? ( <>
- {plans.map(plan => ( + {silverPlans.map(plan => (
))}
- -
    -
  • Theoretical internet speed is the same for all three packages
  • -
  • - One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments -
  • -
  • - Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month - + ¥1,000-3,000 one-time) -
  • -
  • In-home technical assistance available (¥15,000 onsite visiting fee)
  • -
-
+
) : ( diff --git a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx index 4bfb8b90..fb3da05c 100644 --- a/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx +++ b/apps/portal/src/features/catalog/views/PublicSimConfigure.tsx @@ -1,31 +1,59 @@ "use client"; -import { useSearchParams, useRouter } from "next/navigation"; -import { useSimConfigure } from "@/features/catalog/hooks/useSimConfigure"; -import { SimConfigureView as SimConfigureInnerView } from "@/features/catalog/components/sim/SimConfigureView"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { Button } from "@/components/atoms/button"; +import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; +import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; +import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; +import { useSearchParams } from "next/navigation"; /** * Public SIM Configure View * - * Configure SIM plan for unauthenticated users. - * Navigates to public checkout instead of authenticated checkout. + * Public shop is browse-only. Users must create an account to add a payment method and + * complete identity verification before ordering SIM service. */ export function PublicSimConfigureView() { + const shopBasePath = useShopBasePath(); const searchParams = useSearchParams(); - const router = useRouter(); - const planId = searchParams.get("plan") || undefined; + const plan = searchParams?.get("planSku") || undefined; + const redirectTarget = plan ? `/account/shop/sim/configure?planSku=${plan}` : "/account/shop/sim"; - const vm = useSimConfigure(planId); + return ( +
+ - const handleConfirm = () => { - if (!vm.plan || !vm.validate()) return; - const params = vm.buildCheckoutSearchParams(); - if (!params) return; - // Navigate to public checkout - router.push(`/order?${params.toString()}`); - }; + - return ; + +
+

+ Create an account to add your payment method and submit your residence card for review. +

+
+ + +
+
+
+
+ ); } export default PublicSimConfigureView; diff --git a/apps/portal/src/features/catalog/views/PublicSimPlans.tsx b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx index e464e55c..f513b997 100644 --- a/apps/portal/src/features/catalog/views/PublicSimPlans.tsx +++ b/apps/portal/src/features/catalog/views/PublicSimPlans.tsx @@ -37,6 +37,10 @@ export function PublicSimPlansView() { const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( "data-voice" ); + const buildRedirect = (planSku?: string) => { + const target = planSku ? `/account/shop/sim/configure?planSku=${planSku}` : "/account/shop/sim"; + return `/auth/signup?redirect=${encodeURIComponent(target)}`; + }; if (isLoading) { return ( @@ -102,9 +106,36 @@ export function PublicSimPlansView() { + +
+

+ To place a SIM order you’ll need an account, a payment method, and identity + verification. +

+
+ + +
+
+
+
)} @@ -198,6 +230,7 @@ export function PublicSimPlansView() { icon={} plans={plansByType.DataOnly} showFamilyDiscount={false} + cardAction={plan => ({ label: "Continue", href: buildRedirect(plan.sku) })} />
)} @@ -209,6 +242,7 @@ export function PublicSimPlansView() { icon={} plans={plansByType.VoiceOnly} showFamilyDiscount={false} + cardAction={plan => ({ label: "Continue", href: buildRedirect(plan.sku) })} />
)} diff --git a/apps/portal/src/features/catalog/views/SimConfigure.tsx b/apps/portal/src/features/catalog/views/SimConfigure.tsx index b8c36a6c..cf7d7424 100644 --- a/apps/portal/src/features/catalog/views/SimConfigure.tsx +++ b/apps/portal/src/features/catalog/views/SimConfigure.tsx @@ -8,7 +8,7 @@ export function SimConfigureContainer() { const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); - const planId = searchParams.get("plan") || undefined; + const planId = searchParams.get("planSku") || undefined; const vm = useSimConfigure(planId); diff --git a/apps/portal/src/features/catalog/views/SimPlans.tsx b/apps/portal/src/features/catalog/views/SimPlans.tsx index bd734104..0efd499f 100644 --- a/apps/portal/src/features/catalog/views/SimPlans.tsx +++ b/apps/portal/src/features/catalog/views/SimPlans.tsx @@ -18,6 +18,11 @@ import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTyp import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; +import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; +import { + useResidenceCardVerification, + useSubmitResidenceCard, +} from "@/features/verification/hooks/useResidenceCardVerification"; interface PlansByType { DataOnly: SimCatalogProduct[]; @@ -33,6 +38,10 @@ export function SimPlansContainer() { const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( "data-voice" ); + const { data: paymentMethods, isLoading: paymentMethodsLoading } = usePaymentMethods(); + const { data: residenceCard, isLoading: residenceCardLoading } = useResidenceCardVerification(); + const submitResidenceCard = useSubmitResidenceCard(); + const [residenceCardFile, setResidenceCardFile] = useState(null); useEffect(() => { setHasExistingSim(simPlans.some(p => p.simHasFamilyDiscount)); @@ -149,6 +158,98 @@ export function SimPlansContainer() { description="Flexible mobile plans with physical SIM and eSIM options for any device." /> + {paymentMethodsLoading || residenceCardLoading ? ( + +

+ Loading your payment method and residence card verification status. +

+
+ ) : ( + <> + {paymentMethods && paymentMethods.totalCount === 0 && ( + +
+

+ SIM orders require a saved payment method on your account. +

+ +
+
+ )} + + {residenceCard?.status === "pending" && ( + +

+ We’re verifying your residence card. We’ll update your account once review is + complete. +

+
+ )} + + {(residenceCard?.status === "not_submitted" || + residenceCard?.status === "rejected") && ( + +
+

+ To order SIM service, please upload your residence card for identity + verification. +

+
+ setResidenceCardFile(e.target.files?.[0] ?? null)} + className="block w-full sm:max-w-md text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + +
+ {submitResidenceCard.isError && ( +

+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +

+ )} +
+
+ )} + + )} + {hasExistingSim && (
diff --git a/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx new file mode 100644 index 00000000..c17f2af1 --- /dev/null +++ b/apps/portal/src/features/checkout/components/AccountCheckoutContainer.tsx @@ -0,0 +1,854 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { ShieldCheckIcon, CreditCardIcon } from "@heroicons/react/24/outline"; + +import { PageLayout } from "@/components/templates/PageLayout"; +import { SubCard } from "@/components/molecules/SubCard/SubCard"; +import { Button } from "@/components/atoms/button"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { InlineToast } from "@/components/atoms/inline-toast"; +import { StatusPill } from "@/components/atoms/status-pill"; +import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation"; +import { useCheckoutStore } from "@/features/checkout/stores/checkout.store"; +import { ordersService } from "@/features/orders/services/orders.service"; +import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; +import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; +import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; +import { ACTIVE_INTERNET_SUBSCRIPTION_WARNING } from "@/features/checkout/constants"; +import { useInternetEligibility } from "@/features/catalog/hooks/useInternetEligibility"; +import { + useResidenceCardVerification, + useSubmitResidenceCard, +} from "@/features/verification/hooks/useResidenceCardVerification"; + +import { ORDER_TYPE, type OrderTypeValue } from "@customer-portal/domain/orders"; +import type { PaymentMethod } from "@customer-portal/domain/payments"; + +const isNonEmptyString = (value: unknown): value is string => + typeof value === "string" && value.trim().length > 0; + +export function AccountCheckoutContainer() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const { cartItem, checkoutSessionId, clear } = useCheckoutStore(); + + const [submitting, setSubmitting] = useState(false); + const [addressConfirmed, setAddressConfirmed] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const orderType: OrderTypeValue | null = useMemo(() => { + if (!cartItem?.orderType) return null; + switch (cartItem.orderType) { + case "INTERNET": + return ORDER_TYPE.INTERNET; + case "SIM": + return ORDER_TYPE.SIM; + case "VPN": + return ORDER_TYPE.VPN; + default: + return null; + } + }, [cartItem?.orderType]); + + const isInternetOrder = orderType === ORDER_TYPE.INTERNET; + const isSimOrder = orderType === ORDER_TYPE.SIM; + + const { data: activeSubs } = useActiveSubscriptions(); + const hasActiveInternetSubscription = useMemo(() => { + if (!Array.isArray(activeSubs)) return false; + return activeSubs.some( + subscription => + String(subscription.groupName || subscription.productName || "") + .toLowerCase() + .includes("internet") && String(subscription.status || "").toLowerCase() === "active" + ); + }, [activeSubs]); + + const activeInternetWarning = + isInternetOrder && hasActiveInternetSubscription ? ACTIVE_INTERNET_SUBSCRIPTION_WARNING : null; + + const { + data: paymentMethods, + isLoading: paymentMethodsLoading, + error: paymentMethodsError, + refetch: refetchPaymentMethods, + } = usePaymentMethods(); + + const paymentRefresh = usePaymentRefresh({ + refetch: refetchPaymentMethods, + attachFocusListeners: true, + }); + + const paymentMethodList = paymentMethods?.paymentMethods ?? []; + const hasPaymentMethod = paymentMethodList.length > 0; + + const defaultPaymentMethod = + paymentMethodList.find(method => method.isDefault) ?? paymentMethodList[0] ?? null; + const paymentMethodDisplay = defaultPaymentMethod + ? buildPaymentMethodDisplay(defaultPaymentMethod) + : null; + + const eligibilityQuery = useInternetEligibility({ enabled: isInternetOrder }); + const eligibilityValue = eligibilityQuery.data?.eligibility; + const eligibilityLoading = Boolean(isInternetOrder && eligibilityQuery.isLoading); + const eligibilityPending = Boolean( + isInternetOrder && eligibilityQuery.isSuccess && eligibilityValue === null + ); + const eligibilityError = Boolean(isInternetOrder && eligibilityQuery.isError); + const isEligible = !isInternetOrder || isNonEmptyString(eligibilityValue); + + const residenceCardQuery = useResidenceCardVerification({ enabled: isSimOrder }); + const submitResidenceCard = useSubmitResidenceCard(); + const [residenceFile, setResidenceFile] = useState(null); + const residenceFileInputRef = useRef(null); + + const residenceStatus = residenceCardQuery.data?.status; + const residenceSubmitted = + !isSimOrder || residenceStatus === "pending" || residenceStatus === "verified"; + + const formatDateTime = useCallback((iso?: string | null) => { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format( + date + ); + }, []); + + const navigateBackToConfigure = useCallback(() => { + const params = new URLSearchParams(searchParams?.toString() ?? ""); + const type = (params.get("type") ?? "").toLowerCase(); + params.delete("type"); + + // Configure flows use `planSku` as the canonical selection. + // Additional params (addons, simType, etc.) may also be present for restore. + const planSku = params.get("planSku")?.trim(); + if (!planSku) { + params.delete("planSku"); + } + + if (type === "sim") { + router.push(`/account/shop/sim/configure?${params.toString()}`); + return; + } + if (type === "internet" || type === "") { + router.push(`/account/shop/internet/configure?${params.toString()}`); + return; + } + router.push("/account/shop"); + }, [router, searchParams]); + + const handleSubmitOrder = useCallback(async () => { + setSubmitError(null); + if (!checkoutSessionId) { + setSubmitError("Checkout session expired. Please restart checkout from the shop."); + return; + } + + try { + setSubmitting(true); + const result = await ordersService.createOrderFromCheckoutSession(checkoutSessionId); + clear(); + router.push(`/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : "Order submission failed"); + } finally { + setSubmitting(false); + } + }, [checkoutSessionId, clear, router]); + + if (!cartItem || !orderType) { + const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop"; + return ( +
+ +
+ Checkout data is not available + +
+
+
+ ); + } + + return ( + } + > +
+ + + {activeInternetWarning && ( + + {activeInternetWarning} + + )} + + {eligibilityLoading ? ( + + We’re loading your current eligibility status. + + ) : eligibilityError ? ( + +
+ + Please try again in a moment. If this continues, contact support. + + +
+
+ ) : eligibilityPending ? ( + +
+ + We’re verifying whether our service is available at your residence. Once eligibility + is confirmed, you can submit your internet order. + + +
+
+ ) : null} + +
+
+ +

Checkout requirements

+
+ +
+ + setAddressConfirmed(true)} + onAddressIncomplete={() => setAddressConfirmed(false)} + orderType={orderType} + /> + + + } + right={ +
+ {hasPaymentMethod ? : undefined} + +
+ } + > + {paymentMethodsLoading ? ( +
Checking payment methods...
+ ) : paymentMethodsError ? ( + +
+ + +
+
+ ) : hasPaymentMethod ? ( +
+ {paymentMethodDisplay ? ( +
+
+
+

+ Default payment method +

+

+ {paymentMethodDisplay.title} +

+ {paymentMethodDisplay.subtitle ? ( +

+ {paymentMethodDisplay.subtitle} +

+ ) : null} +
+
+
+ ) : null} +

+ We securely charge your saved payment method after the order is approved. +

+
+ ) : ( + +
+ + +
+
+ )} +
+ + {isSimOrder ? ( + } + right={ + residenceStatus === "verified" ? ( + + ) : residenceStatus === "pending" ? ( + + ) : residenceStatus === "rejected" ? ( + + ) : ( + + ) + } + > + {residenceCardQuery.isLoading ? ( +
+ Checking residence card status… +
+ ) : residenceCardQuery.isError ? ( + + + + ) : residenceStatus === "verified" ? ( +
+ + Your identity verification is complete. + + + {residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? ( +
+
+ Submitted document +
+ {residenceCardQuery.data?.filename ? ( +
+ {residenceCardQuery.data.filename} + {typeof residenceCardQuery.data.sizeBytes === "number" && + residenceCardQuery.data.sizeBytes > 0 ? ( + + {" "} + ·{" "} + {Math.round( + (residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10 + ) / 10} + {" MB"} + + ) : null} +
+ ) : null} +
+ {formatDateTime(residenceCardQuery.data?.submittedAt) ? ( +
+ Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)} +
+ ) : null} + {formatDateTime(residenceCardQuery.data?.reviewedAt) ? ( +
+ Reviewed: {formatDateTime(residenceCardQuery.data?.reviewedAt)} +
+ ) : null} +
+
+ ) : null} + +
+ + Replace residence card + +
+

+ Replacing the file restarts the verification process. +

+ setResidenceFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + + {residenceFile ? ( +
+
+
+ Selected file +
+
+ {residenceFile.name} +
+
+ +
+ ) : null} + +
+ +
+ + {submitResidenceCard.isError && ( +
+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +
+ )} +
+
+
+ ) : residenceStatus === "pending" ? ( +
+ + We’ll verify your residence card before activating SIM service. + + + {residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? ( +
+
+ Submitted document +
+ {residenceCardQuery.data?.filename ? ( +
+ {residenceCardQuery.data.filename} + {typeof residenceCardQuery.data.sizeBytes === "number" && + residenceCardQuery.data.sizeBytes > 0 ? ( + + {" "} + ·{" "} + {Math.round( + (residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10 + ) / 10} + {" MB"} + + ) : null} +
+ ) : null} +
+ {formatDateTime(residenceCardQuery.data?.submittedAt) ? ( +
+ Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)} +
+ ) : null} +
+
+ ) : null} + +
+ + Replace residence card + +
+

+ If you uploaded the wrong file, you can replace it. This restarts the + review. +

+ setResidenceFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + + {residenceFile ? ( +
+
+
+ Selected file +
+
+ {residenceFile.name} +
+
+ +
+ ) : null} + +
+ +
+ + {submitResidenceCard.isError && ( +
+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +
+ )} +
+
+
+ ) : ( + +
+ {residenceStatus === "rejected" && residenceCardQuery.data?.reviewerNotes ? ( +
+
Reviewer note
+
{residenceCardQuery.data.reviewerNotes}
+
+ ) : null} +

+ Upload a JPG, PNG, or PDF (max 5MB). We’ll verify it before activating SIM + service. +

+ +
+ setResidenceFile(e.target.files?.[0] ?? null)} + className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + + {residenceFile ? ( +
+
+
+ Selected file +
+
+ {residenceFile.name} +
+
+ +
+ ) : null} +
+ +
+ +
+ + {submitResidenceCard.isError && ( +
+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +
+ )} +
+
+ )} +
+ ) : null} +
+
+ +
+
+ +
+

Review & Submit

+

+ You’re almost done. Confirm your details above, then submit your order. We’ll review and + notify you when everything is ready. +

+ + {submitError ? ( +
+ + {submitError} + +
+ ) : null} + +
+

What to expect

+
+

• Our team reviews your order and schedules setup if needed

+

• We may contact you to confirm details or availability

+ {isSimOrder ? ( +

• For SIM orders, we verify your residence card before SIM activation

+ ) : null} +

• We only charge your card after the order is approved

+

• You’ll receive confirmation and next steps by email

+
+
+ +
+
+ Estimated Total +
+
+ ¥{cartItem.pricing.monthlyTotal.toLocaleString()}/mo +
+ {cartItem.pricing.oneTimeTotal > 0 && ( +
+ + ¥{cartItem.pricing.oneTimeTotal.toLocaleString()} one-time +
+ )} +
+
+
+
+ +
+ + +
+
+
+ ); +} + +function buildPaymentMethodDisplay(method: PaymentMethod): { title: string; subtitle?: string } { + const descriptor = + method.cardType?.trim() || + method.bankName?.trim() || + method.description?.trim() || + method.gatewayName?.trim() || + "Saved payment method"; + + const trimmedLastFour = + typeof method.cardLastFour === "string" && method.cardLastFour.trim().length > 0 + ? method.cardLastFour.trim().slice(-4) + : null; + + const headline = + trimmedLastFour && method.type?.toLowerCase().includes("card") + ? `${descriptor} · •••• ${trimmedLastFour}` + : descriptor; + + const details = new Set(); + + if (method.bankName && !headline.toLowerCase().includes(method.bankName.trim().toLowerCase())) { + details.add(method.bankName.trim()); + } + + const expiry = normalizeExpiryLabel(method.expiryDate); + if (expiry) { + details.add(`Exp ${expiry}`); + } + + if (!trimmedLastFour && method.cardLastFour && method.cardLastFour.trim().length > 0) { + details.add(`Ends ${method.cardLastFour.trim().slice(-4)}`); + } + + if (method.type?.toLowerCase().includes("bank") && method.description?.trim()) { + details.add(method.description.trim()); + } + + const subtitle = details.size > 0 ? Array.from(details).join(" · ") : undefined; + return { title: headline, subtitle }; +} + +function normalizeExpiryLabel(expiry?: string | null): string | null { + if (!expiry) return null; + const value = expiry.trim(); + if (!value) return null; + + if (/^\d{4}-\d{2}$/.test(value)) { + const [year, month] = value.split("-"); + return `${month}/${year.slice(-2)}`; + } + + if (/^\d{2}\/\d{4}$/.test(value)) { + const [month, year] = value.split("/"); + return `${month}/${year.slice(-2)}`; + } + + if (/^\d{2}\/\d{2}$/.test(value)) { + return value; + } + + const digits = value.replace(/\D/g, ""); + + if (digits.length === 6) { + const year = digits.slice(2, 4); + const month = digits.slice(4, 6); + return `${month}/${year}`; + } + + if (digits.length === 4) { + const month = digits.slice(0, 2); + const year = digits.slice(2, 4); + return `${month}/${year}`; + } + + return value; +} + +export default AccountCheckoutContainer; diff --git a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx index 0500511c..692ea2e6 100644 --- a/apps/portal/src/features/checkout/components/CheckoutEntry.tsx +++ b/apps/portal/src/features/checkout/components/CheckoutEntry.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; import type { CartItem, OrderType as CheckoutOrderType } from "@customer-portal/domain/checkout"; import type { CheckoutCart, OrderTypeValue } from "@customer-portal/domain/orders"; import { ORDER_TYPE } from "@customer-portal/domain/orders"; @@ -10,10 +10,12 @@ import { checkoutService } from "@/features/checkout/services/checkout.service"; import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service"; import { useCheckoutStore } from "@/features/checkout/stores/checkout.store"; import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard"; +import { AccountCheckoutContainer } from "@/features/checkout/components/AccountCheckoutContainer"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { Button } from "@/components/atoms/button"; import { Spinner } from "@/components/atoms"; import { EmptyCartRedirect } from "@/features/checkout/components/EmptyCartRedirect"; +import { useAuthSession } from "@/features/auth/services/auth.store"; const signatureFromSearchParams = (params: URLSearchParams): string => { const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); @@ -70,6 +72,8 @@ const cartItemFromCheckoutCart = ( export function CheckoutEntry() { const searchParams = useSearchParams(); + const pathname = usePathname(); + const { isAuthenticated } = useAuthSession(); const paramsKey = useMemo(() => searchParams.toString(), [searchParams]); const signature = useMemo( () => signatureFromSearchParams(new URLSearchParams(paramsKey)), @@ -182,13 +186,14 @@ export function CheckoutEntry() { } if (status === "error") { + const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop"; return (
{errorMessage}
- @@ -205,5 +210,9 @@ export function CheckoutEntry() { return ; } + if (pathname.startsWith("/account") && isAuthenticated) { + return ; + } + return ; } diff --git a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx index c85daf89..7913b9ed 100644 --- a/apps/portal/src/features/checkout/components/steps/AddressStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/AddressStep.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useCallback } from "react"; +import { useMemo, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; import { useCheckoutStore } from "../../stores/checkout.store"; import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store"; import { Button, Input } from "@/components/atoms"; @@ -11,6 +12,9 @@ import { addressFormSchema, type AddressFormData } from "@customer-portal/domain import { useZodForm } from "@/hooks/useZodForm"; import { apiClient } from "@/lib/api"; import { checkoutRegisterResponseSchema } from "@customer-portal/domain/checkout"; +import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation"; +import { ORDER_TYPE } from "@customer-portal/domain/orders"; +import type { Address } from "@customer-portal/domain/customer"; /** * AddressStep - Second step in checkout @@ -18,6 +22,7 @@ import { checkoutRegisterResponseSchema } from "@customer-portal/domain/checkout * Collects service/shipping address and triggers registration for new users. */ export function AddressStep() { + const router = useRouter(); const { isAuthenticated } = useAuthSession(); const user = useAuthStore(state => state.user); const refreshUser = useAuthStore(state => state.refreshUser); @@ -32,6 +37,44 @@ export function AddressStep() { } = useCheckoutStore(); const [registrationError, setRegistrationError] = useState(null); + const isAuthed = isAuthenticated || registrationComplete; + const isInternetOrder = cartItem?.orderType === "INTERNET"; + + const cartOrderTypeForAddressConfirmation = useMemo(() => { + if (cartItem?.orderType === "INTERNET") return ORDER_TYPE.INTERNET; + if (cartItem?.orderType === "SIM") return ORDER_TYPE.SIM; + if (cartItem?.orderType === "VPN") return ORDER_TYPE.VPN; + return undefined; + }, [cartItem?.orderType]); + + const toAddressFormData = useCallback((value?: Address | null): AddressFormData | null => { + if (!value) return null; + + const address1 = value.address1?.trim() ?? ""; + const city = value.city?.trim() ?? ""; + const state = value.state?.trim() ?? ""; + const postcode = value.postcode?.trim() ?? ""; + const country = value.country?.trim() ?? ""; + + if (!address1 || !city || !state || !postcode || !country) { + return null; + } + + return { + address1, + address2: value.address2?.trim() ? value.address2.trim() : undefined, + city, + state, + postcode, + country, + countryCode: value.countryCode?.trim() ? value.countryCode.trim() : undefined, + phoneNumber: value.phoneNumber?.trim() ? value.phoneNumber.trim() : undefined, + phoneCountryCode: value.phoneCountryCode?.trim() ? value.phoneCountryCode.trim() : undefined, + }; + }, []); + + const [authedAddressConfirmed, setAuthedAddressConfirmed] = useState(false); + const handleSubmit = useCallback( async (data: AddressFormData) => { setRegistrationError(null); @@ -101,6 +144,60 @@ export function AddressStep() { onSubmit: handleSubmit, }); + if (isAuthed) { + return ( +
+
+
+ +
+

+ {isInternetOrder ? "Installation Address" : "Service Address"} +

+

+ {isInternetOrder + ? "Confirm the address where internet will be installed." + : "We'll use your account address for this order."} +

+
+
+ + { + const normalized = toAddressFormData(nextAddress ?? null); + if (!normalized) { + setAuthedAddressConfirmed(false); + return; + } + setAddress(normalized); + setAuthedAddressConfirmed(true); + }} + onAddressIncomplete={() => { + setAuthedAddressConfirmed(false); + }} + /> + +
+ + +
+
+
+ ); + } + return (
@@ -221,7 +318,7 @@ export function AddressStep() { isLoading={form.isSubmitting} rightIcon={} > - Continue to Payment + Continue
diff --git a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx index cdd11a48..840a97ad 100644 --- a/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx +++ b/apps/portal/src/features/checkout/components/steps/PaymentStep.tsx @@ -15,6 +15,11 @@ import { ExclamationTriangleIcon, } from "@heroicons/react/24/outline"; import type { PaymentMethodList } from "@customer-portal/domain/payments"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { + useResidenceCardVerification, + useSubmitResidenceCard, +} from "@/features/verification/hooks/useResidenceCardVerification"; /** * PaymentStep - Third step in checkout @@ -23,8 +28,13 @@ import type { PaymentMethodList } from "@customer-portal/domain/payments"; */ export function PaymentStep() { const { isAuthenticated } = useAuthSession(); - const { setPaymentVerified, paymentMethodVerified, setCurrentStep, registrationComplete } = - useCheckoutStore(); + const { + cartItem, + setPaymentVerified, + paymentMethodVerified, + setCurrentStep, + registrationComplete, + } = useCheckoutStore(); const [isWaiting, setIsWaiting] = useState(false); const [error, setError] = useState(null); const [paymentMethod, setPaymentMethod] = useState<{ @@ -33,6 +43,13 @@ export function PaymentStep() { } | null>(null); const canCheckPayment = isAuthenticated || registrationComplete; + const isSimOrder = cartItem?.orderType === "SIM"; + + const residenceCardQuery = useResidenceCardVerification({ + enabled: canCheckPayment && isSimOrder, + }); + const submitResidenceCard = useSubmitResidenceCard(); + const [residenceFile, setResidenceFile] = useState(null); // Poll for payment method const checkPaymentMethod = useCallback(async () => { @@ -202,6 +219,108 @@ export function PaymentStep() {
)} + {isSimOrder ? ( +
+
+
+ ID +
+
+

+ Residence card verification +

+

+ Required for SIM orders. We’ll review it before activation. +

+
+
+ + {!canCheckPayment ? ( + + Please complete account setup so you can upload your residence card. + + ) : residenceCardQuery.isLoading ? ( +
Checking residence card status…
+ ) : residenceCardQuery.isError ? ( + +
+ +
+
+ ) : residenceCardQuery.data?.status === "verified" ? ( +
+
+ +
+

Verified

+

+ Your residence card has been approved. +

+
+
+
+ ) : residenceCardQuery.data?.status === "pending" ? ( + + We’re reviewing your residence card. You can submit your order, but we’ll only + activate the SIM (and charge your card) after the order is approved. + + ) : ( + +
+

+ Upload a JPG, PNG, or PDF (max 5MB). We’ll review it and notify you when it’s + approved. +

+
+ setResidenceFile(e.target.files?.[0] ?? null)} + className="block w-full sm:max-w-md text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80" + /> + +
+ {submitResidenceCard.isError && ( +
+ {submitResidenceCard.error instanceof Error + ? submitResidenceCard.error.message + : "Failed to submit residence card."} +
+ )} +
+
+ )} +
+ ) : null} + {/* Navigation buttons */}
)} + {isSimOrder && ( +
+ {residenceCardQuery.isLoading ? ( + + We’re loading your verification status. + + ) : residenceCardQuery.isError ? ( + + Please check again or try later. We need to confirm that your residence card has + been submitted before you can place a SIM order. + + ) : residenceCardQuery.data?.status === "verified" ? ( + + Your residence card has been approved. You can submit your SIM order once you accept + the terms. + + ) : residenceCardQuery.data?.status === "pending" ? ( + + You can submit your order now. We’ll review your residence card before activation, + and you won’t be charged until your order is approved. + + ) : ( + +
+ + Submit your residence card in the Payment step to place a SIM order. We’ll + review it before activation. + + +
+
+ )} +
+ )} +
{/* Account Info */}
@@ -224,7 +295,9 @@ export function ReviewStep() {