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")
auditLogs AuditLog[]
idMapping IdMapping?
residenceCardSubmission ResidenceCardSubmission?
@@map("users")
}
@ -91,6 +92,30 @@ enum AuditAction {
SYSTEM_MAINTENANCE
}
enum ResidenceCardStatus {
PENDING
VERIFIED
REJECTED
}
model ResidenceCardSubmission {
id String @id @default(uuid())
userId String @unique @map("user_id")
status ResidenceCardStatus @default(PENDING)
filename String
mimeType String @map("mime_type")
sizeBytes Int @map("size_bytes")
content Bytes @db.ByteA
submittedAt DateTime @default(now()) @map("submitted_at")
reviewedAt DateTime? @map("reviewed_at")
reviewerNotes String? @map("reviewer_notes")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("residence_card_submissions")
}
// Per-SIM daily usage snapshot used to build full-month charts
model SimUsageDaily {
id Int @id @default(autoincrement())

View File

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

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

View File

@ -47,6 +47,16 @@ export class GlobalAuthGuard implements CanActivate {
]);
if (isPublic) {
const token = extractAccessTokenFromRequest(request);
if (token) {
try {
await this.attachUserFromToken(request, token);
this.logger.debug(`Authenticated session detected on public route: ${route}`);
} catch (error) {
// Public endpoints should remain accessible even if the session is missing/expired/invalid.
this.logger.debug(`Ignoring invalid session on public route: ${route}`);
}
}
this.logger.debug(`Public route accessed: ${route}`);
return true;
}
@ -61,45 +71,7 @@ export class GlobalAuthGuard implements CanActivate {
throw new UnauthorizedException("Missing token");
}
const payload = await this.jwtService.verify<{ sub?: string; email?: string; exp?: number }>(
token
);
const tokenType = (payload as { type?: unknown }).type;
if (typeof tokenType === "string" && tokenType !== "access") {
throw new UnauthorizedException("Invalid access token");
}
if (!payload.sub || !payload.email) {
throw new UnauthorizedException("Invalid token payload");
}
// Explicit expiry buffer check to avoid tokens expiring mid-request
if (typeof payload.exp !== "number") {
throw new UnauthorizedException("Token missing expiration claim");
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < nowSeconds + 60) {
throw new UnauthorizedException("Token expired or expiring soon");
}
// Then check token blacklist
const isBlacklisted = await this.tokenBlacklistService.isTokenBlacklisted(token);
if (isBlacklisted) {
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
throw new UnauthorizedException("Token has been revoked");
}
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
if (!prismaUser) {
throw new UnauthorizedException("User not found");
}
if (prismaUser.email !== payload.email) {
throw new UnauthorizedException("Token subject does not match user record");
}
const profile: UserAuth = mapPrismaUserToDomain(prismaUser);
(request as RequestWithRoute & { user?: UserAuth }).user = profile;
await this.attachUserFromToken(request, token, route);
this.logger.debug(`Authenticated access to: ${route}`);
return true;
@ -168,4 +140,52 @@ export class GlobalAuthGuard implements CanActivate {
const normalized = path.endsWith("/") ? path.slice(0, -1) : path;
return normalized === "/auth/logout" || normalized === "/api/auth/logout";
}
private async attachUserFromToken(
request: RequestWithRoute,
token: string,
route?: string
): Promise<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 { CatalogModule } from "@bff/modules/catalog/catalog.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
// Clean modular order services
import { OrderValidator } from "./services/order-validator.service.js";
@ -39,6 +40,7 @@ import { OrderFieldConfigModule } from "./config/order-field-config.module.js";
DatabaseModule,
CatalogModule,
CacheModule,
VerificationModule,
OrderFieldConfigModule,
],
controllers: [OrdersController, CheckoutController],

View File

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

View File

