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:
parent
7cfac4c32f
commit
7ab5e12051
@ -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;
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>,
|
||||
});
|
||||
}
|
||||
}
|
||||
87
apps/bff/src/modules/verification/residence-card.service.ts
Normal file
87
apps/bff/src/modules/verification/residence-card.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/bff/src/modules/verification/verification.module.ts
Normal file
12
apps/bff/src/modules/verification/verification.module.ts
Normal 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 {}
|
||||
2
apps/portal/next-env.d.ts
vendored
2
apps/portal/next-env.d.ts
vendored
@ -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.
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
11
apps/portal/src/app/account/shop/layout.tsx
Normal file
11
apps/portal/src/app/account/shop/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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'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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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">
|
||||
We’re 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">
|
||||
We’re reviewing service availability for your address. Once confirmed, we’ll 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="We’re 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; we’ll update your account once it’s confirmed.
|
||||
Our team will verify NTT serviceability and update your eligible offerings. We’ll
|
||||
notify you on your dashboard when review is complete.
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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="We’ll 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;
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 you’ll 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>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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">
|
||||
We’re verifying your residence card. We’ll 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">
|
||||
|
||||
@ -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>
|
||||
We’re 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">
|
||||
We’re 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>
|
||||
We’ll 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). We’ll 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">
|
||||
You’re almost done. Confirm your details above, then submit your order. We’ll 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>• You’ll 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;
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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. We’ll 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>
|
||||
We’re reviewing your residence card. You can submit your order, but we’ll only
|
||||
activate the SIM (and charge your card) after the order is approved.
|
||||
</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). We’ll review it and notify you when it’s
|
||||
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
|
||||
|
||||
@ -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>
|
||||
We’re 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. We’ll review your residence card before activation,
|
||||
and you won’t 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. We’ll
|
||||
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" />}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
"We’re verifying if our service is available at your residence. We’ll 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,
|
||||
};
|
||||
|
||||
@ -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: "We’ve 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>
|
||||
|
||||
@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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");
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 customer’s address (or the one they update during checkout) into the Salesforce Order billing fields so the order shows the exact data used.
|
||||
|
||||
156
docs/portal-guides/eligibility-and-verification.md
Normal file
156
docs/portal-guides/eligibility-and-verification.md
Normal 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 Account’s 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.
|
||||
Loading…
x
Reference in New Issue
Block a user