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.
This commit is contained in:
barsa 2025-12-18 18:12:20 +09:00
parent 7cfac4c32f
commit 7ab5e12051
55 changed files with 2394 additions and 244 deletions

View File

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

View File

@ -36,6 +36,7 @@ model User {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
auditLogs AuditLog[] auditLogs AuditLog[]
idMapping IdMapping? idMapping IdMapping?
residenceCardSubmission ResidenceCardSubmission?
@@map("users") @@map("users")
} }
@ -91,6 +92,30 @@ enum AuditAction {
SYSTEM_MAINTENANCE 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 // Per-SIM daily usage snapshot used to build full-month charts
model SimUsageDaily { model SimUsageDaily {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())

View File

@ -36,6 +36,7 @@ import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.mo
import { CurrencyModule } from "@bff/modules/currency/currency.module.js"; import { CurrencyModule } from "@bff/modules/currency/currency.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js"; import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js"; import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
// System Modules // System Modules
import { HealthModule } from "@bff/modules/health/health.module.js"; import { HealthModule } from "@bff/modules/health/health.module.js";
@ -87,6 +88,7 @@ import { HealthModule } from "@bff/modules/health/health.module.js";
CurrencyModule, CurrencyModule,
SupportModule, SupportModule,
RealtimeApiModule, RealtimeApiModule,
VerificationModule,
// === SYSTEM MODULES === // === SYSTEM MODULES ===
HealthModule, HealthModule,

View File

@ -11,6 +11,7 @@ import { SecurityModule } from "@bff/core/security/security.module.js";
import { SupportModule } from "@bff/modules/support/support.module.js"; import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js"; import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
import { CheckoutRegistrationModule } from "@bff/modules/checkout-registration/checkout-registration.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 = [ export const apiRoutes: Routes = [
{ {
@ -28,6 +29,7 @@ export const apiRoutes: Routes = [
{ path: "", module: SecurityModule }, { path: "", module: SecurityModule },
{ path: "", module: RealtimeApiModule }, { path: "", module: RealtimeApiModule },
{ path: "", module: CheckoutRegistrationModule }, { path: "", module: CheckoutRegistrationModule },
{ path: "", module: VerificationModule },
], ],
}, },
]; ];

View File