@ -16,6 +16,7 @@ type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service.js";
import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js";
import { PaymentValidatorService } from "./payment-validator.service.js";
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
/**
* Handles all order validation logic - both format and business rules
@ -31,7 +32,8 @@ export class OrderValidator {
private readonly whmcs: WhmcsConnectionOrchestratorService,
private readonly pricebookService: OrderPricebookService,
private readonly simCatalogService: SimCatalogService,
private readonly paymentValidator: PaymentValidatorService
private readonly paymentValidator: PaymentValidatorService,
private readonly residenceCards: ResidenceCardService
) {}
/**
@ -269,6 +271,18 @@ export class OrderValidator {
const _productMeta = await this.validateSKUs(businessValidatedBody.skus, pricebookId);
if (businessValidatedBody.orderType === "SIM") {
const verification = await this.residenceCards.getStatusForUser(userId);
if (verification.status === "not_submitted") {
throw new BadRequestException(
"Residence card submission required for SIM orders. Please upload your residence card and try again."
);
}
if (verification.status === "rejected") {
throw new BadRequestException(
"Your residence card submission was rejected. Please resubmit your residence card and try again."
);
}
const activationFees = await this.simCatalogService.getActivationFees();
const activationSkus = new Set(
activationFees

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/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -5,7 +5,13 @@
*/
import { PublicInternetConfigureView } from "@/features/catalog/views/PublicInternetConfigure";
import { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
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 { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
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 { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
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 { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
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 { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
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 { RedirectAuthenticatedToAccountShop } from "@/features/catalog/components/common/RedirectAuthenticatedToAccountShop";
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 { Logo } from "@/components/atoms/logo";
import { useAuthStore } from "@/features/auth/services/auth.store";
import { ShopTabs } from "@/features/catalog/components/base/ShopTabs";
export interface CatalogShellProps {
children: ReactNode;
}
export function CatalogNav() {
return (
<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>
);
return <ShopTabs basePath="/shop" />;
}
export function CatalogShell({ children }: CatalogShellProps) {

View File

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

View File

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

View File

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

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[];
disabled?: boolean;
disabledReason?: string;
/** Override the default configure href (default: /shop/internet/configure?plan=...) */
/** Override the default configure href (default: /shop/internet/configure?planSku=...) */
configureHref?: string;
/** Override default "Configure Plan" action (used for public browse-only flows) */
action?: { label: string; href: string };
/** Optional small prefix above pricing (e.g. "Starting from") */
pricingPrefix?: string;
}
// Tier-based styling using design tokens
@ -51,6 +55,8 @@ export function InternetPlanCard({
disabled,
disabledReason,
configureHref,
action,
pricingPrefix,
}: InternetPlanCardProps) {
const router = useRouter();
const shopBasePath = useShopBasePath();
@ -180,6 +186,11 @@ export function InternetPlanCard({
{/* Pricing - Full width below */}
<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
monthlyPrice={plan.monthlyPrice}
oneTimePrice={plan.oneTimePrice}
@ -204,16 +215,20 @@ export function InternetPlanCard({
rightIcon={!isDisabled ? <ArrowRightIcon className="w-4 h-4" /> : undefined}
onClick={() => {
if (isDisabled) return;
if (action) {
router.push(action.href);
return;
}
const { resetInternetConfig, setInternetConfig } = useCatalogStore.getState();
resetInternetConfig();
setInternetConfig({ planSku: plan.sku, currentStep: 1 });
const href =
configureHref ??
`${shopBasePath}/internet/configure?plan=${encodeURIComponent(plan.sku)}`;
`${shopBasePath}/internet/configure?planSku=${encodeURIComponent(plan.sku)}`;
router.push(href);
}}
>
{isDisabled ? disabledReason || "Not available" : "Configure Plan"}
{isDisabled ? disabledReason || "Not available" : (action?.label ?? "Configure Plan")}
</Button>
</div>
</AnimatedCard>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ import { Button } from "@/components/atoms/button";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { InternetImportantNotes } from "@/features/catalog/components/internet/InternetImportantNotes";
import {
useInternetEligibility,
useRequestInternetEligibilityCheck,
@ -59,22 +60,24 @@ export function InternetPlansContainer() {
(user?.address?.country || user?.address?.countryCode)
);
useEffect(() => {
if (!user?.id) return;
if (eligibilityValue !== null) return;
const key = `cp:internet-eligibility:last:${user.id}`;
if (!localStorage.getItem(key)) {
localStorage.setItem(key, "PENDING");
}
}, [eligibilityValue, user?.id]);
useEffect(() => {
if (eligibilityQuery.isSuccess) {
if (typeof eligibilityValue === "string" && eligibilityValue.trim().length > 0) {
setEligibility(eligibilityValue);
return;
}
if (eligibilityValue === null) {
setEligibility("");
return;
}
}
if (plans.length > 0) {
setEligibility(plans[0].internetOfferingType || "Home 1G");
}
}, [eligibilityQuery.isSuccess, eligibilityValue, plans]);
}, [eligibilityQuery.isSuccess, eligibilityValue]);
const getEligibilityIcon = (offeringType?: string) => {
const lower = (offeringType || "").toLowerCase();
@ -90,6 +93,17 @@ export function InternetPlansContainer() {
return "text-muted-foreground bg-muted border-border";
};
const silverPlans: InternetPlanCatalogItem[] = useMemo(
() =>
plans.filter(
p =>
String(p.internetPlanTier || "")
.trim()
.toLowerCase() === "silver"
) ?? [],
[plans]
);
if (isLoading || error) {
return (
<PageLayout
@ -149,31 +163,50 @@ export function InternetPlansContainer() {
title="Choose Your Internet Plan"
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={`inline-flex items-center gap-2 px-4 py-2 rounded-full border ${getEligibilityColor(eligibility)} shadow-[var(--cp-shadow-1)]`}
>
{getEligibilityIcon(eligibility)}
<span className="font-semibold">Available for: {eligibility}</span>
<span className="font-semibold">Eligible for: {eligibility}</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Plans shown are tailored to your house type and local infrastructure.
</p>
</div>
)}
) : null}
</CatalogHero>
{requiresAvailabilityCheck && (
<AlertBanner
variant="info"
title="We need to confirm availability for your address"
title="Were reviewing service availability for your residence"
className="mb-8"
>
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<p className="text-sm text-foreground/80">
Our team will verify NTT serviceability and update your eligible offerings. You can
request a check now; well update your account once its confirmed.
Our team will verify NTT serviceability and update your eligible offerings. Well
notify you on your dashboard when review is complete.
</p>
{hasServiceAddress ? (
<Button
@ -189,7 +222,7 @@ export function InternetPlansContainer() {
}
className="sm:ml-auto whitespace-nowrap"
>
Request availability check
Request review now
</Button>
) : (
<Button
@ -227,8 +260,22 @@ export function InternetPlansContainer() {
{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">
{plans.map(plan => (
{(requiresAvailabilityCheck ? silverPlans : plans).map(plan => (
<div key={plan.id}>
<InternetPlanCard
plan={plan}
@ -238,7 +285,7 @@ export function InternetPlansContainer() {
hasActiveInternet
? "Already subscribed — contact us to add another residence"
: requiresAvailabilityCheck
? "Availability check required before ordering"
? "Ordering locked until availability is confirmed"
: undefined
}
/>
@ -247,19 +294,7 @@ export function InternetPlansContainer() {
</div>
<div className="mt-16">
<AlertBanner variant="info" title="Important Notes">
<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>
<InternetImportantNotes />
</div>
</>
) : (

View File

@ -34,7 +34,8 @@ export function PublicCatalogHomeView() {
</h1>
<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
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>
</div>
@ -93,13 +94,13 @@ export function PublicCatalogHomeView() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FeatureCard
icon={<WifiIcon className="h-8 w-8 text-primary" />}
title="Flexible Plans"
description="Choose from a variety of plans tailored to your needs and budget"
title="Personalized Plans"
description="Sign up to see eligibility-based internet offerings and plan options"
/>
<FeatureCard
icon={<GlobeAltIcon className="h-8 w-8 text-primary" />}
title="Seamless Checkout"
description="Configure your plan and checkout in minutes - no account required upfront"
title="Account-First Ordering"
description="Create an account to verify eligibility and complete your order"
/>
</div>
</div>

View File

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

View File

@ -13,6 +13,8 @@ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { Button } from "@/components/atoms/button";
import { InternetImportantNotes } from "@/features/catalog/components/internet/InternetImportantNotes";
/**
* Public Internet Plans View
@ -29,7 +31,25 @@ export function PublicInternetPlansView() {
[data?.installations]
);
const eligibility = plans.length > 0 ? plans[0].internetOfferingType || "Home 1G" : "";
const silverPlans: InternetPlanCatalogItem[] = useMemo(
() =>
plans.filter(
p =>
String(p.internetPlanTier || "")
.trim()
.toLowerCase() === "silver"
) ?? [],
[plans]
);
const offeringTypes = useMemo(() => {
const set = new Set<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 lower = (offeringType || "").toLowerCase();
@ -90,50 +110,55 @@ export function PublicInternetPlansView() {
title="Choose Your Internet Plan"
description="High-speed fiber internet with reliable connectivity for your home or business."
>
{eligibility && (
<div className="flex flex-col items-center gap-2">
<div
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full border ${getEligibilityColor(eligibility)} shadow-[var(--cp-shadow-1)]`}
>
{getEligibilityIcon(eligibility)}
<span className="font-semibold">Available for: {eligibility}</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-md">
Plans shown are our standard offerings. Personalized plans available after sign-in.
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground text-center max-w-xl">
Prices shown are the <span className="font-medium text-foreground">Silver</span> tier so
you can compare starting prices. Create an account to check internet availability for
your residence and unlock personalized plan options.
</p>
<div className="flex flex-wrap justify-center gap-2">
{offeringTypes.map(type => (
<div
key={type}
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 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>
{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">
{plans.map(plan => (
{silverPlans.map(plan => (
<div key={plan.id}>
<InternetPlanCard
plan={plan}
installations={installations}
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 className="mt-16">
<AlertBanner variant="info" title="Important Notes">
<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>
<InternetImportantNotes />
</div>
</>
) : (

View File

@ -1,31 +1,59 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { useSimConfigure } from "@/features/catalog/hooks/useSimConfigure";
import { SimConfigureView as SimConfigureInnerView } from "@/features/catalog/components/sim/SimConfigureView";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { useSearchParams } from "next/navigation";
/**
* Public SIM Configure View
*
* Configure SIM plan for unauthenticated users.
* Navigates to public checkout instead of authenticated checkout.
* Public shop is browse-only. Users must create an account to add a payment method and
* complete identity verification before ordering SIM service.
*/
export function PublicSimConfigureView() {
const shopBasePath = useShopBasePath();
const searchParams = useSearchParams();
const router = useRouter();
const planId = searchParams.get("plan") || undefined;
const plan = searchParams?.get("planSku") || undefined;
const redirectTarget = plan ? `/account/shop/sim/configure?planSku=${plan}` : "/account/shop/sim";
const vm = useSimConfigure(planId);
return (
<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 = () => {
if (!vm.plan || !vm.validate()) return;
const params = vm.buildCheckoutSearchParams();
if (!params) return;
// Navigate to public checkout
router.push(`/order?${params.toString()}`);
};
<CatalogHero
title="Create an account to order SIM service"
description="Ordering requires a payment method and identity verification."
/>
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;

View File

@ -37,6 +37,10 @@ export function PublicSimPlansView() {
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
"data-voice"
);
const buildRedirect = (planSku?: string) => {
const target = planSku ? `/account/shop/sim/configure?planSku=${planSku}` : "/account/shop/sim";
return `/auth/signup?redirect=${encodeURIComponent(target)}`;
};
if (isLoading) {
return (
@ -102,9 +106,36 @@ export function PublicSimPlansView() {
<CatalogHero
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="border-b border-border">
<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" />}
plans={plansByType.DataSmsVoice}
showFamilyDiscount={false}
cardAction={plan => ({ label: "Continue", href: buildRedirect(plan.sku) })}
/>
</div>
)}
@ -198,6 +230,7 @@ export function PublicSimPlansView() {
icon={<GlobeAltIcon className="h-6 w-6 text-primary" />}
plans={plansByType.DataOnly}
showFamilyDiscount={false}
cardAction={plan => ({ label: "Continue", href: buildRedirect(plan.sku) })}
/>
</div>
)}
@ -209,6 +242,7 @@ export function PublicSimPlansView() {
icon={<PhoneIcon className="h-6 w-6 text-primary" />}
plans={plansByType.VoiceOnly}
showFamilyDiscount={false}
cardAction={plan => ({ label: "Continue", href: buildRedirect(plan.sku) })}
/>
</div>
)}

View File

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

View File

@ -18,6 +18,11 @@ import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTyp
import { CatalogBackLink } from "@/features/catalog/components/base/CatalogBackLink";
import { CatalogHero } from "@/features/catalog/components/base/CatalogHero";
import { useShopBasePath } from "@/features/catalog/hooks/useShopBasePath";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import {
useResidenceCardVerification,
useSubmitResidenceCard,
} from "@/features/verification/hooks/useResidenceCardVerification";
interface PlansByType {
DataOnly: SimCatalogProduct[];
@ -33,6 +38,10 @@ export function SimPlansContainer() {
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
"data-voice"
);
const { data: paymentMethods, isLoading: paymentMethodsLoading } = usePaymentMethods();
const { data: residenceCard, isLoading: residenceCardLoading } = useResidenceCardVerification();
const submitResidenceCard = useSubmitResidenceCard();
const [residenceCardFile, setResidenceCardFile] = useState<File | null>(null);
useEffect(() => {
setHasExistingSim(simPlans.some(p => p.simHasFamilyDiscount));
@ -149,6 +158,98 @@ export function SimPlansContainer() {
description="Flexible mobile plans with physical SIM and eSIM options for any device."
/>
{paymentMethodsLoading || residenceCardLoading ? (
<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 && (
<AlertBanner variant="success" title="Family Discount Applied" className="mb-8">
<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 Link from "next/link";
import { useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import type { CartItem, OrderType as CheckoutOrderType } from "@customer-portal/domain/checkout";
import type { CheckoutCart, OrderTypeValue } from "@customer-portal/domain/orders";
import { ORDER_TYPE } from "@customer-portal/domain/orders";
@ -10,10 +10,12 @@ import { checkoutService } from "@/features/checkout/services/checkout.service";
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
import { useCheckoutStore } from "@/features/checkout/stores/checkout.store";
import { CheckoutWizard } from "@/features/checkout/components/CheckoutWizard";
import { AccountCheckoutContainer } from "@/features/checkout/components/AccountCheckoutContainer";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { Spinner } from "@/components/atoms";
import { EmptyCartRedirect } from "@/features/checkout/components/EmptyCartRedirect";
import { useAuthSession } from "@/features/auth/services/auth.store";
const signatureFromSearchParams = (params: URLSearchParams): string => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
@ -70,6 +72,8 @@ const cartItemFromCheckoutCart = (
export function CheckoutEntry() {
const searchParams = useSearchParams();
const pathname = usePathname();
const { isAuthenticated } = useAuthSession();
const paramsKey = useMemo(() => searchParams.toString(), [searchParams]);
const signature = useMemo(
() => signatureFromSearchParams(new URLSearchParams(paramsKey)),
@ -182,13 +186,14 @@ export function CheckoutEntry() {
}
if (status === "error") {
const shopHref = pathname.startsWith("/account") ? "/account/shop" : "/shop";
return (
<div className="max-w-2xl mx-auto py-8">
<AlertBanner variant="error" title="Unable to start checkout" elevated>
<div className="space-y-3">
<div className="text-sm text-foreground/80">{errorMessage}</div>
<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
</Button>
<Link href="/contact" className="text-sm text-primary hover:underline self-center">
@ -205,5 +210,9 @@ export function CheckoutEntry() {
return <EmptyCartRedirect />;
}
if (pathname.startsWith("/account") && isAuthenticated) {
return <AccountCheckoutContainer />;
}
return <CheckoutWizard />;
}

View File

@ -1,6 +1,7 @@
"use client";
import { useState, useCallback } from "react";
import { useMemo, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
import { Button, Input } from "@/components/atoms";
@ -11,6 +12,9 @@ import { addressFormSchema, type AddressFormData } from "@customer-portal/domain
import { useZodForm } from "@/hooks/useZodForm";
import { apiClient } from "@/lib/api";
import { checkoutRegisterResponseSchema } from "@customer-portal/domain/checkout";
import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation";
import { ORDER_TYPE } from "@customer-portal/domain/orders";
import type { Address } from "@customer-portal/domain/customer";
/**
* AddressStep - Second step in checkout
@ -18,6 +22,7 @@ import { checkoutRegisterResponseSchema } from "@customer-portal/domain/checkout
* Collects service/shipping address and triggers registration for new users.
*/
export function AddressStep() {
const router = useRouter();
const { isAuthenticated } = useAuthSession();
const user = useAuthStore(state => state.user);
const refreshUser = useAuthStore(state => state.refreshUser);
@ -32,6 +37,44 @@ export function AddressStep() {
} = useCheckoutStore();
const [registrationError, setRegistrationError] = useState<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(
async (data: AddressFormData) => {
setRegistrationError(null);
@ -101,6 +144,60 @@ export function AddressStep() {
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 (
<div className="space-y-6">
<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}
rightIcon={<ArrowRightIcon className="w-4 h-4" />}
>
Continue to Payment
Continue
</Button>
</div>
</form>

View File

@ -15,6 +15,11 @@ import {
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import type { PaymentMethodList } from "@customer-portal/domain/payments";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import {
useResidenceCardVerification,
useSubmitResidenceCard,
} from "@/features/verification/hooks/useResidenceCardVerification";
/**
* PaymentStep - Third step in checkout
@ -23,8 +28,13 @@ import type { PaymentMethodList } from "@customer-portal/domain/payments";
*/
export function PaymentStep() {
const { isAuthenticated } = useAuthSession();
const { setPaymentVerified, paymentMethodVerified, setCurrentStep, registrationComplete } =
useCheckoutStore();
const {
cartItem,
setPaymentVerified,
paymentMethodVerified,
setCurrentStep,
registrationComplete,
} = useCheckoutStore();
const [isWaiting, setIsWaiting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [paymentMethod, setPaymentMethod] = useState<{
@ -33,6 +43,13 @@ export function PaymentStep() {
} | null>(null);
const canCheckPayment = isAuthenticated || registrationComplete;
const isSimOrder = cartItem?.orderType === "SIM";
const residenceCardQuery = useResidenceCardVerification({
enabled: canCheckPayment && isSimOrder,
});
const submitResidenceCard = useSubmitResidenceCard();
const [residenceFile, setResidenceFile] = useState<File | null>(null);
// Poll for payment method
const checkPaymentMethod = useCallback(async () => {
@ -202,6 +219,108 @@ export function PaymentStep() {
</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 */}
<div className="flex gap-4 pt-6 mt-6 border-t border-border">
<Button

View File

@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useCheckoutStore } from "../../stores/checkout.store";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { ordersService } from "@/features/orders/services/orders.service";
@ -15,6 +16,8 @@ import {
ShoppingCartIcon,
CheckIcon,
} from "@heroicons/react/24/outline";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { useResidenceCardVerification } from "@/features/verification/hooks/useResidenceCardVerification";
/**
* ReviewStep - Final step in checkout
@ -23,6 +26,7 @@ import {
*/
export function ReviewStep() {
const router = useRouter();
const pathname = usePathname();
const { user, isAuthenticated } = useAuthSession();
const {
cartItem,
@ -32,12 +36,20 @@ export function ReviewStep() {
checkoutSessionId,
setCurrentStep,
clear,
registrationComplete,
} = useCheckoutStore();
const [termsAccepted, setTermsAccepted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
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 () => {
if (!termsAccepted) {
setError("Please accept the terms and conditions");
@ -63,7 +75,12 @@ export function ReviewStep() {
clear();
// 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) {
setError(err instanceof Error ? err.message : "Failed to submit order");
setIsSubmitting(false);
@ -86,6 +103,60 @@ export function ReviewStep() {
</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">
{/* Account Info */}
<div className="p-4 rounded-lg bg-muted/50 border border-border">
@ -224,7 +295,9 @@ export function ReviewStep() {
<Button
className="flex-1"
onClick={handleSubmit}
disabled={!termsAccepted || isSubmitting}
disabled={
!termsAccepted || isSubmitting || !paymentMethodVerified || !residenceSubmitted
}
isLoading={isSubmitting}
loadingText="Submitting..."
rightIcon={<CheckIcon className="w-4 h-4" />}

View File

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

View File

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

View File

@ -16,11 +16,18 @@ import { useDashboardSummary } from "./useDashboardSummary";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import { useOrdersList } from "@/features/orders/hooks/useOrdersList";
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
*/
export type DashboardTaskType = "invoice" | "payment_method" | "order" | "onboarding";
export type DashboardTaskType =
| "invoice"
| "payment_method"
| "order"
| "internet_eligibility"
| "onboarding";
/**
* Dashboard task structure
@ -51,6 +58,7 @@ interface ComputeTasksParams {
summary: DashboardSummary | undefined;
paymentMethods: PaymentMethodList | undefined;
orders: OrderSummary[] | undefined;
internetEligibility: string | null | undefined;
formatCurrency: (amount: number, options?: { currency?: string }) => string;
}
@ -61,6 +69,7 @@ function computeTasks({
summary,
paymentMethods,
orders,
internetEligibility,
formatCurrency,
}: ComputeTasksParams): 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
if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) {
tasks.push({
@ -173,6 +198,7 @@ export interface UseDashboardTasksResult {
*/
export function useDashboardTasks(): UseDashboardTasksResult {
const { formatCurrency } = useFormatCurrency();
const { isAuthenticated } = useAuthSession();
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: eligibility,
isLoading: eligibilityLoading,
error: eligibilityError,
} = useInternetEligibility({ enabled: isAuthenticated });
const isLoading = summaryLoading || paymentMethodsLoading || ordersLoading;
const hasError = Boolean(summaryError || paymentMethodsError || ordersError);
const hasError = Boolean(summaryError || paymentMethodsError || ordersError || eligibilityError);
const tasks = useMemo(
() =>
@ -193,14 +225,15 @@ export function useDashboardTasks(): UseDashboardTasksResult {
summary,
paymentMethods,
orders,
internetEligibility: eligibility?.eligibility,
formatCurrency,
}),
[summary, paymentMethods, orders, formatCurrency]
[summary, paymentMethods, orders, eligibility?.eligibility, formatCurrency]
);
return {
tasks,
isLoading,
isLoading: isLoading || eligibilityLoading,
hasError,
taskCount: tasks.length,
};

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useEffect, useRef, useState } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
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 { PageLayout } from "@/components/templates";
import { cn } from "@/lib/utils";
import { InlineToast } from "@/components/atoms/inline-toast";
import { useInternetEligibility } from "@/features/catalog/hooks";
export function DashboardView() {
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)
useEffect(() => {
@ -20,10 +28,47 @@ export function DashboardView() {
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
const { tasks, isLoading: tasksLoading, taskCount } = useDashboardTasks();
const { data: eligibility } = useInternetEligibility({ enabled: isAuthenticated });
// Combined loading state
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) {
return (
<PageLayout title="Dashboard" description="Overview of your account" loading>
@ -75,6 +120,11 @@ export function DashboardView() {
return (
<PageLayout title="Dashboard" description="Overview of your account">
<InlineToast
visible={eligibilityToast.visible}
text={eligibilityToast.text}
tone={eligibilityToast.tone}
/>
{/* Greeting Section */}
<div className="mb-8">
<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,
detail: (id: string | number) => ["orders", "detail", String(id)] as const,
},
verification: {
residenceCard: () => ["verification", "residence-card"] as const,
},
support: {
cases: (params?: Record<string, unknown>) => ["support", "cases", params] 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.
- `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.
- `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.
- `billing-and-payments.md` — invoices, payment methods, and how billing links are handled.
- `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.
- 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
- 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.