@ -47,6 +47,16 @@ export class GlobalAuthGuard implements CanActivate {
]); ]);
if (isPublic) { 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}`); this.logger.debug(`Public route accessed: ${route}`);
return true; return true;
} }
@ -61,45 +71,7 @@ export class GlobalAuthGuard implements CanActivate {
throw new UnauthorizedException("Missing token"); throw new UnauthorizedException("Missing token");
} }
const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>( await this.attachUserFromToken(request, token, route);
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;
this.logger.debug(`Authenticated access to: ${route}`); this.logger.debug(`Authenticated access to: ${route}`);
return true; return true;
@ -168,4 +140,52 @@ export class GlobalAuthGuard implements CanActivate {
const normalized = path.endsWith("/") ? path.slice(0, -1) : path; const normalized = path.endsWith("/") ? path.slice(0, -1) : path;
return normalized === "/auth/logout" || normalized === "/api/auth/logout"; return normalized === "/auth/logout" || normalized === "/api/auth/logout";
} }
private async attachUserFromToken(
request: RequestWithRoute,
token: string,
route?: string
): Promise<void> {
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;
}
} }

View File

@ -8,6 +8,7 @@ import { CoreConfigModule } from "@bff/core/config/config.module.js";
import { DatabaseModule } from "@bff/core/database/database.module.js"; import { DatabaseModule } from "@bff/core/database/database.module.js";
import { CatalogModule } from "@bff/modules/catalog/catalog.module.js"; import { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
// Clean modular order services // Clean modular order services
import { OrderValidator } from "./services/order-validator.service.js"; import { OrderValidator } from "./services/order-validator.service.js";
@ -39,6 +40,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
DatabaseModule, DatabaseModule,
CatalogModule, CatalogModule,
CacheModule, CacheModule,
VerificationModule,
OrderFieldConfigModule, OrderFieldConfigModule,
], ],
controllers: [OrdersController, CheckoutController], controllers: [OrdersController, CheckoutController],

View File

@ -237,9 +237,11 @@ export class CheckoutService {
userId?: string userId?: string
): Promise<{ items: CheckoutItem[] }> { ): Promise<{ items: CheckoutItem[] }> {
const items: CheckoutItem[] = []; const items: CheckoutItem[] = [];
const plans: SimCatalogProduct[] = userId if (!userId) {
? await this.simCatalogService.getPlansForUser(userId) throw new BadRequestException("Please sign in to order SIM service.");
: await this.simCatalogService.getPlans(); }
const plans: SimCatalogProduct[] = await this.simCatalogService.getPlansForUser(userId);
const rawActivationFees: SimActivationFeeCatalogItem[] = const rawActivationFees: SimActivationFeeCatalogItem[] =
await this.simCatalogService.getActivationFees(); await this.simCatalogService.getActivationFees();
const activationFees = this.filterActivationFeesWithSku(rawActivationFees); const activationFees = this.filterActivationFeesWithSku(rawActivationFees);

View File

@ -16,6 +16,7 @@ type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js"; import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js"; import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js";
import { PaymentValidatorService } from "./payment-validator.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 * Handles all order validation logic - both format and business rules
@ -31,7 +32,8 @@ export class OrderValidator {
private readonly whmcs: WhmcsConnectionOrchestratorService, private readonly whmcs: WhmcsConnectionOrchestratorService,
private readonly pricebookService: OrderPricebookService, private readonly pricebookService: OrderPricebookService,
private readonly simCatalogService: SimCatalogService, 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); const _productMeta = await this.validateSKUs(businessValidatedBody.skus, pricebookId);
if (businessValidatedBody.orderType === "SIM") { 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 activationFees = await this.simCatalogService.getActivationFees();
const activationSkus = new Set( const activationSkus = new Set(
activationFees activationFees

View File

@ -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<ResidenceCardVerificationDto> {
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<ResidenceCardVerificationDto> {
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<ArrayBuffer>,
});
}
}

View File

@ -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<ResidenceCardVerificationDto> {
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<ArrayBuffer>;
}): Promise<ResidenceCardVerificationDto> {
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);
}
}

View File

@ -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 {}

View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -5,7 +5,13 @@
*/ */
import { PublicInternetConfigureView } from "@/features/catalog/views/PublicInternetConfigure"; import { PublicInternetConfigureView } from "@/features/catalog/views/PublicInternetConfigure";
import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
export default function PublicInternetConfigurePage() { export default function PublicInternetConfigurePage() {
return <PublicInternetConfigureView />; return (
<>
<RedirectAuthenticatedToAccountShop targetPath="/account/shop/internet/configure" />
<PublicInternetConfigureView />
</>
);
} }

View File

@ -5,7 +5,13 @@
*/ */
import { PublicInternetPlansView } from "@/features/catalog/views/PublicInternetPlans"; import { PublicInternetPlansView } from "@/features/catalog/views/PublicInternetPlans";
import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
export default function PublicInternetPlansPage() { export default function PublicInternetPlansPage() {
return <PublicInternetPlansView />; return (
<>
<RedirectAuthenticatedToAccountShop targetPath="/account/shop/internet" />
<PublicInternetPlansView />
</>
);
} }

View File

@ -5,7 +5,13 @@
*/ */
import { PublicCatalogHomeView } from "@/features/catalog/views/PublicCatalogHome"; import { PublicCatalogHomeView } from "@/features/catalog/views/PublicCatalogHome";
import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
export default function PublicCatalogPage() { export default function PublicCatalogPage() {
return <PublicCatalogHomeView />; return (
<>
<RedirectAuthenticatedToAccountShop targetPath="/account/shop" />
<PublicCatalogHomeView />
</>
);
} }

View File

@ -5,7 +5,13 @@
*/ */
import { PublicSimConfigureView } from "@/features/catalog/views/PublicSimConfigure"; import { PublicSimConfigureView } from "@/features/catalog/views/PublicSimConfigure";
import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
export default function PublicSimConfigurePage() { export default function PublicSimConfigurePage() {
return <PublicSimConfigureView />; return (
<>
<RedirectAuthenticatedToAccountShop targetPath="/account/shop/sim/configure" />
<PublicSimConfigureView />
</>
);
} }

View File

@ -5,7 +5,13 @@
*/ */
import { PublicSimPlansView } from "@/features/catalog/views/PublicSimPlans"; import { PublicSimPlansView } from "@/features/catalog/views/PublicSimPlans";
import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
export default function PublicSimPlansPage() { export default function PublicSimPlansPage() {
return <PublicSimPlansView />; return (
<>
<RedirectAuthenticatedToAccountShop targetPath="/account/shop/sim" />
<PublicSimPlansView />
</>
);
} }

View File

@ -5,7 +5,13 @@
*/ */
import { PublicVpnPlansView } from "@/features/catalog/views/PublicVpnPlans"; import { PublicVpnPlansView } from "@/features/catalog/views/PublicVpnPlans";
import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
export default function PublicVpnPlansPage() { export default function PublicVpnPlansPage() {
return <PublicVpnPlansView />; return (
<>
<RedirectAuthenticatedToAccountShop targetPath="/account/shop/vpn" />
<PublicVpnPlansView />
</>
);
} }

View File

@ -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 (
<div>
<ShopTabs basePath="/account/shop" />
{children}
</div>
);
}

View File

@ -12,44 +12,14 @@ import { useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { Logo } from "@/components/atoms/logo"; import { Logo } from "@/components/atoms/logo";
import { useAuthStore } from "@/features/auth/services/auth.store"; import { useAuthStore } from "@/features/auth/services/auth.store";
import { ShopTabs } from "@/features/catalog/components/base/ShopTabs";
export interface CatalogShellProps { export interface CatalogShellProps {
children: ReactNode; children: ReactNode;
} }
export function CatalogNav() { export function CatalogNav() {
return ( return <ShopTabs basePath="/shop" />;
<div className="border-b border-border/50 bg-background/60 backdrop-blur-xl">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-2">
<nav className="flex items-center gap-1 overflow-x-auto">
<Link
href="/shop"
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
All Services
</Link>
<Link
href="/shop/internet"
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
Internet
</Link>
<Link
href="/shop/sim"
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
SIM
</Link>
<Link
href="/shop/vpn"
className="whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
VPN
</Link>
</nav>
</div>
</div>
);
} }
export function CatalogShell({ children }: CatalogShellProps) { export function CatalogShell({ children }: CatalogShellProps) {

View File

@ -7,6 +7,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Button, Input, ErrorMessage } from "@/components/atoms"; import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { useLogin } from "../../hooks/use-auth"; import { useLogin } from "../../hooks/use-auth";
@ -41,7 +42,10 @@ export function LoginForm({
showForgotPasswordLink = true, showForgotPasswordLink = true,
className = "", className = "",
}: LoginFormProps) { }: LoginFormProps) {
const searchParams = useSearchParams();
const { login, loading, error, clearError } = useLogin(); const { login, loading, error, clearError } = useLogin();
const redirect = searchParams?.get("next") || searchParams?.get("redirect");
const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
const handleLogin = useCallback( const handleLogin = useCallback(
async ({ rememberMe: _rememberMe, ...formData }: LoginFormValues) => { async ({ rememberMe: _rememberMe, ...formData }: LoginFormValues) => {
@ -143,7 +147,7 @@ export function LoginForm({
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link <Link
href="/auth/signup" href={`/auth/signup${redirectQuery}`}
className="font-medium text-primary hover:text-primary-hover transition-colors duration-200" className="font-medium text-primary hover:text-primary-hover transition-colors duration-200"
> >
Sign up Sign up

View File

@ -7,6 +7,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ErrorMessage } from "@/components/atoms"; import { ErrorMessage } from "@/components/atoms";
import { useSignup } from "../../hooks/use-auth"; import { useSignup } from "../../hooks/use-auth";
import { signupInputSchema, buildSignupRequest } from "@customer-portal/domain/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) { export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) {
const searchParams = useSearchParams();
const { signup, loading, error, clearError } = useSignup(); const { signup, loading, error, clearError } = useSignup();
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const redirect = searchParams?.get("next") || searchParams?.get("redirect");
const redirectQuery = redirect ? `?redirect=${encodeURIComponent(redirect)}` : "";
const form = useZodForm<SignupFormData>({ const form = useZodForm<SignupFormData>({
schema: signupFormSchema, schema: signupFormSchema,
@ -274,7 +278,7 @@ export function SignupForm({ onSuccess, onError, className = "" }: SignupFormPro
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Already have an account?{" "} Already have an account?{" "}
<Link <Link
href="/auth/login" href={`/auth/login${redirectQuery}`}
className="font-medium text-primary hover:underline transition-colors" className="font-medium text-primary hover:underline transition-colors"
> >
Sign in Sign in

View File

@ -1,7 +1,7 @@
import type { ReadonlyURLSearchParams } from "next/navigation"; import type { ReadonlyURLSearchParams } from "next/navigation";
export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): string { export function getPostLoginRedirect(searchParams: ReadonlyURLSearchParams): string {
const dest = searchParams.get("redirect") || "/account"; const dest = searchParams.get("next") || searchParams.get("redirect") || "/account";
// prevent open redirects // prevent open redirects
if (dest.startsWith("http://") || dest.startsWith("https://")) return "/account"; if (dest.startsWith("http://") || dest.startsWith("https://")) return "/account";
return dest; return dest;

View File

@ -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 (
<div className="border-b border-border/50 bg-background/60 backdrop-blur-xl">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-[var(--cp-page-padding)] py-2">
<nav className="flex items-center gap-1 overflow-x-auto" aria-label="Shop sections">
{tabs.map(tab => (
<Link
key={tab.href}
href={tab.href}
className={cn(
"whitespace-nowrap inline-flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive(tab.href)
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
{tab.label}
</Link>
))}
</nav>
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,22 @@
"use client";
export function InternetImportantNotes() {
return (
<details className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] p-5">
<summary className="cursor-pointer select-none font-semibold text-foreground">
Important notes
</summary>
<div className="pt-4">
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>Theoretical internet speed is the same for all three packages</li>
<li>One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments</li>
<li>
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month +
¥1,000-3,000 one-time)
</li>
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
</ul>
</div>
</details>
);
}

View File

@ -21,8 +21,12 @@ interface InternetPlanCardProps {
installations: InternetInstallationCatalogItem[]; installations: InternetInstallationCatalogItem[];
disabled?: boolean; disabled?: boolean;
disabledReason?: string; disabledReason?: string;
/** Override the default configure href (default: /shop/internet/configure?plan=...) */ /** Override the default configure href (default: /shop/internet/configure?planSku=...) */
configureHref?: string; 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 // Tier-based styling using design tokens
@ -51,6 +55,8 @@ export function InternetPlanCard({
disabled, disabled,
disabledReason, disabledReason,
configureHref, configureHref,
action,
pricingPrefix,
}: InternetPlanCardProps) { }: InternetPlanCardProps) {
const router = useRouter(); const router = useRouter();
const shopBasePath = useShopBasePath(); const shopBasePath = useShopBasePath();
@ -180,6 +186,11 @@ export function InternetPlanCard({
{/* Pricing - Full width below */} {/* Pricing - Full width below */}
<div className="w-full pt-2"> <div className="w-full pt-2">
{pricingPrefix ? (
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-1">
{pricingPrefix}
</div>
) : null}
<CardPricing <CardPricing
monthlyPrice={plan.monthlyPrice} monthlyPrice={plan.monthlyPrice}
oneTimePrice={plan.oneTimePrice} oneTimePrice={plan.oneTimePrice}
@ -204,16 +215,20 @@ export function InternetPlanCard({
rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined} rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
onClick={() => { onClick={() => {
if (isDisabled) return; if (isDisabled) return;
if (action) {
router.push(action.href);
return;
}
const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState(); const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState();
resetInternetConfig(); resetInternetConfig();
setInternetConfig({ planSku: plan.sku, currentStep: 1 }); setInternetConfig({ planSku: plan.sku, currentStep: 1 });
const href = const href =
configureHref ?? configureHref ??
`${shopBasePath}/internet/configure?plan=${encodeURIComponent(plan.sku)}`; `${shopBasePath}/internet/configure?planSku=${encodeURIComponent(plan.sku)}`;
router.push(href); router.push(href);
}} }}
> >
{isDisabled ? disabledReason || "Not available" : "Configure Plan"} {isDisabled ? disabledReason || "Not available" : (action?.label ?? "Configure Plan")}
</Button> </Button>
</div> </div>
</AnimatedCard> </AnimatedCard>

View File

@ -9,11 +9,31 @@ import { CardPricing } from "@/features/catalog/components/base/CardPricing";
import { CardBadge } from "@/features/catalog/components/base/CardBadge"; import { CardBadge } from "@/features/catalog/components/base/CardBadge";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCatalogStore } from "@/features/catalog/services/catalog.store"; 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 displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount); const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount);
const router = useRouter(); const router = useRouter();
const shopBasePath = useShopBasePath();
const resolvedAction = typeof action === "function" ? action(plan) : action;
return ( return (
<AnimatedCard <AnimatedCard
@ -47,15 +67,21 @@ export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFam
{/* Action Button */} {/* Action Button */}
<Button <Button
className="w-full" className="w-full"
disabled={Boolean(disabled)}
onClick={() => { onClick={() => {
if (disabled) return;
if (resolvedAction) {
router.push(resolvedAction.href);
return;
}
const { resetSimConfig, setSimConfig } = useCatalogStore.getState(); const { resetSimConfig, setSimConfig } = useCatalogStore.getState();
resetSimConfig(); resetSimConfig();
setSimConfig({ planSku: plan.sku, currentStep: 1 }); setSimConfig({ planSku: plan.sku, currentStep: 1 });
router.push(`/catalog/sim/configure?plan=${plan.sku}`); router.push(`${shopBasePath}/sim/configure?planSku=${encodeURIComponent(plan.sku)}`);
}} }}
rightIcon={<ArrowRightIcon className="w-4 h-4" />} rightIcon={<ArrowRightIcon className="w-4 h-4" />}
> >
Configure {disabled ? disabledReason || "Not available" : (resolvedAction?.label ?? "Configure")}
</Button> </Button>
</AnimatedCard> </AnimatedCard>
); );

View File

@ -3,7 +3,7 @@
import React from "react"; import React from "react";
import { UsersIcon } from "@heroicons/react/24/outline"; import { UsersIcon } from "@heroicons/react/24/outline";
import type { SimCatalogProduct } from "@customer-portal/domain/catalog"; import type { SimCatalogProduct } from "@customer-portal/domain/catalog";
import { SimPlanCard } from "./SimPlanCard"; import { SimPlanCard, type SimPlanCardActionResolver } from "./SimPlanCard";
export function SimPlanTypeSection({ export function SimPlanTypeSection({
title, title,
@ -11,12 +11,18 @@ export function SimPlanTypeSection({
icon, icon,
plans, plans,
showFamilyDiscount, showFamilyDiscount,
cardAction,
cardDisabled,
cardDisabledReason,
}: { }: {
title: string; title: string;
description: string; description: string;
icon: React.ReactNode; icon: React.ReactNode;
plans: SimCatalogProduct[]; plans: SimCatalogProduct[];
showFamilyDiscount: boolean; showFamilyDiscount: boolean;
cardAction?: SimPlanCardActionResolver;
cardDisabled?: boolean;
cardDisabledReason?: string;
}) { }) {
if (plans.length === 0) return null; if (plans.length === 0) return null;
const regularPlans = plans.filter(p => !p.simHasFamilyDiscount); const regularPlans = plans.filter(p => !p.simHasFamilyDiscount);
@ -33,7 +39,13 @@ export function SimPlanTypeSection({
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mb-6 justify-items-center"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mb-6 justify-items-center">
{regularPlans.map(plan => ( {regularPlans.map(plan => (
<SimPlanCard key={plan.id} plan={plan} /> <SimPlanCard
key={plan.id}
plan={plan}
action={cardAction}
disabled={cardDisabled}
disabledReason={cardDisabledReason}
/>
))} ))}
</div> </div>
{showFamilyDiscount && familyPlans.length > 0 && ( {showFamilyDiscount && familyPlans.length > 0 && (
@ -47,7 +59,14 @@ export function SimPlanTypeSection({
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 justify-items-center"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 justify-items-center">
{familyPlans.map(plan => ( {familyPlans.map(plan => (
<SimPlanCard key={plan.id} plan={plan} isFamily /> <SimPlanCard
key={plan.id}
plan={plan}
isFamily
action={cardAction}
disabled={cardDisabled}
disabledReason={cardDisabledReason}
/>
))} ))}
</div> </div>
</> </>

View File

@ -7,7 +7,7 @@ import { SimConfigureView } from "@/features/catalog/components/sim/SimConfigure
export function SimConfigureContainer() { export function SimConfigureContainer() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const planId = searchParams.get("plan") || undefined; const planId = searchParams.get("planSku") || undefined;
const vm = useSimConfigure(planId); const vm = useSimConfigure(planId);

View File

@ -45,7 +45,7 @@ export function useInternetConfigure(): UseInternetConfigureResult {
const shopBasePath = useShopBasePath(); const shopBasePath = useShopBasePath();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]); const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]);
const urlPlanSku = searchParams.get("plan"); const urlPlanSku = searchParams.get("planSku");
// Get state from Zustand store (persisted) // Get state from Zustand store (persisted)
const configState = useCatalogStore(state => state.internet); 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 // If URL has configuration params (back navigation from checkout), restore them
const params = new URLSearchParams(paramsSignature); 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 = const shouldRestore =
hasConfigParams && lastRestoredSignatureRef.current !== paramsSignature && paramsSignature; hasConfigParams && lastRestoredSignatureRef.current !== paramsSignature && paramsSignature;
if (shouldRestore) { if (shouldRestore) {

View File

@ -57,7 +57,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const router = useRouter(); const router = useRouter();
const shopBasePath = useShopBasePath(); const shopBasePath = useShopBasePath();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const urlPlanSku = searchParams.get("plan"); const urlPlanSku = searchParams.get("planSku");
const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]); const paramsSignature = useMemo(() => searchParams.toString(), [searchParams]);
// Get state from Zustand store (persisted) // 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 // If URL has configuration params (back navigation from checkout), restore them
const params = new URLSearchParams(paramsSignature); 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 = const shouldRestore =
hasConfigParams && lastRestoredSignatureRef.current !== paramsSignature && paramsSignature; hasConfigParams && lastRestoredSignatureRef.current !== paramsSignature && paramsSignature;
if (shouldRestore) { if (shouldRestore) {

View File

@ -17,6 +17,7 @@ import { Button } from "@/components/atoms/button";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { InternetImportantNotes } from "@/features/catalog/components/internet/InternetImportantNotes";
import { import {
useInternetEligibility, useInternetEligibility,
useRequestInternetEligibilityCheck, useRequestInternetEligibilityCheck,
@ -59,22 +60,24 @@ export function InternetPlansContainer() {
(user?.address?.country || user?.address?.countryCode) (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(() => { useEffect(() => {
if (eligibilityQuery.isSuccess) { if (eligibilityQuery.isSuccess) {
if (typeof eligibilityValue === "string" && eligibilityValue.trim().length > 0) { if (typeof eligibilityValue === "string" && eligibilityValue.trim().length > 0) {
setEligibility(eligibilityValue); setEligibility(eligibilityValue);
return; return;
} }
if (eligibilityValue === null) { setEligibility("");
setEligibility("");
return;
}
} }
}, [eligibilityQuery.isSuccess, eligibilityValue]);
if (plans.length > 0) {
setEligibility(plans[0].internetOfferingType || "Home 1G");
}
}, [eligibilityQuery.isSuccess, eligibilityValue, plans]);
const getEligibilityIcon = (offeringType?: string) => { const getEligibilityIcon = (offeringType?: string) => {
const lower = (offeringType || "").toLowerCase(); const lower = (offeringType || "").toLowerCase();
@ -90,6 +93,17 @@ export function InternetPlansContainer() {
return "text-muted-foreground bg-muted border-border"; 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) { if (isLoading || error) {
return ( return (
<PageLayout <PageLayout
@ -149,31 +163,50 @@ export function InternetPlansContainer() {
title="Choose Your Internet Plan" title="Choose Your Internet Plan"
description="High-speed fiber internet with reliable connectivity for your home or business." description="High-speed fiber internet with reliable connectivity for your home or business."
> >
{eligibility && ( {eligibilityQuery.isLoading ? (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-muted shadow-[var(--cp-shadow-1)]">
<span className="font-semibold text-foreground">Checking availability</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Were verifying whether our service is available at your residence.
</p>
</div>
) : requiresAvailabilityCheck ? (
<div className="flex flex-col items-center gap-2">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-info/25 bg-info-soft text-info shadow-[var(--cp-shadow-1)]">
<span className="font-semibold">Availability review in progress</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Were reviewing service availability for your address. Once confirmed, well unlock
your personalized internet plans.
</p>
</div>
) : eligibility ? (
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div <div
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full border ${getEligibilityColor(eligibility)} shadow-[var(--cp-shadow-1)]`} className={`inline-flex items-center gap-2 px-4 py-2 rounded-full border ${getEligibilityColor(eligibility)} shadow-[var(--cp-shadow-1)]`}
> >
{getEligibilityIcon(eligibility)} {getEligibilityIcon(eligibility)}
<span className="font-semibold">Available for: {eligibility}</span> <span className="font-semibold">Eligible for: {eligibility}</span>
</div> </div>
<p className="text-sm text-muted-foreground text-center max-w-md"> <p className="text-sm text-muted-foreground text-center max-w-md">
Plans shown are tailored to your house type and local infrastructure. Plans shown are tailored to your house type and local infrastructure.
</p> </p>
</div> </div>
)} ) : null}
</CatalogHero> </CatalogHero>
{requiresAvailabilityCheck && ( {requiresAvailabilityCheck && (
<AlertBanner <AlertBanner
variant="info" variant="info"
title="We need to confirm availability for your address" title="Were reviewing service availability for your residence"
className="mb-8" className="mb-8"
> >
<div className="flex flex-col sm:flex-row sm:items-center gap-3"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-foreground/80"> <p className="text-sm text-foreground/80">
Our team will verify NTT serviceability and update your eligible offerings. You can Our team will verify NTT serviceability and update your eligible offerings. Well
request a check now; well update your account once its confirmed. notify you on your dashboard when review is complete.
</p> </p>
{hasServiceAddress ? ( {hasServiceAddress ? (
<Button <Button
@ -189,7 +222,7 @@ export function InternetPlansContainer() {
} }
className="sm:ml-auto whitespace-nowrap" className="sm:ml-auto whitespace-nowrap"
> >
Request availability check Request review now
</Button> </Button>
) : ( ) : (
<Button <Button
@ -227,8 +260,22 @@ export function InternetPlansContainer() {
{plans.length > 0 ? ( {plans.length > 0 ? (
<> <>
{requiresAvailabilityCheck && (
<AlertBanner
variant="info"
title="Availability review in progress"
className="mb-8"
elevated
>
<p className="text-sm text-foreground/80">
You can browse standard pricing below, but ordering stays locked until we confirm
service availability for your residence.
</p>
</AlertBanner>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{plans.map(plan => ( {(requiresAvailabilityCheck ? silverPlans : plans).map(plan => (
<div key={plan.id}> <div key={plan.id}>
<InternetPlanCard <InternetPlanCard
plan={plan} plan={plan}
@ -238,7 +285,7 @@ export function InternetPlansContainer() {
hasActiveInternet hasActiveInternet
? "Already subscribed — contact us to add another residence" ? "Already subscribed — contact us to add another residence"
: requiresAvailabilityCheck : requiresAvailabilityCheck
? "Availability check required before ordering" ? "Ordering locked until availability is confirmed"
: undefined : undefined
} }
/> />
@ -247,19 +294,7 @@ export function InternetPlansContainer() {
</div> </div>
<div className="mt-16"> <div className="mt-16">
<AlertBanner variant="info" title="Important Notes"> <InternetImportantNotes />
<ul className="list-disc list-inside space-y-2 text-sm">
<li>Theoretical internet speed is the same for all three packages</li>
<li>
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
</li>
<li>
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans
(¥450/month + ¥1,000-3,000 one-time)
</li>
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
</ul>
</AlertBanner>
</div> </div>
</> </>
) : ( ) : (

View File

@ -34,7 +34,8 @@ export function PublicCatalogHomeView() {
</h1> </h1>
<p className="text-sm sm:text-base text-muted-foreground mt-2 max-w-3xl leading-relaxed"> <p className="text-sm sm:text-base text-muted-foreground mt-2 max-w-3xl leading-relaxed">
Discover high-speed internet, mobile data/voice options, and secure VPN services. Browse Discover high-speed internet, mobile data/voice options, and secure VPN services. Browse
our catalog and configure your perfect plan. our catalog and see starting prices. Create an account to unlock personalized plans and
check internet availability for your address.
</p> </p>
</div> </div>
@ -93,13 +94,13 @@ export function PublicCatalogHomeView() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FeatureCard <FeatureCard
icon={<WifiIcon className="h-8 w-8 text-primary" />} icon={<WifiIcon className="h-8 w-8 text-primary" />}
title="Flexible Plans" title="Personalized Plans"
description="Choose from a variety of plans tailored to your needs and budget" description="Sign up to see eligibility-based internet offerings and plan options"
/> />
<FeatureCard <FeatureCard
icon={<GlobeAltIcon className="h-8 w-8 text-primary" />} icon={<GlobeAltIcon className="h-8 w-8 text-primary" />}
title="Seamless Checkout" title="Account-First Ordering"
description="Configure your plan and checkout in minutes - no account required upfront" description="Create an account to verify eligibility and complete your order"
/> />
</div> </div>
</div> </div>

View File

@ -1,52 +1,60 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { logger } from "@/lib/logger"; import { Button } from "@/components/atoms/button";
import { useInternetConfigure } from "@/features/catalog/hooks/useInternetConfigure"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { InternetConfigureView as InternetConfigureInnerView } from "@/features/catalog/components/internet/InternetConfigureView"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
/** /**
* Public Internet Configure View * Public Internet Configure View
* *
* Configure internet plan for unauthenticated users. * Public shop is browse-only. Users must create an account so we can verify internet availability
* Navigates to public checkout instead of authenticated checkout. * for their service address before showing personalized plans and allowing configuration.
*/ */
export function PublicInternetConfigureView() { export function PublicInternetConfigureView() {
const router = useRouter(); const shopBasePath = useShopBasePath();
const vm = useInternetConfigure();
const handleConfirm = () => { return (
logger.debug("Public handleConfirm called, current state", { <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
plan: vm.plan?.sku, <CatalogBackLink href={`${shopBasePath}/internet`} label="Back to Internet plans" />
mode: vm.mode,
installation: vm.selectedInstallation?.sku,
});
const params = vm.buildCheckoutSearchParams(); <CatalogHero
if (!params) { title="Create an account to continue"
logger.error("Cannot proceed to checkout: missing required configuration", { description="Well verify service availability for your address, then show personalized internet plans and configuration options."
plan: vm.plan?.sku, />
mode: vm.mode,
installation: vm.selectedInstallation?.sku,
});
const missingItems: string[] = []; <AlertBanner
if (!vm.plan) missingItems.push("plan selection"); variant="info"
if (!vm.mode) missingItems.push("access mode"); title="Internet availability review"
if (!vm.selectedInstallation) missingItems.push("installation option"); className="max-w-2xl mx-auto"
>
alert(`Please complete the following before proceeding:\n- ${missingItems.join("\n- ")}`); <div className="space-y-3 text-sm text-foreground/80">
return; <p>
} Internet plans depend on your residence and local infrastructure. Create an account so
we can review availability and unlock ordering.
logger.debug("Navigating to public checkout with params", { </p>
params: params.toString(), <div className="flex flex-col sm:flex-row gap-3">
}); <Button
// Navigate to public checkout as="a"
router.push(`/order?${params.toString()}`); href={`/auth/signup?redirect=${encodeURIComponent("/account/shop/internet")}`}
}; className="whitespace-nowrap"
>
return <InternetConfigureInnerView {...vm} onConfirm={handleConfirm} />; Create account
</Button>
<Button
as="a"
href={`/auth/login?redirect=${encodeURIComponent("/account/shop/internet")}`}
variant="outline"
className="whitespace-nowrap"
>
Sign in
</Button>
</div>
</div>
</AlertBanner>
</div>
);
} }
export default PublicInternetConfigureView; export default PublicInternetConfigureView;

View File

@ -13,6 +13,8 @@ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; 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 * Public Internet Plans View
@ -29,7 +31,25 @@ export function PublicInternetPlansView() {
[data?.installations] [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<string>();
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 getEligibilityIcon = (offeringType?: string) => {
const lower = (offeringType || "").toLowerCase(); const lower = (offeringType || "").toLowerCase();
@ -90,50 +110,55 @@ export function PublicInternetPlansView() {
title="Choose Your Internet Plan" title="Choose Your Internet Plan"
description="High-speed fiber internet with reliable connectivity for your home or business." description="High-speed fiber internet with reliable connectivity for your home or business."
> >
{eligibility && ( <div className="flex flex-col items-center gap-3">
<div className="flex flex-col items-center gap-2"> <p className="text-sm text-muted-foreground text-center max-w-xl">
<div Prices shown are the <span className="font-medium text-foreground">Silver</span> tier so
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full border ${getEligibilityColor(eligibility)} shadow-[var(--cp-shadow-1)]`} you can compare starting prices. Create an account to check internet availability for
> your residence and unlock personalized plan options.
{getEligibilityIcon(eligibility)} </p>
<span className="font-semibold">Available for: {eligibility}</span> <div className="flex flex-wrap justify-center gap-2">
</div> {offeringTypes.map(type => (
<p className="text-sm text-muted-foreground text-center max-w-md"> <div
Plans shown are our standard offerings. Personalized plans available after sign-in. key={type}
</p> className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full border ${getEligibilityColor(type)} shadow-[var(--cp-shadow-1)]`}
>
{getEligibilityIcon(type)}
<span className="text-sm font-semibold">{type}</span>
</div>
))}
</div> </div>
)} <div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button as="a" href="/auth/signup" className="whitespace-nowrap">
Create account to check availability
</Button>
<Button as="a" href="/auth/login" variant="outline" className="whitespace-nowrap">
Sign in
</Button>
</div>
</div>
</CatalogHero> </CatalogHero>
{plans.length > 0 ? ( {silverPlans.length > 0 ? (
<> <>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{plans.map(plan => ( {silverPlans.map(plan => (
<div key={plan.id}> <div key={plan.id}>
<InternetPlanCard <InternetPlanCard
plan={plan} plan={plan}
installations={installations} installations={installations}
disabled={false} disabled={false}
configureHref={`${shopBasePath}/internet/configure?plan=${encodeURIComponent(plan.sku)}`} pricingPrefix="Starting from"
action={{
label: "Create account",
href: `/auth/signup?redirect=${encodeURIComponent("/account/shop/internet")}`,
}}
/> />
</div> </div>
))} ))}
</div> </div>
<div className="mt-16"> <div className="mt-16">
<AlertBanner variant="info" title="Important Notes"> <InternetImportantNotes />
<ul className="list-disc list-inside space-y-2 text-sm">
<li>Theoretical internet speed is the same for all three packages</li>
<li>
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
</li>
<li>
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month
+ ¥1,000-3,000 one-time)
</li>
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
</ul>
</AlertBanner>
</div> </div>
</> </>
) : ( ) : (

View File

@ -1,31 +1,59 @@
"use client"; "use client";
import { useSearchParams, useRouter } from "next/navigation"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { useSimConfigure } from "@/features/catalog/hooks/useSimConfigure"; import { Button } from "@/components/atoms/button";
import { SimConfigureView as SimConfigureInnerView } from "@/features/catalog/components/sim/SimConfigureView"; 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 * Public SIM Configure View
* *
* Configure SIM plan for unauthenticated users. * Public shop is browse-only. Users must create an account to add a payment method and
* Navigates to public checkout instead of authenticated checkout. * complete identity verification before ordering SIM service.
*/ */
export function PublicSimConfigureView() { export function PublicSimConfigureView() {
const shopBasePath = useShopBasePath();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const plan = searchParams?.get("planSku") || undefined;
const planId = searchParams.get("plan") || undefined; const redirectTarget = plan ? `/account/shop/sim/configure?planSku=${plan}` : "/account/shop/sim";
const vm = useSimConfigure(planId); return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<CatalogBackLink href={`${shopBasePath}/sim`} label="Back to SIM plans" />
const handleConfirm = () => { <CatalogHero
if (!vm.plan || !vm.validate()) return; title="Create an account to order SIM service"
const params = vm.buildCheckoutSearchParams(); description="Ordering requires a payment method and identity verification."
if (!params) return; />
// Navigate to public checkout
router.push(`/order?${params.toString()}`);
};
return <SimConfigureInnerView {...vm} onConfirm={handleConfirm} />; <AlertBanner variant="info" title="Account required" className="max-w-2xl mx-auto">
<div className="space-y-3 text-sm text-foreground/80">
<p>
Create an account to add your payment method and submit your residence card for review.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button
as="a"
href={`/auth/signup?redirect=${encodeURIComponent(redirectTarget)}`}
className="whitespace-nowrap"
>
Create account
</Button>
<Button
as="a"
href={`/auth/login?redirect=${encodeURIComponent(redirectTarget)}`}
variant="outline"
className="whitespace-nowrap"
>
Sign in
</Button>
</div>
</div>
</AlertBanner>
</div>
);
} }
export default PublicSimConfigureView; export default PublicSimConfigureView;

View File

@ -37,6 +37,10 @@ export function PublicSimPlansView() {
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
"data-voice" "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) { if (isLoading) {
return ( return (
@ -102,9 +106,36 @@ export function PublicSimPlansView() {
<CatalogHero <CatalogHero
title="Choose Your SIM Plan" title="Choose Your SIM Plan"
description="Flexible mobile plans with physical SIM and eSIM options for any device." description="Browse plan options now. Create an account to order, manage billing, and complete verification."
/> />
<AlertBanner
variant="info"
title="Account required to order"
className="mb-8 max-w-4xl mx-auto"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-foreground/80">
To place a SIM order youll need an account, a payment method, and identity
verification.
</p>
<div className="flex gap-3 sm:ml-auto">
<Button as="a" href={buildRedirect()} size="sm" className="whitespace-nowrap">
Create account
</Button>
<Button
as="a"
href={`/auth/login?redirect=${encodeURIComponent("/account/shop/sim")}`}
size="sm"
variant="outline"
className="whitespace-nowrap"
>
Sign in
</Button>
</div>
</div>
</AlertBanner>
<div className="mb-8 flex justify-center"> <div className="mb-8 flex justify-center">
<div className="border-b border-border"> <div className="border-b border-border">
<nav className="-mb-px flex space-x-6" aria-label="Tabs"> <nav className="-mb-px flex space-x-6" aria-label="Tabs">
@ -187,6 +218,7 @@ export function PublicSimPlansView() {
icon={<DevicePhoneMobileIcon className="h-6 w-6 text-primary" />} icon={<DevicePhoneMobileIcon className="h-6 w-6 text-primary" />}
plans={plansByType.DataSmsVoice} plans={plansByType.DataSmsVoice}
showFamilyDiscount={false} showFamilyDiscount={false}
cardAction={plan => ({ label: "Continue", href: buildRedirect(plan.sku) })}
/> />
</div> </div>
)} )}
@ -198,6 +230,7 @@ export function PublicSimPlansView() {
icon={<GlobeAltIcon className="h-6 w-6 text-primary" />} icon={<GlobeAltIcon className="h-6 w-6 text-primary" />}
plans={plansByType.DataOnly} plans={plansByType.DataOnly}
showFamilyDiscount={false} showFamilyDiscount={false}
cardAction={plan => ({ label: "Continue", href: buildRedirect(plan.sku) })}
/> />
</div> </div>
)} )}
@ -209,6 +242,7 @@ export function PublicSimPlansView() {
icon={<PhoneIcon className="h-6 w-6 text-primary" />} icon={<PhoneIcon className="h-6 w-6 text-primary" />}
plans={plansByType.VoiceOnly} plans={plansByType.VoiceOnly}
showFamilyDiscount={false} showFamilyDiscount={false}
cardAction={plan => ({ label: "Continue", href: buildRedirect(plan.sku) })}
/> />
</div> </div>
)} )}

View File

@ -8,7 +8,7 @@ export function SimConfigureContainer() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const planId = searchParams.get("plan") || undefined; const planId = searchParams.get("planSku") || undefined;
const vm = useSimConfigure(planId); const vm = useSimConfigure(planId);

View File

@ -18,6 +18,11 @@ import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTyp
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink"; import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero"; import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath"; import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import {
useResidenceCardVerification,
useSubmitResidenceCard,
} from "@/features/verification/hooks/useResidenceCardVerification";
interface PlansByType { interface PlansByType {
DataOnly: SimCatalogProduct[]; DataOnly: SimCatalogProduct[];
@ -33,6 +38,10 @@ export function SimPlansContainer() {
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
"data-voice" "data-voice"
); );
const { data: paymentMethods, isLoading: paymentMethodsLoading } = usePaymentMethods();
const { data: residenceCard, isLoading: residenceCardLoading } = useResidenceCardVerification();
const submitResidenceCard = useSubmitResidenceCard();
const [residenceCardFile, setResidenceCardFile] = useState<File | null>(null);
useEffect(() => { useEffect(() => {
setHasExistingSim(simPlans.some(p => p.simHasFamilyDiscount)); 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." description="Flexible mobile plans with physical SIM and eSIM options for any device."
/> />
{paymentMethodsLoading || residenceCardLoading ? (
<AlertBanner variant="info" title="Checking requirements…" className="mb-8">
<p className="text-sm text-foreground/80">
Loading your payment method and residence card verification status.
</p>
</AlertBanner>
) : (
<>
{paymentMethods && paymentMethods.totalCount === 0 && (
<AlertBanner
variant="warning"
title="Add a payment method to order SIM"
className="mb-6"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-foreground/80">
SIM orders require a saved payment method on your account.
</p>
<Button
as="a"
href="/account/billing/payments"
size="sm"
className="sm:ml-auto whitespace-nowrap"
>
Add payment method
</Button>
</div>
</AlertBanner>
)}
{residenceCard?.status === "pending" && (
<AlertBanner variant="info" title="Residence card under review" className="mb-6">
<p className="text-sm text-foreground/80">
Were verifying your residence card. Well update your account once review is
complete.
</p>
</AlertBanner>
)}
{(residenceCard?.status === "not_submitted" ||
residenceCard?.status === "rejected") && (
<AlertBanner
variant={residenceCard?.status === "rejected" ? "warning" : "info"}
title={
residenceCard?.status === "rejected"
? "Residence card needs resubmission"
: "Submit your residence card for verification"
}
className="mb-8"
>
<div className="space-y-3">
<p className="text-sm text-foreground/80">
To order SIM service, please upload your residence card for identity
verification.
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<input
type="file"
accept="image/*,application/pdf"
onChange={e => 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"
/>
<Button
type="button"
size="sm"
disabled={!residenceCardFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceCardFile) return;
submitResidenceCard.mutate(residenceCardFile, {
onSuccess: () => setResidenceCardFile(null),
});
}}
className="sm:ml-auto whitespace-nowrap"
>
Submit for review
</Button>
</div>
{submitResidenceCard.isError && (
<p className="text-sm text-danger">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</p>
)}
</div>
</AlertBanner>
)}
</>
)}
{hasExistingSim && ( {hasExistingSim && (
<AlertBanner variant="success" title="Family Discount Applied" className="mb-8"> <AlertBanner variant="success" title="Family Discount Applied" className="mb-8">
<div className="space-y-2"> <div className="space-y-2">

View File

@ -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<string | null>(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<File | null>(null);
const residenceFileInputRef = useRef<HTMLInputElement | null>(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 (
<div className="max-w-2xl mx-auto py-8">
<AlertBanner variant="error" title="Checkout Error" elevated>
<div className="flex items-center justify-between">
<span>Checkout data is not available</span>
<Button as="a" href={shopHref} variant="link">
Back to Shop
</Button>
</div>
</AlertBanner>
</div>
);
}
return (
<PageLayout
title="Checkout"
description="Verify your address, review totals, and submit your order"
icon={<ShieldCheckIcon className="h-6 w-6" />}
>
<div className="max-w-2xl mx-auto space-y-8">
<InlineToast
visible={paymentRefresh.toast.visible}
text={paymentRefresh.toast.text}
tone={paymentRefresh.toast.tone}
/>
{activeInternetWarning && (
<AlertBanner variant="warning" title="Existing Internet Subscription" elevated>
<span className="text-sm text-foreground/80">{activeInternetWarning}</span>
</AlertBanner>
)}
{eligibilityLoading ? (
<AlertBanner variant="info" title="Checking availability…" elevated>
Were loading your current eligibility status.
</AlertBanner>
) : eligibilityError ? (
<AlertBanner variant="warning" title="Unable to verify availability right now" elevated>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Please try again in a moment. If this continues, contact support.
</span>
<Button
type="button"
size="sm"
className="sm:ml-auto"
onClick={() => void eligibilityQuery.refetch()}
>
Try again
</Button>
</div>
</AlertBanner>
) : eligibilityPending ? (
<AlertBanner variant="info" title="Availability review in progress" elevated>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Were verifying whether our service is available at your residence. Once eligibility
is confirmed, you can submit your internet order.
</span>
<Button as="a" href="/account/shop/internet" size="sm" className="sm:ml-auto">
View status
</Button>
</div>
</AlertBanner>
) : null}
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<ShieldCheckIcon className="w-6 h-6 text-primary" />
<h2 className="text-lg font-semibold text-foreground">Checkout requirements</h2>
</div>
<div className="space-y-5">
<SubCard>
<AddressConfirmation
embedded
onAddressConfirmed={() => setAddressConfirmed(true)}
onAddressIncomplete={() => setAddressConfirmed(false)}
orderType={orderType}
/>
</SubCard>
<SubCard
title="Billing & Payment"
icon={<CreditCardIcon className="w-5 h-5 text-primary" />}
right={
<div className="flex items-center gap-2">
{hasPaymentMethod ? <StatusPill label="Verified" variant="success" /> : undefined}
<Button as="a" href="/account/billing/payments" size="sm" variant="outline">
{hasPaymentMethod ? "Change" : "Add"}
</Button>
</div>
}
>
{paymentMethodsLoading ? (
<div className="text-sm text-muted-foreground">Checking payment methods...</div>
) : paymentMethodsError ? (
<AlertBanner
variant="warning"
title="Unable to verify payment methods"
size="sm"
elevated
>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => void paymentRefresh.triggerRefresh()}
>
Check Again
</Button>
<Button as="a" href="/account/billing/payments" size="sm">
Add Payment Method
</Button>
</div>
</AlertBanner>
) : hasPaymentMethod ? (
<div className="space-y-3">
{paymentMethodDisplay ? (
<div className="rounded-xl border border-border bg-card p-4 shadow-[var(--cp-shadow-1)] transition-shadow duration-200 hover:shadow-[var(--cp-shadow-2)]">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
Default payment method
</p>
<p className="mt-1 text-sm font-semibold text-foreground">
{paymentMethodDisplay.title}
</p>
{paymentMethodDisplay.subtitle ? (
<p className="mt-1 text-xs text-muted-foreground">
{paymentMethodDisplay.subtitle}
</p>
) : null}
</div>
</div>
</div>
) : null}
<p className="text-xs text-muted-foreground">
We securely charge your saved payment method after the order is approved.
</p>
</div>
) : (
<AlertBanner variant="error" title="No payment method on file" size="sm" elevated>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => void paymentRefresh.triggerRefresh()}
>
Check Again
</Button>
<Button as="a" href="/account/billing/payments" size="sm">
Add Payment Method
</Button>
</div>
</AlertBanner>
)}
</SubCard>
{isSimOrder ? (
<SubCard
title="Identity verification"
icon={<ShieldCheckIcon className="w-5 h-5 text-primary" />}
right={
residenceStatus === "verified" ? (
<StatusPill label="Verified" variant="success" />
) : residenceStatus === "pending" ? (
<StatusPill label="Submitted" variant="info" />
) : residenceStatus === "rejected" ? (
<StatusPill label="Action needed" variant="warning" />
) : (
<StatusPill label="Required" variant="warning" />
)
}
>
{residenceCardQuery.isLoading ? (
<div className="text-sm text-muted-foreground">
Checking residence card status
</div>
) : residenceCardQuery.isError ? (
<AlertBanner
variant="warning"
title="Unable to load verification status"
size="sm"
elevated
>
<Button
type="button"
size="sm"
onClick={() => void residenceCardQuery.refetch()}
>
Check again
</Button>
</AlertBanner>
) : residenceStatus === "verified" ? (
<div className="space-y-3">
<AlertBanner
variant="success"
title="Residence card verified"
size="sm"
elevated
>
Your identity verification is complete.
</AlertBanner>
{residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Submitted document
</div>
{residenceCardQuery.data?.filename ? (
<div className="mt-1 text-sm font-medium text-foreground">
{residenceCardQuery.data.filename}
{typeof residenceCardQuery.data.sizeBytes === "number" &&
residenceCardQuery.data.sizeBytes > 0 ? (
<span className="text-xs text-muted-foreground">
{" "}
·{" "}
{Math.round(
(residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10
) / 10}
{" MB"}
</span>
) : null}
</div>
) : null}
<div className="mt-1 text-xs text-muted-foreground space-y-0.5">
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
<div>
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
</div>
) : null}
{formatDateTime(residenceCardQuery.data?.reviewedAt) ? (
<div>
Reviewed: {formatDateTime(residenceCardQuery.data?.reviewedAt)}
</div>
) : null}
</div>
</div>
) : null}
<details className="rounded-xl border border-border bg-card p-4">
<summary className="cursor-pointer select-none text-sm font-semibold text-foreground">
Replace residence card
</summary>
<div className="pt-3 space-y-3">
<p className="text-xs text-muted-foreground">
Replacing the file restarts the verification process.
</p>
<input
ref={residenceFileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={e => 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 ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
Selected file
</div>
<div className="text-sm font-medium text-foreground truncate">
{residenceFile.name}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
}}
>
Change
</Button>
</div>
) : null}
<div className="flex items-center justify-end">
<Button
type="button"
size="sm"
disabled={!residenceFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceFile) return;
submitResidenceCard.mutate(residenceFile, {
onSuccess: () => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
},
});
}}
>
Submit replacement
</Button>
</div>
{submitResidenceCard.isError && (
<div className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</div>
)}
</div>
</details>
</div>
) : residenceStatus === "pending" ? (
<div className="space-y-3">
<AlertBanner variant="info" title="Residence card submitted" size="sm" elevated>
Well verify your residence card before activating SIM service.
</AlertBanner>
{residenceCardQuery.data?.filename || residenceCardQuery.data?.submittedAt ? (
<div className="rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Submitted document
</div>
{residenceCardQuery.data?.filename ? (
<div className="mt-1 text-sm font-medium text-foreground">
{residenceCardQuery.data.filename}
{typeof residenceCardQuery.data.sizeBytes === "number" &&
residenceCardQuery.data.sizeBytes > 0 ? (
<span className="text-xs text-muted-foreground">
{" "}
·{" "}
{Math.round(
(residenceCardQuery.data.sizeBytes / 1024 / 1024) * 10
) / 10}
{" MB"}
</span>
) : null}
</div>
) : null}
<div className="mt-1 text-xs text-muted-foreground">
{formatDateTime(residenceCardQuery.data?.submittedAt) ? (
<div>
Submitted: {formatDateTime(residenceCardQuery.data?.submittedAt)}
</div>
) : null}
</div>
</div>
) : null}
<details className="rounded-xl border border-border bg-card p-4">
<summary className="cursor-pointer select-none text-sm font-semibold text-foreground">
Replace residence card
</summary>
<div className="pt-3 space-y-3">
<p className="text-xs text-muted-foreground">
If you uploaded the wrong file, you can replace it. This restarts the
review.
</p>
<input
ref={residenceFileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={e => 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 ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
Selected file
</div>
<div className="text-sm font-medium text-foreground truncate">
{residenceFile.name}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
}}
>
Change
</Button>
</div>
) : null}
<div className="flex items-center justify-end">
<Button
type="button"
size="sm"
disabled={!residenceFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceFile) return;
submitResidenceCard.mutate(residenceFile, {
onSuccess: () => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
},
});
}}
>
Submit replacement
</Button>
</div>
{submitResidenceCard.isError && (
<div className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</div>
)}
</div>
</details>
</div>
) : (
<AlertBanner
variant={residenceStatus === "rejected" ? "warning" : "info"}
title={
residenceStatus === "rejected"
? "Residence card needs resubmission"
: "Submit your residence card"
}
size="sm"
elevated
>
<div className="space-y-3">
{residenceStatus === "rejected" && residenceCardQuery.data?.reviewerNotes ? (
<div className="text-sm text-foreground/80">
<div className="font-medium text-foreground">Reviewer note</div>
<div>{residenceCardQuery.data.reviewerNotes}</div>
</div>
) : null}
<p className="text-sm text-foreground/80">
Upload a JPG, PNG, or PDF (max 5MB). Well verify it before activating SIM
service.
</p>
<div className="space-y-2">
<input
ref={residenceFileInputRef}
type="file"
accept="image/*,application/pdf"
onChange={e => 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 ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="min-w-0">
<div className="text-xs font-medium text-muted-foreground">
Selected file
</div>
<div className="text-sm font-medium text-foreground truncate">
{residenceFile.name}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
}}
>
Change
</Button>
</div>
) : null}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<Button
type="button"
size="sm"
disabled={!residenceFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceFile) return;
submitResidenceCard.mutate(residenceFile, {
onSuccess: () => {
setResidenceFile(null);
if (residenceFileInputRef.current) {
residenceFileInputRef.current.value = "";
}
},
});
}}
className="sm:ml-auto whitespace-nowrap"
>
Submit for review
</Button>
</div>
{submitResidenceCard.isError && (
<div className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</div>
)}
</div>
</AlertBanner>
)}
</SubCard>
) : null}
</div>
</div>
<div className="bg-card border border-border rounded-2xl p-6 md:p-7 text-center shadow-[var(--cp-shadow-1)]">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm border border-primary/20">
<ShieldCheckIcon className="w-8 h-8 text-primary" />
</div>
<h2 className="text-2xl font-bold text-foreground mb-2">Review & Submit</h2>
<p className="text-muted-foreground mb-4 max-w-xl mx-auto">
Youre almost done. Confirm your details above, then submit your order. Well review and
notify you when everything is ready.
</p>
{submitError ? (
<div className="pb-4">
<AlertBanner variant="error" title="Unable to submit order" elevated>
{submitError}
</AlertBanner>
</div>
) : null}
<div className="bg-muted/50 rounded-lg p-4 border border-border text-left max-w-2xl mx-auto">
<h3 className="font-semibold text-foreground mb-2">What to expect</h3>
<div className="text-sm text-muted-foreground space-y-1">
<p> Our team reviews your order and schedules setup if needed</p>
<p> We may contact you to confirm details or availability</p>
{isSimOrder ? (
<p> For SIM orders, we verify your residence card before SIM activation</p>
) : null}
<p> We only charge your card after the order is approved</p>
<p> Youll receive confirmation and next steps by email</p>
</div>
</div>
<div className="mt-4 bg-card rounded-lg p-4 border border-border max-w-2xl mx-auto shadow-[var(--cp-shadow-1)]">
<div className="flex justify-between items-center">
<span className="font-medium text-muted-foreground">Estimated Total</span>
<div className="text-right">
<div className="text-xl font-bold text-foreground">
¥{cartItem.pricing.monthlyTotal.toLocaleString()}/mo
</div>
{cartItem.pricing.oneTimeTotal > 0 && (
<div className="text-sm text-warning font-medium">
+ ¥{cartItem.pricing.oneTimeTotal.toLocaleString()} one-time
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex gap-4">
<Button
type="button"
variant="ghost"
className="flex-1 py-4 text-muted-foreground hover:text-foreground"
onClick={navigateBackToConfigure}
>
Back to Configuration
</Button>
<Button
type="button"
className="flex-1 py-4 text-lg"
onClick={() => void handleSubmitOrder()}
disabled={
submitting ||
!addressConfirmed ||
paymentMethodsLoading ||
!hasPaymentMethod ||
!residenceSubmitted ||
!isEligible ||
eligibilityLoading ||
eligibilityPending ||
eligibilityError
}
isLoading={submitting}
loadingText="Submitting…"
>
Submit order
</Button>
</div>
</div>
</PageLayout>
);
}
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<string>();
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;

View File

@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; 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 { CartItem, OrderType as CheckoutOrderType } from "@customer-portal/domain/checkout";
import type { CheckoutCart, OrderTypeValue } from "@customer-portal/domain/orders"; import type { CheckoutCart, OrderTypeValue } from "@customer-portal/domain/orders";
import { ORDER_TYPE } 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 { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store"; import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard"; import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard";
import { AccountCheckoutContainer } from "@/features/checkout/components/AccountCheckoutContainer";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { Spinner } from "@/components/atoms"; import { Spinner } from "@/components/atoms";
import { EmptyCartRedirect } from "@/features/checkout/components/EmptyCartRedirect"; import { EmptyCartRedirect } from "@/features/checkout/components/EmptyCartRedirect";
import { useAuthSession } from "@/features/auth/services/auth.store";
const signatureFromSearchParams = (params: URLSearchParams): string => { const signatureFromSearchParams = (params: URLSearchParams): string => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b)); const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
@ -70,6 +72,8 @@ const cartItemFromCheckoutCart = (
export function CheckoutEntry() { export function CheckoutEntry() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const pathname = usePathname();
const { isAuthenticated } = useAuthSession();
const paramsKey = useMemo(() => searchParams.toString(), [searchParams]); const paramsKey = useMemo(() => searchParams.toString(), [searchParams]);
const signature = useMemo( const signature = useMemo(
() => signatureFromSearchParams(new URLSearchParams(paramsKey)), () => signatureFromSearchParams(new URLSearchParams(paramsKey)),
@ -182,13 +186,14 @@ export function CheckoutEntry() {
} }
if (status === "error") { if (status === "error") {
const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop";
return ( return (
<div className="max-w-2xl mx-auto py-8"> <div className="max-w-2xl mx-auto py-8">
<AlertBanner variant="error" title="Unable to start checkout" elevated> <AlertBanner variant="error" title="Unable to start checkout" elevated>
<div className="space-y-3"> <div className="space-y-3">
<div className="text-sm text-foreground/80">{errorMessage}</div> <div className="text-sm text-foreground/80">{errorMessage}</div>
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<Button as="a" href="/shop" size="sm"> <Button as="a" href={shopHref} size="sm">
Back to Shop Back to Shop
</Button> </Button>
<Link href="/contact" className="text-sm text-primary hover:underline self-center"> <Link href="/contact" className="text-sm text-primary hover:underline self-center">
@ -205,5 +210,9 @@ export function CheckoutEntry() {
return <EmptyCartRedirect />; return <EmptyCartRedirect />;
} }
if (pathname.startsWith("/account") && isAuthenticated) {
return <AccountCheckoutContainer />;
}
return <CheckoutWizard />; return <CheckoutWizard />;
} }

View File

@ -1,6 +1,7 @@
"use client"; "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 { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store"; import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
import { Button, Input } from "@/components/atoms"; import { Button, Input } from "@/components/atoms";
@ -11,6 +12,9 @@ import { addressFormSchema, type AddressFormData } from "@customer-portal/domain
import { useZodForm } from "@/hooks/useZodForm"; import { useZodForm } from "@/hooks/useZodForm";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { checkoutRegisterResponseSchema } from "@customer-portal/domain/checkout"; 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 * 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. * Collects service/shipping address and triggers registration for new users.
*/ */
export function AddressStep() { export function AddressStep() {
const router = useRouter();
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
const user = useAuthStore(state => state.user); const user = useAuthStore(state => state.user);
const refreshUser = useAuthStore(state => state.refreshUser); const refreshUser = useAuthStore(state => state.refreshUser);
@ -32,6 +37,44 @@ export function AddressStep() {
} = useCheckoutStore(); } = useCheckoutStore();
const [registrationError, setRegistrationError] = useState<string | null>(null); const [registrationError, setRegistrationError] = useState<string | null>(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( const handleSubmit = useCallback(
async (data: AddressFormData) => { async (data: AddressFormData) => {
setRegistrationError(null); setRegistrationError(null);
@ -101,6 +144,60 @@ export function AddressStep() {
onSubmit: handleSubmit, onSubmit: handleSubmit,
}); });
if (isAuthed) {
return (
<div className="space-y-6">
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
<div className="flex items-center gap-3 mb-6">
<MapPinIcon className="h-6 w-6 text-primary" />
<div>
<h2 className="text-lg font-semibold text-foreground">
{isInternetOrder ? "Installation Address" : "Service Address"}
</h2>
<p className="text-sm text-muted-foreground">
{isInternetOrder
? "Confirm the address where internet will be installed."
: "We'll use your account address for this order."}
</p>
</div>
</div>
<AddressConfirmation
embedded
orderType={cartOrderTypeForAddressConfirmation}
onAddressConfirmed={nextAddress => {
const normalized = toAddressFormData(nextAddress ?? null);
if (!normalized) {
setAuthedAddressConfirmed(false);
return;
}
setAddress(normalized);
setAuthedAddressConfirmed(true);
}}
onAddressIncomplete={() => {
setAuthedAddressConfirmed(false);
}}
/>
<div className="flex gap-4 pt-6 mt-6 border-t border-border">
<Button type="button" variant="ghost" onClick={() => router.back()}>
Back
</Button>
<Button
type="button"
className="flex-1"
onClick={() => setCurrentStep(isInternetOrder ? "availability" : "payment")}
disabled={!authedAddressConfirmed}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue
</Button>
</div>
</div>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]"> <div className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]">
@ -221,7 +318,7 @@ export function AddressStep() {
isLoading={form.isSubmitting} isLoading={form.isSubmitting}
rightIcon={<ArrowRightIcon className="w-4 h-4" />} rightIcon={<ArrowRightIcon className="w-4 h-4" />}
> >
Continue to Payment Continue
</Button> </Button>
</div> </div>
</form> </form>

View File

@ -15,6 +15,11 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import type { PaymentMethodList } from "@customer-portal/domain/payments"; 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 * PaymentStep - Third step in checkout
@ -23,8 +28,13 @@ import type { PaymentMethodList } from "@customer-portal/domain/payments";
*/ */
export function PaymentStep() { export function PaymentStep() {
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
const { setPaymentVerified, paymentMethodVerified, setCurrentStep, registrationComplete } = const {
useCheckoutStore(); cartItem,
setPaymentVerified,
paymentMethodVerified,
setCurrentStep,
registrationComplete,
} = useCheckoutStore();
const [isWaiting, setIsWaiting] = useState(false); const [isWaiting, setIsWaiting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [paymentMethod, setPaymentMethod] = useState<{ const [paymentMethod, setPaymentMethod] = useState<{
@ -33,6 +43,13 @@ export function PaymentStep() {
} | null>(null); } | null>(null);
const canCheckPayment = isAuthenticated || registrationComplete; const canCheckPayment = isAuthenticated || registrationComplete;
const isSimOrder = cartItem?.orderType === "SIM";
const residenceCardQuery = useResidenceCardVerification({
enabled: canCheckPayment && isSimOrder,
});
const submitResidenceCard = useSubmitResidenceCard();
const [residenceFile, setResidenceFile] = useState<File | null>(null);
// Poll for payment method // Poll for payment method
const checkPaymentMethod = useCallback(async () => { const checkPaymentMethod = useCallback(async () => {
@ -202,6 +219,108 @@ export function PaymentStep() {
</div> </div>
)} )}
{isSimOrder ? (
<div className="mt-6 pt-6 border-t border-border">
<div className="flex items-center gap-3 mb-4">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<span className="text-sm font-semibold text-muted-foreground">ID</span>
</div>
<div>
<h3 className="text-base font-semibold text-foreground">
Residence card verification
</h3>
<p className="text-sm text-muted-foreground">
Required for SIM orders. Well review it before activation.
</p>
</div>
</div>
{!canCheckPayment ? (
<AlertBanner variant="warning" title="Complete registration first" elevated>
Please complete account setup so you can upload your residence card.
</AlertBanner>
) : residenceCardQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Checking residence card status</div>
) : residenceCardQuery.isError ? (
<AlertBanner variant="warning" title="Unable to load verification status" elevated>
<div className="flex items-center gap-2">
<Button type="button" size="sm" onClick={() => void residenceCardQuery.refetch()}>
Check again
</Button>
</div>
</AlertBanner>
) : residenceCardQuery.data?.status === "verified" ? (
<div className="p-4 rounded-lg bg-success/10 border border-success/20">
<div className="flex items-center gap-3">
<CheckCircleIcon className="h-6 w-6 text-success" />
<div>
<p className="font-medium text-foreground">Verified</p>
<p className="text-sm text-muted-foreground">
Your residence card has been approved.
</p>
</div>
</div>
</div>
) : residenceCardQuery.data?.status === "pending" ? (
<AlertBanner variant="info" title="Under review" elevated>
Were reviewing your residence card. You can submit your order, but well only
activate the SIM (and charge your card) after the order is approved.
</AlertBanner>
) : (
<AlertBanner
variant={residenceCardQuery.data?.status === "rejected" ? "warning" : "info"}
title={
residenceCardQuery.data?.status === "rejected"
? "Resubmission required"
: "Submit your residence card"
}
elevated
>
<div className="space-y-3">
<p className="text-sm text-foreground/80">
Upload a JPG, PNG, or PDF (max 5MB). Well review it and notify you when its
approved.
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<input
type="file"
accept="image/*,application/pdf"
onChange={e => 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"
/>
<Button
type="button"
size="sm"
disabled={!residenceFile || submitResidenceCard.isPending}
isLoading={submitResidenceCard.isPending}
loadingText="Uploading…"
onClick={() => {
if (!residenceFile) return;
submitResidenceCard.mutate(residenceFile, {
onSuccess: async () => {
setResidenceFile(null);
await residenceCardQuery.refetch();
},
});
}}
className="sm:ml-auto whitespace-nowrap"
>
Submit for review
</Button>
</div>
{submitResidenceCard.isError && (
<div className="text-sm text-destructive">
{submitResidenceCard.error instanceof Error
? submitResidenceCard.error.message
: "Failed to submit residence card."}
</div>
)}
</div>
</AlertBanner>
)}
</div>
) : null}
{/* Navigation buttons */} {/* Navigation buttons */}
<div className="flex gap-4 pt-6 mt-6 border-t border-border"> <div className="flex gap-4 pt-6 mt-6 border-t border-border">
<Button <Button

View File

@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useCheckoutStore } from "../../stores/checkout.store"; import { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession } from "@/features/auth/services/auth.store"; import { useAuthSession } from "@/features/auth/services/auth.store";
import { ordersService } from "@/features/orders/services/orders.service"; import { ordersService } from "@/features/orders/services/orders.service";
@ -15,6 +16,8 @@ import {
ShoppingCartIcon, ShoppingCartIcon,
CheckIcon, CheckIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { useResidenceCardVerification } from "@/features/verification/hooks/useResidenceCardVerification";
/** /**
* ReviewStep - Final step in checkout * ReviewStep - Final step in checkout
@ -23,6 +26,7 @@ import {
*/ */
export function ReviewStep() { export function ReviewStep() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const { user, isAuthenticated } = useAuthSession(); const { user, isAuthenticated } = useAuthSession();
const { const {
cartItem, cartItem,
@ -32,12 +36,20 @@ export function ReviewStep() {
checkoutSessionId, checkoutSessionId,
setCurrentStep, setCurrentStep,
clear, clear,
registrationComplete,
} = useCheckoutStore(); } = useCheckoutStore();
const [termsAccepted, setTermsAccepted] = useState(false); const [termsAccepted, setTermsAccepted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isSimOrder = cartItem?.orderType === "SIM";
const canCheck = isAuthenticated || registrationComplete;
const residenceCardQuery = useResidenceCardVerification({ enabled: canCheck && isSimOrder });
const residenceStatus = residenceCardQuery.data?.status;
const residenceSubmitted =
!isSimOrder || residenceStatus === "pending" || residenceStatus === "verified";
const handleSubmit = async () => { const handleSubmit = async () => {
if (!termsAccepted) { if (!termsAccepted) {
setError("Please accept the terms and conditions"); setError("Please accept the terms and conditions");
@ -63,7 +75,12 @@ export function ReviewStep() {
clear(); clear();
// Redirect to confirmation // Redirect to confirmation
router.push(`/order/complete?orderId=${encodeURIComponent(result.sfOrderId)}`); const isAccountFlow = pathname.startsWith("/account");
router.push(
isAccountFlow
? `/account/orders/${encodeURIComponent(result.sfOrderId)}?status=success`
: `/order/complete?orderId=${encodeURIComponent(result.sfOrderId)}`
);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit order"); setError(err instanceof Error ? err.message : "Failed to submit order");
setIsSubmitting(false); setIsSubmitting(false);
@ -86,6 +103,60 @@ export function ReviewStep() {
</div> </div>
)} )}
{isSimOrder && (
<div className="mb-6">
{residenceCardQuery.isLoading ? (
<AlertBanner variant="info" title="Checking residence card status…" elevated>
Were loading your verification status.
</AlertBanner>
) : residenceCardQuery.isError ? (
<AlertBanner
variant="warning"
title="Unable to verify residence card status"
elevated
>
Please check again or try later. We need to confirm that your residence card has
been submitted before you can place a SIM order.
</AlertBanner>
) : residenceCardQuery.data?.status === "verified" ? (
<AlertBanner variant="success" title="Residence card verified" elevated>
Your residence card has been approved. You can submit your SIM order once you accept
the terms.
</AlertBanner>
) : residenceCardQuery.data?.status === "pending" ? (
<AlertBanner variant="info" title="Residence card submitted — under review" elevated>
You can submit your order now. Well review your residence card before activation,
and you wont be charged until your order is approved.
</AlertBanner>
) : (
<AlertBanner
variant={residenceCardQuery.data?.status === "rejected" ? "warning" : "info"}
title={
residenceCardQuery.data?.status === "rejected"
? "Residence card needs resubmission"
: "Residence card required"
}
elevated
>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<span className="text-sm text-foreground/80">
Submit your residence card in the Payment step to place a SIM order. Well
review it before activation.
</span>
<Button
type="button"
size="sm"
onClick={() => setCurrentStep("payment")}
className="sm:ml-auto whitespace-nowrap"
>
Go to Payment step
</Button>
</div>
</AlertBanner>
)}
</div>
)}
<div className="space-y-4"> <div className="space-y-4">
{/* Account Info */} {/* Account Info */}
<div className="p-4 rounded-lg bg-muted/50 border border-border"> <div className="p-4 rounded-lg bg-muted/50 border border-border">
@ -224,7 +295,9 @@ export function ReviewStep() {
<Button <Button
className="flex-1" className="flex-1"
onClick={handleSubmit} onClick={handleSubmit}
disabled={!termsAccepted || isSubmitting} disabled={
!termsAccepted || isSubmitting || !paymentMethodVerified || !residenceSubmitted
}
isLoading={isSubmitting} isLoading={isSubmitting}
loadingText="Submitting..." loadingText="Submitting..."
rightIcon={<CheckIcon className="w-4 h-4" />} rightIcon={<CheckIcon className="w-4 h-4" />}

View File

@ -42,11 +42,8 @@ export class CheckoutParamsService {
} }
private static coalescePlanReference(selections: OrderSelections): string | null { private static coalescePlanReference(selections: OrderSelections): string | null {
// After cleanup, we only use planSku
const planSku = selections.planSku; const planSku = selections.planSku;
if (typeof planSku === "string" && planSku.trim().length > 0) { if (typeof planSku === "string" && planSku.trim().length > 0) return planSku.trim();
return planSku.trim();
}
return null; return null;
} }

View File

@ -9,7 +9,7 @@ import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware"; import { persist, createJSONStorage } from "zustand/middleware";
import type { CartItem, GuestInfo, CheckoutStep } from "@customer-portal/domain/checkout"; import type { CartItem, GuestInfo, CheckoutStep } from "@customer-portal/domain/checkout";
import type { AddressFormData } from "@customer-portal/domain/customer"; import type { AddressFormData } from "@customer-portal/domain/customer";
import { useAuthSession } from "@/features/auth/services/auth.store"; import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
interface CheckoutState { interface CheckoutState {
// Cart data // Cart data
@ -307,12 +307,17 @@ export function useCurrentStepIndex(): number {
*/ */
export function useCanProceedToStep(targetStep: CheckoutStep): boolean { export function useCanProceedToStep(targetStep: CheckoutStep): boolean {
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
const userAddress = useAuthStore(state => state.user?.address);
const { cartItem, guestInfo, address, registrationComplete, paymentMethodVerified } = const { cartItem, guestInfo, address, registrationComplete, paymentMethodVerified } =
useCheckoutStore(); useCheckoutStore();
// Must have cart to proceed anywhere // Must have cart to proceed anywhere
if (!cartItem) return false; if (!cartItem) return false;
const hasAddress =
Boolean(address?.address1 && address?.city && address?.postcode) ||
Boolean(userAddress?.address1 && userAddress?.city && userAddress?.postcode);
// Step-specific validation // Step-specific validation
switch (targetStep) { switch (targetStep) {
case "account": case "account":
@ -322,17 +327,16 @@ export function useCanProceedToStep(targetStep: CheckoutStep): boolean {
return ( return (
Boolean( Boolean(
guestInfo?.email && guestInfo?.firstName && guestInfo?.lastName && guestInfo?.password guestInfo?.email && guestInfo?.firstName && guestInfo?.lastName && guestInfo?.password
) || registrationComplete ) ||
isAuthenticated ||
registrationComplete
); );
case "availability": case "availability":
// Need address + be authenticated (eligibility lives on Salesforce Account) // Need address + be authenticated (eligibility lives on Salesforce Account)
return ( return hasAddress && (isAuthenticated || registrationComplete);
Boolean(address?.address1 && address?.city && address?.postcode) &&
(isAuthenticated || registrationComplete)
);
case "payment": case "payment":
// Need address // Need address
return Boolean(address?.address1 && address?.city && address?.postcode); return hasAddress;
case "review": case "review":
// Need payment method verified // Need payment method verified
return paymentMethodVerified; return paymentMethodVerified;

View File

@ -16,11 +16,18 @@ import { useDashboardSummary } from "./useDashboardSummary";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import { useOrdersList } from "@/features/orders/hooks/useOrdersList"; import { useOrdersList } from "@/features/orders/hooks/useOrdersList";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
import { useInternetEligibility } from "@/features/catalog/hooks";
import { useAuthSession } from "@/features/auth/services/auth.store";
/** /**
* Task type for dashboard actions * Task type for dashboard actions
*/ */
export type DashboardTaskType = "invoice" | "payment_method" | "order" | "onboarding"; export type DashboardTaskType =
| "invoice"
| "payment_method"
| "order"
| "internet_eligibility"
| "onboarding";
/** /**
* Dashboard task structure * Dashboard task structure
@ -51,6 +58,7 @@ interface ComputeTasksParams {
summary: DashboardSummary | undefined; summary: DashboardSummary | undefined;
paymentMethods: PaymentMethodList | undefined; paymentMethods: PaymentMethodList | undefined;
orders: OrderSummary[] | undefined; orders: OrderSummary[] | undefined;
internetEligibility: string | null | undefined;
formatCurrency: (amount: number, options?: { currency?: string }) => string; formatCurrency: (amount: number, options?: { currency?: string }) => string;
} }
@ -61,6 +69,7 @@ function computeTasks({
summary, summary,
paymentMethods, paymentMethods,
orders, orders,
internetEligibility,
formatCurrency, formatCurrency,
}: ComputeTasksParams): DashboardTask[] { }: ComputeTasksParams): DashboardTask[] {
const tasks: DashboardTask[] = []; const tasks: DashboardTask[] = [];
@ -143,6 +152,22 @@ function computeTasks({
} }
} }
// Priority 4: Internet eligibility review (only when value is explicitly null)
if (internetEligibility === null) {
tasks.push({
id: "internet-eligibility-review",
priority: 4,
type: "internet_eligibility",
title: "Internet availability review",
description:
"Were verifying if our service is available at your residence. Well notify you when review is complete.",
actionLabel: "View status",
detailHref: "/account/shop/internet",
tone: "info",
icon: ClockIcon,
});
}
// Priority 4: No subscriptions (onboarding) - only show if no other tasks // Priority 4: No subscriptions (onboarding) - only show if no other tasks
if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) { if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) {
tasks.push({ tasks.push({
@ -173,6 +198,7 @@ export interface UseDashboardTasksResult {
*/ */
export function useDashboardTasks(): UseDashboardTasksResult { export function useDashboardTasks(): UseDashboardTasksResult {
const { formatCurrency } = useFormatCurrency(); const { formatCurrency } = useFormatCurrency();
const { isAuthenticated } = useAuthSession();
const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary(); const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
@ -184,8 +210,14 @@ export function useDashboardTasks(): UseDashboardTasksResult {
const { data: orders, isLoading: ordersLoading, error: ordersError } = useOrdersList(); const { data: orders, isLoading: ordersLoading, error: ordersError } = useOrdersList();
const {
data: eligibility,
isLoading: eligibilityLoading,
error: eligibilityError,
} = useInternetEligibility({ enabled: isAuthenticated });
const isLoading = summaryLoading || paymentMethodsLoading || ordersLoading; const isLoading = summaryLoading || paymentMethodsLoading || ordersLoading;
const hasError = Boolean(summaryError || paymentMethodsError || ordersError); const hasError = Boolean(summaryError || paymentMethodsError || ordersError || eligibilityError);
const tasks = useMemo( const tasks = useMemo(
() => () =>
@ -193,14 +225,15 @@ export function useDashboardTasks(): UseDashboardTasksResult {
summary, summary,
paymentMethods, paymentMethods,
orders, orders,
internetEligibility: eligibility?.eligibility,
formatCurrency, formatCurrency,
}), }),
[summary, paymentMethods, orders, formatCurrency] [summary, paymentMethods, orders, eligibility?.eligibility, formatCurrency]
); );
return { return {
tasks, tasks,
isLoading, isLoading: isLoading || eligibilityLoading,
hasError, hasError,
taskCount: tasks.length, taskCount: tasks.length,
}; };

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useRef, useState } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { useAuthStore } from "@/features/auth/services/auth.store"; import { useAuthStore } from "@/features/auth/services/auth.store";
@ -9,9 +9,17 @@ import { TaskList, QuickStats, ActivityFeed } from "@/features/dashboard/compone
import { ErrorState } from "@/components/atoms/error-state"; import { ErrorState } from "@/components/atoms/error-state";
import { PageLayout } from "@/components/templates"; import { PageLayout } from "@/components/templates";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { InlineToast } from "@/components/atoms/inline-toast";
import { useInternetEligibility } from "@/features/catalog/hooks";
export function DashboardView() { export function DashboardView() {
const { user, isAuthenticated, loading: authLoading, clearLoading } = useAuthStore(); const { user, isAuthenticated, loading: authLoading, clearLoading } = useAuthStore();
const hideToastTimeout = useRef<number | null>(null);
const [eligibilityToast, setEligibilityToast] = useState<{
visible: boolean;
text: string;
tone: "info" | "success" | "warning" | "error";
}>({ visible: false, text: "", tone: "info" });
// Clear auth loading state when dashboard loads (after successful login) // Clear auth loading state when dashboard loads (after successful login)
useEffect(() => { useEffect(() => {
@ -20,10 +28,47 @@ export function DashboardView() {
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary(); const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
const { tasks, isLoading: tasksLoading, taskCount } = useDashboardTasks(); const { tasks, isLoading: tasksLoading, taskCount } = useDashboardTasks();
const { data: eligibility } = useInternetEligibility({ enabled: isAuthenticated });
// Combined loading state // Combined loading state
const isLoading = authLoading || summaryLoading || !isAuthenticated; const isLoading = authLoading || summaryLoading || !isAuthenticated;
useEffect(() => {
if (!isAuthenticated || !user?.id) return;
const current = eligibility?.eligibility;
if (current === undefined) return; // query not ready yet
const key = `cp:internet-eligibility:last:${user.id}`;
const last = localStorage.getItem(key);
if (current === null) {
localStorage.setItem(key, "PENDING");
return;
}
if (typeof current === "string" && current.trim().length > 0) {
if (last === "PENDING") {
setEligibilityToast({
visible: true,
text: "Weve finished reviewing your address — you can now choose personalized internet plans.",
tone: "success",
});
if (hideToastTimeout.current) window.clearTimeout(hideToastTimeout.current);
hideToastTimeout.current = window.setTimeout(() => {
setEligibilityToast(t => ({ ...t, visible: false }));
hideToastTimeout.current = null;
}, 3500);
}
localStorage.setItem(key, current);
}
}, [eligibility?.eligibility, isAuthenticated, user?.id]);
useEffect(() => {
return () => {
if (hideToastTimeout.current) window.clearTimeout(hideToastTimeout.current);
};
}, []);
if (isLoading) { if (isLoading) {
return ( return (
<PageLayout title="Dashboard" description="Overview of your account" loading> <PageLayout title="Dashboard" description="Overview of your account" loading>
@ -75,6 +120,11 @@ export function DashboardView() {
return ( return (
<PageLayout title="Dashboard" description="Overview of your account"> <PageLayout title="Dashboard" description="Overview of your account">
<InlineToast
visible={eligibilityToast.visible}
text={eligibilityToast.text}
tone={eligibilityToast.tone}
/>
{/* Greeting Section */} {/* Greeting Section */}
<div className="mb-8"> <div className="mb-8">
<p className="text-sm font-medium text-muted-foreground">Welcome back</p> <p className="text-sm font-medium text-muted-foreground">Welcome back</p>

View File

@ -0,0 +1,25 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/api";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { verificationService } from "../services/verification.service";
export function useResidenceCardVerification(options?: { enabled?: boolean }) {
const { isAuthenticated } = useAuthSession();
return useQuery({
queryKey: queryKeys.verification.residenceCard(),
queryFn: () => verificationService.getResidenceCardVerification(),
enabled: (options?.enabled ?? true) && isAuthenticated,
});
}
export function useSubmitResidenceCard() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => verificationService.submitResidenceCard(file),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.verification.residenceCard() });
},
});
}

View File

@ -0,0 +1,37 @@
"use client";
import { apiClient, getDataOrThrow } from "@/lib/api";
export type ResidenceCardVerificationStatus = "not_submitted" | "pending" | "verified" | "rejected";
export interface ResidenceCardVerification {
status: ResidenceCardVerificationStatus;
filename: string | null;
mimeType: string | null;
sizeBytes: number | null;
submittedAt: string | null;
reviewedAt: string | null;
reviewerNotes: string | null;
}
export const verificationService = {
async getResidenceCardVerification(): Promise<ResidenceCardVerification> {
const response = await apiClient.GET<ResidenceCardVerification>(
"/api/verification/residence-card"
);
return getDataOrThrow(response, "Failed to load residence card verification status");
},
async submitResidenceCard(file: File): Promise<ResidenceCardVerification> {
const form = new FormData();
form.set("file", file);
const response = await apiClient.POST<ResidenceCardVerification>(
"/api/verification/residence-card",
{
body: form,
}
);
return getDataOrThrow(response, "Failed to submit residence card");
},
};

View File

@ -157,6 +157,9 @@ export const queryKeys = {
list: () => ["orders", "list"] as const, list: () => ["orders", "list"] as const,
detail: (id: string | number) => ["orders", "detail", String(id)] as const, detail: (id: string | number) => ["orders", "detail", String(id)] as const,
}, },
verification: {
residenceCard: () => ["verification", "residence-card"] as const,
},
support: { support: {
cases: (params?: Record<string, unknown>) => ["support", "cases", params] as const, cases: (params?: Record<string, unknown>) => ["support", "cases", params] as const,
case: (id: string) => ["support", "case", id] as const, case: (id: string) => ["support", "case", id] as const,

View File

@ -8,6 +8,7 @@ Start with `system-overview.md`, then jump into the feature you care about:
- `system-overview.md` — high-level architecture, data ownership, and caching quick reference. - `system-overview.md` — high-level architecture, data ownership, and caching quick reference.
- `accounts-and-identity.md` — how sign-up, WHMCS linking, and address/profile updates behave. - `accounts-and-identity.md` — how sign-up, WHMCS linking, and address/profile updates behave.
- `catalog-and-checkout.md` — where products/pricing come from and what we check before checkout. - `catalog-and-checkout.md` — where products/pricing come from and what we check before checkout.
- `eligibility-and-verification.md` — internet eligibility + SIM ID verification (Salesforce-driven).
- `orders-and-provisioning.md` — order lifecycle in Salesforce and how it is fulfilled into WHMCS. - `orders-and-provisioning.md` — order lifecycle in Salesforce and how it is fulfilled into WHMCS.
- `billing-and-payments.md` — invoices, payment methods, and how billing links are handled. - `billing-and-payments.md` — invoices, payment methods, and how billing links are handled.
- `subscriptions.md` — how active services are read and refreshed. - `subscriptions.md` — how active services are read and refreshed.

View File

@ -21,6 +21,8 @@ Where product data comes from, what we validate, and how we keep it fresh.
- SKUs selected exist in the Salesforce pricebook. - SKUs selected exist in the Salesforce pricebook.
- For Internet orders, we block duplicates when WHMCS already shows an active Internet service (in production). - For Internet orders, we block duplicates when WHMCS already shows an active Internet service (in production).
For the intended Salesforce-driven workflow model (Cases + Account fields + portal UX), see `docs/portal-guides/eligibility-and-verification.md`.
## Checkout Data Captured ## Checkout Data Captured
- Address snapshot: we copy the customers address (or the one they update during checkout) into the Salesforce Order billing fields so the order shows the exact data used. - Address snapshot: we copy the customers address (or the one they update during checkout) into the Salesforce Order billing fields so the order shows the exact data used.

View File

@ -0,0 +1,156 @@
# Eligibility & Verification (Salesforce-Driven)
This guide describes the intended “Salesforce is the source of truth” model for:
- **Internet eligibility** (address/serviceability review)
- **SIM ID verification** (residence card / identity document)
It also explains how these checks gate checkout and where the portal should display their status.
## Goals
- Make eligibility/verification **account-level** so repeat orders are frictionless.
- Use **Salesforce workflow + audit trail** (Cases + Account fields + Files) instead of portal-only tables.
- Keep checkout clean: show **one canonical status** and a single next action.
## Concepts & Ownership
| Concept | Source of truth | Why |
| --------------------------- | ------------------------------------------- | -------------------------------- |
| Products + pricing | Salesforce pricebook | Single catalog truth |
| Payment methods | WHMCS | No card storage in portal |
| Orders + fulfillment | Salesforce Order (and downstream WHMCS) | Operational workflow |
| Internet eligibility status | Salesforce Account (with Case for workflow) | Reuse for future internet orders |
| SIM ID verification status | Salesforce Account (with Files) | Reuse for future SIM orders |
## Internet Eligibility (Address Review)
### Target UX
- On `/account/shop/internet`:
- Show current eligibility status (e.g. “Checking”, “Eligible for …”, “Not available”, “Action needed”).
- If not requested yet: show a single CTA (“Request eligibility review”).
- In checkout (Internet orders):
- If eligibility is **PENDING/REQUIRED**, the submit CTA is disabled and we guide the user to the next action.
- If **ELIGIBLE**, proceed normally.
### Target Salesforce Model
**Account fields (canonical, cached by portal):**
- `InternetEligibilityStatus__c` (picklist)
- Suggested values:
- `REQUIRED` (no check requested / missing address)
- `PENDING` (case open, awaiting review)
- `ELIGIBLE` (approved)
- `INELIGIBLE` (rejected)
- `InternetEligibilityResult__c` (text/picklist; optional)
- Example: `Home`, `Apartment`, or a more structured code that maps to portal offerings.
- `InternetEligibilityCheckedAt__c` (datetime; optional)
- `InternetEligibilityNotes__c` (long text; optional)
**Case (workflow + audit trail):**
- Record type: “Internet Eligibility”
- Fields populated from portal:
- Account, contact, service address snapshot, desired product (SKU), notes
- SLA/review handled by internal team; a Flow/Trigger updates Account fields above.
### Portal/BFF Flow (proposed)
1. Portal calls `POST /api/eligibility/internet/request` (or reuse existing hook behavior).
2. BFF validates:
- account has a service address (or includes the address in request)
- throttling/rate limits
3. BFF creates Salesforce Case and sets `InternetEligibilityStatus__c = PENDING`.
4. Portal reads `GET /api/eligibility/internet` and shows:
- `PENDING` → “Review in progress”
- `ELIGIBLE` → “Eligible for …”
- `INELIGIBLE` → “Not available” + next steps (support/contact)
5. When Salesforce updates the Account fields:
- Portal cache invalidates via CDC/eventing (preferred), or via polling fallback.
### Recommended status → UI mapping
| Status | Shop page | Checkout gating |
| ------------ | ------------------------------------------------ | --------------- |
| `REQUIRED` | Show “Add/confirm address” then “Request review” | Block submit |
| `PENDING` | Show “Review in progress” | Block submit |
| `ELIGIBLE` | Show “Eligible for: …” | Allow submit |
| `INELIGIBLE` | Show “Not available” + support CTA | Block submit |
## SIM ID Verification (Residence Card / Identity Document)
### Target UX
- In SIM checkout (and any future SIM order flow):
- If status is `VERIFIED`: show “Verified” and **no upload/change UI**.
- If `SUBMITTED`: show what was submitted (filename + submitted time) and optionally allow “Replace file”.
- If `REQUIRED`: require upload before order submission.
- In order detail pages:
- Show a simple “ID verification: Required / Submitted / Verified” row.
### Target Salesforce Model
**Account fields (canonical):**
- `IdVerificationStatus__c` (picklist)
- Suggested values (3-state):
- `REQUIRED`
- `SUBMITTED`
- `VERIFIED`
- `IdVerificationSubmittedAt__c` (datetime; optional)
- `IdVerificationVerifiedAt__c` (datetime; optional)
- `IdVerificationNotes__c` (long text; optional)
**Files (document storage):**
- Upload as Salesforce Files (ContentVersion)
- Link to Account (ContentDocumentLink)
- Optionally keep a reference field on Account (e.g. `IdVerificationContentDocumentId__c`) for fast lookup.
### Portal/BFF Flow (proposed)
1. Portal calls `GET /api/verification/id` to read the Accounts canonical status.
2. If user uploads a file:
- Portal calls `POST /api/verification/id` with multipart file.
- BFF uploads File to Salesforce Account and sets:
- `IdVerificationStatus__c = SUBMITTED`
- `IdVerificationSubmittedAt__c = now()`
3. Internal review updates status to `VERIFIED` (and sets verified timestamp/notes).
4. Portal displays:
- `VERIFIED` → no edit, no upload
- `SUBMITTED` → show file metadata + optional replace
- `REQUIRED` → upload required before SIM activation
### Gating rules (SIM checkout)
| Status | Can submit order? | What portal shows |
| ----------- | ----------------: | -------------------------------------- |
| `REQUIRED` | No | Upload required |
| `SUBMITTED` | Yes | Submitted summary + (optional) replace |
| `VERIFIED` | Yes | Verified badge only |
## Where to show status (recommended)
- **Shop pages**
- Internet: eligibility banner/status on `/account/shop/internet`
- SIM: verification requirement banner/status on `/account/shop/sim` (optional)
- **Checkout**
- Show the relevant status inline in the “Checkout requirements” section.
- **Orders**
- Show “Eligibility / ID verification” status on the order detail page so users can track progress after submitting.
- **Dashboard**
- Task tiles like “Internet availability review in progress” or “Submit ID to activate SIM”.
## Notes on transitioning from current implementation
Current portal code uses a portal-side `residence_card_submissions` table for the uploaded file and status. The target model moves canonical status to Salesforce Account fields and stores the file in Salesforce Files.
Recommended migration approach:
1. Add Salesforce Account fields for eligibility and ID verification.
2. Dual-write (temporary): when portal receives an upload, store in both DB and Salesforce.
3. Switch reads to Salesforce status.
4. Backfill existing DB submissions into Salesforce Files.
5. Remove DB storage once operationally safe.