Update Documentation and Refactor Service Structure

- Revised README and documentation links to reflect updated paths and improve clarity on service offerings.
- Refactored service components to enhance organization and maintainability, including updates to the Internet and SIM offerings.
- Improved user navigation and experience in service-related views by streamlining component structures and enhancing data handling.
- Updated internal documentation to align with recent changes in service architecture and eligibility processes.
This commit is contained in:
barsa 2025-12-25 15:48:57 +09:00
parent a4d5d03d91
commit 0f8435e6bd
47 changed files with 1415 additions and 1304 deletions

View File

@ -481,7 +481,7 @@ rm -rf node_modules && pnpm install
- **[Deployment Guide](docs/DEPLOY.md)** - Production deployment instructions - **[Deployment Guide](docs/DEPLOY.md)** - Production deployment instructions
- **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions - **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions
- **[Logging](docs/LOGGING.md)** - Logging configuration and best practices - **[Logging](docs/LOGGING.md)** - Logging configuration and best practices
- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/portal-guides/README.md`) - **Portal Guides** - High-level flow, data ownership, and error handling (`docs/how-it-works/README.md`)
## Contributing ## Contributing

View File

@ -119,8 +119,8 @@ Security audits are automatically run on:
### Internal Documentation ### Internal Documentation
- [Environment Configuration](./docs/portal-guides/COMPLETE-GUIDE.md) - [Environment Configuration](./docs/how-it-works/COMPLETE-GUIDE.md)
- [Deployment Guide](./docs/portal-guides/) - [Deployment Guide](./docs/getting-started/)
### External Resources ### External Resources

View File

@ -54,6 +54,20 @@ export const envSchema = z.object({
"Authentication service is temporarily unavailable for maintenance. Please try again later." "Authentication service is temporarily unavailable for maintenance. Please try again later."
), ),
/**
* Services catalog/eligibility cache safety TTL.
*
* Primary invalidation is event-driven (Salesforce CDC / Platform Events).
* This TTL is a safety net to self-heal if events are missed.
*
* Set to 0 to disable safety TTL (pure event-driven).
*/
SERVICES_CACHE_SAFETY_TTL_SECONDS: z.coerce
.number()
.int()
.min(0)
.default(60 * 60 * 12),
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
WHMCS_BASE_URL: z.string().url().optional(), WHMCS_BASE_URL: z.string().url().optional(),
@ -139,9 +153,6 @@ export const envSchema = z.object({
ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z
.string() .string()
.default("Internet_Eligibility_Checked_Date_Time__c"), .default("Internet_Eligibility_Checked_Date_Time__c"),
// Note: These fields are not used in the current Salesforce environment but kept in config schema for future compatibility
ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD: z.string().default("Internet_Eligibility_Notes__c"),
ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD: z.string().default("Internet_Eligibility_Case_Id__c"),
ACCOUNT_ID_VERIFICATION_STATUS_FIELD: z.string().default("Id_Verification_Status__c"), ACCOUNT_ID_VERIFICATION_STATUS_FIELD: z.string().default("Id_Verification_Status__c"),
ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD: z ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD: z

View File

@ -2,3 +2,12 @@ import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic"; export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
/**
* Marks a route/controller as public *and* disables optional session attachment.
*
* Why: some endpoints must be strictly non-personalized for caching/security correctness
* (e.g. public service catalogs). These endpoints should ignore cookies/tokens entirely.
*/
export const IS_PUBLIC_NO_SESSION_KEY = "isPublicNoSession";
export const PublicNoSession = () => SetMetadata(IS_PUBLIC_NO_SESSION_KEY, true);

View File

@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core";
import type { Request } from "express"; import type { Request } from "express";
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js"; import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js";
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator.js"; import { IS_PUBLIC_KEY, IS_PUBLIC_NO_SESSION_KEY } from "../../../decorators/public.decorator.js";
import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js"; import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
@ -45,8 +45,17 @@ export class GlobalAuthGuard implements CanActivate {
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
]); ]);
const isPublicNoSession = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_NO_SESSION_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) { if (isPublic) {
if (isPublicNoSession) {
this.logger.debug(`Strict public route accessed (no session attach): ${route}`);
return true;
}
const token = extractAccessTokenFromRequest(request); const token = extractAccessTokenFromRequest(request);
if (token) { if (token) {
try { try {

View File

@ -0,0 +1,61 @@
import { Controller, Get, Header, Request, UseGuards } from "@nestjs/common";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import {
parseInternetCatalog,
parseSimCatalog,
parseVpnCatalog,
type InternetCatalogCollection,
type SimCatalogCollection,
type VpnCatalogCollection,
} from "@customer-portal/domain/services";
import { InternetServicesService } from "./services/internet-services.service.js";
import { SimServicesService } from "./services/sim-services.service.js";
import { VpnServicesService } from "./services/vpn-services.service.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
@Controller("account/services")
@UseGuards(SalesforceReadThrottleGuard, RateLimitGuard)
export class AccountServicesController {
constructor(
private readonly internetCatalog: InternetServicesService,
private readonly simCatalog: SimServicesService,
private readonly vpnCatalog: VpnServicesService
) {}
@Get("internet/plans")
@RateLimit({ limit: 60, ttl: 60 }) // account page refreshes are cheap; still bounded per IP+UA
@Header("Cache-Control", "private, no-store") // personalized
async getInternetCatalogForAccount(
@Request() req: RequestWithUser
): Promise<InternetCatalogCollection> {
const userId = req.user?.id;
const [plans, installations, addons] = await Promise.all([
this.internetCatalog.getPlansForUser(userId),
this.internetCatalog.getInstallations(),
this.internetCatalog.getAddons(),
]);
return parseInternetCatalog({ plans, installations, addons });
}
@Get("sim/plans")
@RateLimit({ limit: 60, ttl: 60 })
@Header("Cache-Control", "private, no-store") // personalized
async getSimCatalogForAccount(@Request() req: RequestWithUser): Promise<SimCatalogCollection> {
const userId = req.user?.id;
const [plans, activationFees, addons] = await Promise.all([
this.simCatalog.getPlansForUser(userId),
this.simCatalog.getActivationFees(),
this.simCatalog.getAddons(),
]);
return parseSimCatalog({ plans, activationFees, addons });
}
@Get("vpn/plans")
@RateLimit({ limit: 60, ttl: 60 })
@Header("Cache-Control", "private, no-store")
async getVpnCatalogForAccount(@Request() _req: RequestWithUser): Promise<VpnCatalogCollection> {
const catalog = await this.vpnCatalog.getCatalogData();
return parseVpnCatalog(catalog);
}
}

View File

@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Req, UseGuards, UsePipes } from "@nestjs/common"; import { Body, Controller, Get, Header, Post, Req, UseGuards, UsePipes } from "@nestjs/common";
import { ZodValidationPipe } from "nestjs-zod"; import { ZodValidationPipe } from "nestjs-zod";
import { z } from "zod"; import { z } from "zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
@ -31,6 +31,7 @@ export class InternetEligibilityController {
@Get("eligibility") @Get("eligibility")
@RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap) @RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap)
@Header("Cache-Control", "private, no-store")
async getEligibility(@Req() req: RequestWithUser): Promise<InternetEligibilityDetails> { async getEligibility(@Req() req: RequestWithUser): Promise<InternetEligibilityDetails> {
return this.internetCatalog.getEligibilityDetailsForUser(req.user.id); return this.internetCatalog.getEligibilityDetailsForUser(req.user.id);
} }
@ -38,6 +39,7 @@ export class InternetEligibilityController {
@Post("eligibility-request") @Post("eligibility-request")
@RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP @RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP
@UsePipes(new ZodValidationPipe(eligibilityRequestSchema)) @UsePipes(new ZodValidationPipe(eligibilityRequestSchema))
@Header("Cache-Control", "private, no-store")
async requestEligibility( async requestEligibility(
@Req() req: RequestWithUser, @Req() req: RequestWithUser,
@Body() body: EligibilityRequest @Body() body: EligibilityRequest

View File

@ -0,0 +1,54 @@
import { Controller, Get, Header, UseGuards } from "@nestjs/common";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import { Public, PublicNoSession } from "@bff/modules/auth/decorators/public.decorator.js";
import {
parseInternetCatalog,
parseSimCatalog,
parseVpnCatalog,
type InternetCatalogCollection,
type SimCatalogCollection,
type VpnCatalogCollection,
} from "@customer-portal/domain/services";
import { InternetServicesService } from "./services/internet-services.service.js";
import { SimServicesService } from "./services/sim-services.service.js";
import { VpnServicesService } from "./services/vpn-services.service.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
@Controller("public/services")
@Public()
@PublicNoSession()
@UseGuards(SalesforceReadThrottleGuard, RateLimitGuard)
export class PublicServicesController {
constructor(
private readonly internetCatalog: InternetServicesService,
private readonly simCatalog: SimServicesService,
private readonly vpnCatalog: VpnServicesService
) {}
@Get("internet/plans")
@RateLimit({ limit: 20, ttl: 60 }) // 20/min per IP+UA
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // safe: strictly non-personalized
async getInternetCatalog(): Promise<InternetCatalogCollection> {
const catalog = await this.internetCatalog.getCatalogData();
return parseInternetCatalog(catalog);
}
@Get("sim/plans")
@RateLimit({ limit: 20, ttl: 60 }) // 20/min per IP+UA
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // safe: strictly non-personalized
async getSimCatalog(): Promise<SimCatalogCollection> {
const catalog = await this.simCatalog.getCatalogData();
return parseSimCatalog({
...catalog,
plans: catalog.plans.filter(plan => !plan.simHasFamilyDiscount),
});
}
@Get("vpn/plans")
@RateLimit({ limit: 20, ttl: 60 }) // 20/min per IP+UA
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // safe: strictly non-personalized
async getVpnCatalog(): Promise<VpnCatalogCollection> {
const catalog = await this.vpnCatalog.getCatalogData();
return parseVpnCatalog(catalog);
}
}

View File

@ -5,6 +5,7 @@ import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
import { import {
parseInternetCatalog, parseInternetCatalog,
parseSimCatalog, parseSimCatalog,
parseVpnCatalog,
type InternetAddonCatalogItem, type InternetAddonCatalogItem,
type InternetInstallationCatalogItem, type InternetInstallationCatalogItem,
type InternetPlanCatalogItem, type InternetPlanCatalogItem,
@ -12,6 +13,7 @@ import {
type SimCatalogCollection, type SimCatalogCollection,
type SimCatalogProduct, type SimCatalogProduct,
type VpnCatalogProduct, type VpnCatalogProduct,
type VpnCatalogCollection,
} from "@customer-portal/domain/services"; } from "@customer-portal/domain/services";
import { InternetServicesService } from "./services/internet-services.service.js"; import { InternetServicesService } from "./services/internet-services.service.js";
import { SimServicesService } from "./services/sim-services.service.js"; import { SimServicesService } from "./services/sim-services.service.js";
@ -100,8 +102,10 @@ export class ServicesController {
@Get("vpn/plans") @Get("vpn/plans")
@RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute @RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes @Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
async getVpnPlans(): Promise<VpnCatalogProduct[]> { async getVpnPlans(): Promise<VpnCatalogCollection> {
return this.vpnCatalog.getPlans(); // Backwards-compatible: return the full VPN catalog (plans + activation fees)
const catalog = await this.vpnCatalog.getCatalogData();
return parseVpnCatalog(catalog);
} }
@Get("vpn/activation-fees") @Get("vpn/activation-fees")

View File

@ -2,6 +2,8 @@ import { Module, forwardRef } from "@nestjs/common";
import { ServicesController } from "./services.controller.js"; import { ServicesController } from "./services.controller.js";
import { ServicesHealthController } from "./services-health.controller.js"; import { ServicesHealthController } from "./services-health.controller.js";
import { InternetEligibilityController } from "./internet-eligibility.controller.js"; import { InternetEligibilityController } from "./internet-eligibility.controller.js";
import { PublicServicesController } from "./public-services.controller.js";
import { AccountServicesController } from "./account-services.controller.js";
import { IntegrationsModule } from "@bff/integrations/integrations.module.js"; import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
import { CoreConfigModule } from "@bff/core/config/config.module.js"; import { CoreConfigModule } from "@bff/core/config/config.module.js";
@ -22,7 +24,13 @@ import { ServicesCacheService } from "./services/services-cache.service.js";
CacheModule, CacheModule,
QueueModule, QueueModule,
], ],
controllers: [ServicesController, ServicesHealthController, InternetEligibilityController], controllers: [
ServicesController,
PublicServicesController,
AccountServicesController,
ServicesHealthController,
InternetEligibilityController,
],
providers: [ providers: [
BaseServicesService, BaseServicesService,
InternetServicesService, InternetServicesService,

View File

@ -1,4 +1,5 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { CacheService } from "@bff/infra/cache/cache.service.js"; import { CacheService } from "@bff/infra/cache/cache.service.js";
import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js"; import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js";
@ -43,10 +44,10 @@ interface LegacyCatalogCachePayload<T> {
*/ */
@Injectable() @Injectable()
export class ServicesCacheService { export class ServicesCacheService {
// CDC-driven invalidation: null TTL means cache persists until explicit invalidation // CDC-driven invalidation + safety TTL (self-heal if events are missed)
private readonly SERVICES_TTL: number | null = null; private readonly SERVICES_TTL: number | null;
private readonly STATIC_TTL: number | null = null; private readonly STATIC_TTL: number | null;
private readonly ELIGIBILITY_TTL: number | null = null; private readonly ELIGIBILITY_TTL: number | null;
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
private readonly metrics: ServicesCacheSnapshot = { private readonly metrics: ServicesCacheSnapshot = {
@ -61,10 +62,21 @@ export class ServicesCacheService {
// request the same data after CDC invalidation // request the same data after CDC invalidation
private readonly inflightRequests = new Map<string, Promise<unknown>>(); private readonly inflightRequests = new Map<string, Promise<unknown>>();
constructor(private readonly cache: CacheService) {} constructor(
private readonly cache: CacheService,
private readonly config: ConfigService
) {
const raw = this.config.get<number>("SERVICES_CACHE_SAFETY_TTL_SECONDS", 60 * 60 * 12);
const ttl = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null;
// Apply to CDC-driven buckets (catalog + static + eligibility)
this.SERVICES_TTL = ttl;
this.STATIC_TTL = ttl;
this.ELIGIBILITY_TTL = ttl;
}
/** /**
* Get or fetch catalog data (CDC-driven cache, no TTL) * Get or fetch catalog data (CDC-driven cache with safety TTL)
*/ */
async getCachedServices<T>( async getCachedServices<T>(
key: string, key: string,
@ -75,7 +87,7 @@ export class ServicesCacheService {
} }
/** /**
* Get or fetch static catalog data (CDC-driven cache, no TTL) * Get or fetch static catalog data (CDC-driven cache with safety TTL)
*/ */
async getCachedStatic<T>(key: string, fetchFn: () => Promise<T>): Promise<T> { async getCachedStatic<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
return this.getOrSet("static", key, this.STATIC_TTL, fetchFn); return this.getOrSet("static", key, this.STATIC_TTL, fetchFn);
@ -89,7 +101,7 @@ export class ServicesCacheService {
} }
/** /**
* Get or fetch eligibility data (CDC-driven cache, no TTL) * Get or fetch eligibility data (event-driven cache with safety TTL)
*/ */
async getCachedEligibility<T>(key: string, fetchFn: () => Promise<T>): Promise<T> { async getCachedEligibility<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, { return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, {

View File

@ -184,7 +184,15 @@ export class SimServicesService extends BaseServicesService {
return false; return false;
} }
// Check WHMCS for existing SIM services const cacheKey = this.catalogCache.buildServicesKey(
"sim",
"has-existing-sim",
String(mapping.whmcsClientId)
);
// This is per-account and can be somewhat expensive (WHMCS call).
// Cache briefly to reduce repeat reads during account page refreshes.
return await this.catalogCache.getCachedVolatile(cacheKey, async () => {
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId }); const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId });
const services = (products?.products?.product || []) as Array<{ const services = (products?.products?.product || []) as Array<{
groupname?: string; groupname?: string;
@ -192,14 +200,12 @@ export class SimServicesService extends BaseServicesService {
}>; }>;
// Look for active SIM services // Look for active SIM services
const hasActiveSim = services.some( return services.some(service => {
service => const group = String(service.groupname || "").toLowerCase();
String(service.groupname || "") const status = String(service.status || "").toLowerCase();
.toLowerCase() return group.includes("sim") && status === "active";
.includes("sim") && String(service.status || "").toLowerCase() === "active" });
); });
return hasActiveSim;
} catch (error) { } catch (error) {
this.logger.warn(`Failed to check existing SIM for user ${userId}`, error); this.logger.warn(`Failed to check existing SIM for user ${userId}`, error);
return false; // Default to no existing SIM return false; // Default to no existing SIM

View File

@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
import { BaseServicesService } from "./base-services.service.js"; import { BaseServicesService } from "./base-services.service.js";
import { ServicesCacheService } from "./services-cache.service.js";
import type { import type {
SalesforceProduct2WithPricebookEntries, SalesforceProduct2WithPricebookEntries,
VpnCatalogProduct, VpnCatalogProduct,
@ -14,11 +15,17 @@ export class VpnServicesService extends BaseServicesService {
constructor( constructor(
sf: SalesforceConnection, sf: SalesforceConnection,
configService: ConfigService, configService: ConfigService,
@Inject(Logger) logger: Logger @Inject(Logger) logger: Logger,
private readonly catalogCache: ServicesCacheService
) { ) {
super(sf, configService, logger); super(sf, configService, logger);
} }
async getPlans(): Promise<VpnCatalogProduct[]> { async getPlans(): Promise<VpnCatalogProduct[]> {
const cacheKey = this.catalogCache.buildServicesKey("vpn", "plans");
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildServicesQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]); const soql = this.buildServicesQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
@ -33,9 +40,21 @@ export class VpnServicesService extends BaseServicesService {
description: product.description || product.name, description: product.description || product.name,
} satisfies VpnCatalogProduct; } satisfies VpnCatalogProduct;
}); });
},
{
resolveDependencies: plans => ({
productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)),
}),
}
);
} }
async getActivationFees(): Promise<VpnCatalogProduct[]> { async getActivationFees(): Promise<VpnCatalogProduct[]> {
const cacheKey = this.catalogCache.buildServicesKey("vpn", "activation-fees");
return this.catalogCache.getCachedServices(
cacheKey,
async () => {
const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]); const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
@ -51,6 +70,13 @@ export class VpnServicesService extends BaseServicesService {
description: product.description ?? product.name, description: product.description ?? product.name,
} satisfies VpnCatalogProduct; } satisfies VpnCatalogProduct;
}); });
},
{
resolveDependencies: fees => ({
productIds: fees.map(fee => fee.id).filter((id): id is string => Boolean(id)),
}),
}
);
} }
async getCatalogData() { async getCatalogData() {

View File

@ -6,6 +6,6 @@
"rootDir": "./src", "rootDir": "./src",
"sourceMap": true "sourceMap": true
}, },
"include": ["src/**/*"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"] "exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"]
} }

View File

@ -15,6 +15,6 @@
"noEmit": true, "noEmit": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["src/**/*"], "include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"] "exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"]
} }

View File

@ -10,7 +10,7 @@ import { RedirectAuthenticatedToAccountServices } from "@/features/services/comp
export default function PublicInternetConfigurePage() { export default function PublicInternetConfigurePage() {
return ( return (
<> <>
<RedirectAuthenticatedToAccountServices targetPath="/account/services/internet/configure" /> <RedirectAuthenticatedToAccountServices targetPath="/account/services/internet" />
<PublicInternetConfigureView /> <PublicInternetConfigureView />
</> </>
); );

View File

@ -0,0 +1,5 @@
import InternetEligibilityRequestSubmittedView from "@/features/services/views/InternetEligibilityRequestSubmitted";
export default function AccountInternetEligibilityRequestSubmittedPage() {
return <InternetEligibilityRequestSubmittedView />;
}

View File

@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
interface TierInfo { interface TierInfo {
tier: "Silver" | "Gold" | "Platinum"; tier: "Silver" | "Gold" | "Platinum";
planSku: string;
monthlyPrice: number; monthlyPrice: number;
description: string; description: string;
features: string[]; features: string[];
@ -62,6 +63,10 @@ export function InternetOfferingCard({
previewMode = false, previewMode = false,
}: InternetOfferingCardProps) { }: InternetOfferingCardProps) {
const Icon = iconType === "home" ? Home : Building2; const Icon = iconType === "home" ? Home : Building2;
const resolveTierHref = (basePath: string, planSku: string): string => {
const joiner = basePath.includes("?") ? "&" : "?";
return `${basePath}${joiner}planSku=${encodeURIComponent(planSku)}`;
};
return ( return (
<div <div
@ -176,7 +181,7 @@ export function InternetOfferingCard({
) : ( ) : (
<Button <Button
as="a" as="a"
href={ctaPath} href={resolveTierHref(ctaPath, tier.planSku)}
variant={tier.recommended ? "default" : "outline"} variant={tier.recommended ? "default" : "outline"}
size="sm" size="sm"
className="w-full mt-auto" className="w-full mt-auto"

View File

@ -0,0 +1,622 @@
"use client";
import { useMemo, useState, type ElementType, type ReactNode } from "react";
import {
Smartphone,
Check,
Phone,
Globe,
ArrowLeft,
Signal,
Sparkles,
CreditCard,
ChevronDown,
Info,
CircleDollarSign,
TriangleAlert,
Calendar,
ArrowRightLeft,
ArrowRight,
Users,
} from "lucide-react";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import type { SimCatalogProduct } from "@customer-portal/domain/services";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { CardPricing } from "@/features/services/components/base/CardPricing";
import {
ServiceHighlights,
type HighlightFeature,
} from "@/features/services/components/base/ServiceHighlights";
export type SimPlansTab = "data-voice" | "data-only" | "voice-only";
interface PlansByType {
DataOnly: SimCatalogProduct[];
DataSmsVoice: SimCatalogProduct[];
VoiceOnly: SimCatalogProduct[];
}
function CollapsibleSection({
title,
icon: Icon,
defaultOpen = false,
children,
}: {
title: string;
icon: ElementType;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border border-border rounded-xl overflow-hidden bg-card">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-primary" />
<span className="font-medium text-foreground">{title}</span>
</div>
<ChevronDown
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
</button>
<div
className={`overflow-hidden transition-all duration-300 ${isOpen ? "max-h-[2000px]" : "max-h-0"}`}
>
<div className="p-4 pt-0 border-t border-border">{children}</div>
</div>
</div>
);
}
function SimPlanCardCompact({
plan,
isFamily,
onSelect,
}: {
plan: SimCatalogProduct;
isFamily?: boolean;
onSelect: (sku: string) => void;
}) {
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
return (
<div
className={`group relative bg-card rounded-2xl p-5 transition-all duration-200 ${
isFamily
? "border-2 border-success/50 hover:border-success hover:shadow-[var(--cp-shadow-2)]"
: "border border-border hover:border-primary/50 hover:shadow-[var(--cp-shadow-2)]"
}`}
>
{isFamily && (
<div className="absolute -top-3 left-4 flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-success text-success-foreground text-xs font-medium">
<Users className="w-3.5 h-3.5" />
Family Discount
</div>
)}
<div className="flex items-center justify-between mb-4 mt-1">
<div className="flex items-center gap-2">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center ${
isFamily ? "bg-success/10" : "bg-primary/10"
}`}
>
<Signal className={`w-5 h-5 ${isFamily ? "text-success" : "text-primary"}`} />
</div>
<span className="text-lg font-bold text-foreground">{plan.simDataSize}</span>
</div>
</div>
<div className="mb-4">
<CardPricing monthlyPrice={displayPrice} size="sm" alignment="left" />
{isFamily && (
<div className="text-xs text-success font-medium mt-1">Discounted price applied</div>
)}
</div>
<p className="text-sm text-muted-foreground mb-5 line-clamp-2">{plan.name}</p>
<Button
className={`w-full ${
isFamily
? "group-hover:bg-success group-hover:text-success-foreground"
: "group-hover:bg-primary group-hover:text-primary-foreground"
}`}
variant="outline"
onClick={() => onSelect(plan.sku)}
rightIcon={<ArrowRight className="w-4 h-4" />}
>
Select Plan
</Button>
</div>
);
}
export function SimPlansContent({
variant,
plans,
isLoading,
error,
activeTab,
onTabChange,
onSelectPlan,
}: {
variant: "public" | "account";
plans: SimCatalogProduct[];
isLoading: boolean;
error: unknown;
activeTab: SimPlansTab;
onTabChange: (tab: SimPlansTab) => void;
onSelectPlan: (sku: string) => void;
}) {
const servicesBasePath = useServicesBasePath();
const simPlans: SimCatalogProduct[] = useMemo(() => plans ?? [], [plans]);
const hasExistingSim = useMemo(() => simPlans.some(p => p.simHasFamilyDiscount), [simPlans]);
const simFeatures: HighlightFeature[] = [
{
icon: <Signal className="h-6 w-6" />,
title: "NTT Docomo Network",
description: "Best area coverage among the main three carriers in Japan",
highlight: "Nationwide coverage",
},
{
icon: <CircleDollarSign className="h-6 w-6" />,
title: "First Month Free",
description: "Basic fee waived on signup to get you started risk-free",
highlight: "Great value",
},
{
icon: <CreditCard className="h-6 w-6" />,
title: "Foreign Cards Accepted",
description: "We accept both foreign and Japanese credit cards",
highlight: "No hassle",
},
{
icon: <Calendar className="h-6 w-6" />,
title: "No Binding Contract",
description: "Minimum 4 months service (1st month free + 3 billing months)",
highlight: "Flexible contract",
},
{
icon: <ArrowRightLeft className="h-6 w-6" />,
title: "Number Portability",
description: "Easily switch to us keeping your current Japanese number",
highlight: "Keep your number",
},
{
icon: <Smartphone className="h-6 w-6" />,
title: "Free Plan Changes",
description: "Switch data plans anytime for the next billing cycle",
highlight: "Flexibility",
},
];
if (isLoading) {
return (
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<div className="text-center mb-12 pt-8">
<Skeleton className="h-10 w-80 mx-auto mb-4" />
<Skeleton className="h-6 w-96 max-w-full mx-auto" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-card rounded-2xl border border-border p-5">
<div className="flex items-center justify-between mb-6 mt-1">
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-10 rounded-xl" />
<Skeleton className="h-6 w-16" />
</div>
</div>
<div className="mb-6 space-y-2">
<Skeleton className="h-8 w-32" />
</div>
<div className="space-y-2 mb-6">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<Skeleton className="h-10 w-full rounded-md" />
</div>
))}
</div>
</div>
);
}
if (error) {
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred";
return (
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-8 text-center mt-8">
<div className="text-destructive font-medium text-lg mb-2">Failed to load SIM plans</div>
<div className="text-destructive/80 text-sm mb-6">{errorMessage}</div>
<Button as="a" href={servicesBasePath} leftIcon={<ArrowLeft className="w-4 h-4" />}>
Back to Services
</Button>
</div>
</div>
);
}
const plansByType = simPlans.reduce<PlansByType>(
(acc, plan) => {
const planType = plan.simPlanType || "DataOnly";
if (planType === "DataOnly") acc.DataOnly.push(plan);
else if (planType === "VoiceOnly") acc.VoiceOnly.push(plan);
else acc.DataSmsVoice.push(plan);
return acc;
},
{ DataOnly: [], DataSmsVoice: [], VoiceOnly: [] }
);
const getCurrentPlans = () => {
const tabPlans =
activeTab === "data-voice"
? plansByType.DataSmsVoice
: activeTab === "data-only"
? plansByType.DataOnly
: plansByType.VoiceOnly;
const regularPlans = tabPlans.filter(p => !p.simHasFamilyDiscount);
const familyPlans = tabPlans.filter(p => p.simHasFamilyDiscount);
return { regularPlans, familyPlans };
};
const { regularPlans, familyPlans } = getCurrentPlans();
return (
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<div className="text-center pt-8 pb-8">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 mb-6">
<Sparkles className="w-4 h-4 text-primary" />
<span className="text-sm font-medium text-primary">Powered by NTT DOCOMO</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
Choose Your SIM Plan
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Get connected with Japan&apos;s best network coverage. Choose eSIM for quick digital
delivery or physical SIM shipped to your door.
</p>
</div>
{variant === "account" && hasExistingSim && (
<div className="space-y-4 mb-8">
<AlertBanner variant="success" title="Family Discount Available">
<p className="text-sm">
You already have a SIM subscription. Discounted pricing is automatically shown for
additional lines.
</p>
</AlertBanner>
</div>
)}
<ServiceHighlights features={simFeatures} className="mb-12" />
<div className="flex justify-center mb-8">
<div className="inline-flex rounded-xl bg-muted p-1 border border-border">
<button
type="button"
onClick={() => onTabChange("data-voice")}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "data-voice"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Phone className="h-4 w-4" />
<span className="hidden sm:inline">Data + Voice</span>
<span className="sm:hidden">All-in</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{plansByType.DataSmsVoice.length}
</span>
</button>
<button
type="button"
onClick={() => onTabChange("data-only")}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "data-only"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Globe className="h-4 w-4" />
<span className="hidden sm:inline">Data Only</span>
<span className="sm:hidden">Data</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{plansByType.DataOnly.length}
</span>
</button>
<button
type="button"
onClick={() => onTabChange("voice-only")}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "voice-only"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Check className="h-4 w-4" />
<span className="hidden sm:inline">Voice Only</span>
<span className="sm:hidden">Voice</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{plansByType.VoiceOnly.length}
</span>
</button>
</div>
</div>
<div id="plans" className="min-h-[300px]">
{regularPlans.length > 0 || familyPlans.length > 0 ? (
<div className="space-y-8 animate-in fade-in duration-300">
{regularPlans.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{regularPlans.map(plan => (
<SimPlanCardCompact key={plan.id} plan={plan} onSelect={onSelectPlan} />
))}
</div>
)}
{variant === "account" && hasExistingSim && familyPlans.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-4">
<Users className="h-5 w-5 text-success" />
<h3 className="text-lg font-semibold text-foreground">Family Discount Plans</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{familyPlans.map(plan => (
<SimPlanCardCompact
key={plan.id}
plan={plan}
isFamily
onSelect={onSelectPlan}
/>
))}
</div>
</div>
)}
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
No plans available in this category.
</div>
)}
</div>
<div className="mt-8 space-y-4">
<CollapsibleSection title="Calling & SMS Rates" icon={Phone}>
<div className="space-y-6 pt-4">
<div>
<h4 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<span className="w-5 h-3 rounded-sm bg-[#BC002D] relative overflow-hidden flex items-center justify-center">
<span className="w-2 h-2 rounded-full bg-white" />
</span>
Domestic (Japan)
</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="text-sm text-muted-foreground mb-1">Voice Calls</div>
<div className="text-xl font-bold text-foreground">
¥10<span className="text-sm font-normal text-muted-foreground">/30 sec</span>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="text-sm text-muted-foreground mb-1">SMS</div>
<div className="text-xl font-bold text-foreground">
¥3<span className="text-sm font-normal text-muted-foreground">/message</span>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage.
</p>
</div>
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
<div className="flex items-start gap-3">
<Phone className="w-5 h-5 text-success mt-0.5" />
<div>
<h4 className="font-medium text-foreground">Unlimited Domestic Calling</h4>
<p className="text-sm text-muted-foreground">
Add unlimited domestic calls for{" "}
<span className="font-semibold text-success">¥3,000/month</span> (available at
checkout)
</p>
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">
<p>
International calling rates vary by country (¥31-148/30 sec). See{" "}
<a
href="https://www.docomo.ne.jp/service/world/worldcall/call/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
NTT Docomo&apos;s website
</a>{" "}
for full details.
</p>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection title="Fees & Discounts" icon={CircleDollarSign}>
<div className="space-y-6 pt-4">
<div>
<h4 className="text-sm font-medium text-foreground mb-3">One-time Fees</h4>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-muted-foreground">Activation Fee</span>
<span className="font-medium text-foreground">¥1,500</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-muted-foreground">SIM Replacement (lost/damaged)</span>
<span className="font-medium text-foreground">¥1,500</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-muted-foreground">eSIM Re-download</span>
<span className="font-medium text-foreground">¥1,500</span>
</div>
</div>
</div>
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
<h4 className="font-medium text-foreground mb-2">Family Discount</h4>
<p className="text-sm text-muted-foreground">
<span className="font-semibold text-success">¥300/month off</span> per additional
Voice SIM on your account
</p>
</div>
<p className="text-xs text-muted-foreground">All prices exclude 10% consumption tax.</p>
</div>
</CollapsibleSection>
<CollapsibleSection title="Important Information & Terms" icon={Info}>
<div className="space-y-6 pt-4 text-sm">
<div>
<h4 className="font-medium text-foreground mb-3 flex items-center gap-2">
<TriangleAlert className="w-4 h-4 text-warning" />
Important Notices
</h4>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
ID verification with official documents (name, date of birth, address, photo) is
required during checkout.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
A compatible unlocked device is required. Check compatibility on our website.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
Service may not be available in areas with weak signal. See{" "}
<a
href="https://www.nttdocomo.co.jp/English/support/area/index.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
NTT Docomo coverage map
</a>
.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
SIM is activated as 4G by default. 5G can be requested via your account portal.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
International data roaming is not available. Voice/SMS roaming can be enabled
upon request (¥50,000/month limit).
</span>
</li>
</ul>
</div>
<div>
<h4 className="font-medium text-foreground mb-3">Contract Terms</h4>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">Minimum contract:</strong> 3 full billing
months. First month (sign-up to end of month) is free and doesn&apos;t count.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">Billing cycle:</strong> 1st to end of month.
Regular billing starts the 1st of the following month after sign-up.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">Cancellation:</strong> Can be requested
after 3rd month via cancellation form. Monthly fee is incurred in full for
cancellation month.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">SIM return:</strong> SIM card must be
returned after service termination.
</span>
</li>
</ul>
</div>
<div>
<h4 className="font-medium text-foreground mb-3">Additional Options</h4>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>Call waiting and voice mail available as separate paid options.</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>Data plan changes are free and take effect next billing month.</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
Voice plan changes require new SIM issuance and standard policies apply.
</span>
</li>
</ul>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-xs text-muted-foreground">
Payment is by credit card only. Data service is not suitable for activities
requiring continuous large data transfers. See full Terms of Service for complete
details.
</p>
</div>
</div>
</CollapsibleSection>
</div>
<div className="mt-8 text-center text-sm text-muted-foreground">
<p>
All prices exclude 10% consumption tax.{" "}
<a href="#" className="text-primary hover:underline">
View full Terms of Service
</a>
</p>
</div>
</div>
);
}
export default SimPlansContent;

View File

@ -2,7 +2,7 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useInternetCatalog, useInternetPlan } from "."; import { useAccountInternetCatalog } from ".";
import { useCatalogStore } from "../services/services.store"; import { useCatalogStore } from "../services/services.store";
import { useServicesBasePath } from "./useServicesBasePath"; import { useServicesBasePath } from "./useServicesBasePath";
import type { AccessModeValue } from "@customer-portal/domain/orders"; import type { AccessModeValue } from "@customer-portal/domain/orders";
@ -55,8 +55,12 @@ export function useInternetConfigure(): UseInternetConfigureResult {
const lastRestoredSignatureRef = useRef<string | null>(null); const lastRestoredSignatureRef = useRef<string | null>(null);
// Fetch services data from BFF // Fetch services data from BFF
const { data: internetData, isLoading: internetLoading } = useInternetCatalog(); const { data: internetData, isLoading: internetLoading } = useAccountInternetCatalog();
const { plan: selectedPlan } = useInternetPlan(configState.planSku || urlPlanSku || undefined); const selectedPlanSku = configState.planSku || urlPlanSku || undefined;
const selectedPlan = useMemo(() => {
if (!selectedPlanSku) return null;
return (internetData?.plans ?? []).find(p => p.sku === selectedPlanSku) ?? null;
}, [internetData?.plans, selectedPlanSku]);
// Initialize/restore state on mount // Initialize/restore state on mount
useEffect(() => { useEffect(() => {

View File

@ -6,57 +6,106 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/api"; import { queryKeys } from "@/lib/api";
import { servicesService } from "../services"; import { servicesService } from "../services";
import { useAuthSession } from "@/features/auth/services/auth.store";
type ServicesCatalogScope = "public" | "account";
function withScope<T extends readonly unknown[]>(key: T, scope: ServicesCatalogScope) {
return [...key, scope] as const;
}
/** /**
* Internet services composite hook * Internet catalog (public vs account)
* Fetches plans and installations together
*/ */
export function useInternetCatalog() { export function usePublicInternetCatalog() {
return useQuery({ return useQuery({
queryKey: queryKeys.services.internet.combined(), queryKey: withScope(queryKeys.services.internet.combined(), "public"),
queryFn: () => servicesService.getInternetCatalog(), queryFn: () => servicesService.getPublicInternetCatalog(),
});
}
export function useAccountInternetCatalog() {
const { isAuthenticated } = useAuthSession();
return useQuery({
queryKey: withScope(queryKeys.services.internet.combined(), "account"),
enabled: isAuthenticated,
queryFn: () => servicesService.getAccountInternetCatalog(),
}); });
} }
/** /**
* SIM services composite hook * SIM catalog (public vs account)
* Fetches plans, activation fees, and addons together
*/ */
export function useSimCatalog() { export function usePublicSimCatalog() {
return useQuery({ return useQuery({
queryKey: queryKeys.services.sim.combined(), queryKey: withScope(queryKeys.services.sim.combined(), "public"),
queryFn: () => servicesService.getSimCatalog(), queryFn: () => servicesService.getPublicSimCatalog(),
});
}
export function useAccountSimCatalog() {
const { isAuthenticated } = useAuthSession();
return useQuery({
queryKey: withScope(queryKeys.services.sim.combined(), "account"),
enabled: isAuthenticated,
queryFn: () => servicesService.getAccountSimCatalog(),
}); });
} }
/** /**
* VPN services hook * VPN catalog (public vs account)
* Fetches VPN plans and activation fees
*/ */
export function useVpnCatalog() { export function usePublicVpnCatalog() {
return useQuery({ return useQuery({
queryKey: queryKeys.services.vpn.combined(), queryKey: withScope(queryKeys.services.vpn.combined(), "public"),
queryFn: () => servicesService.getVpnCatalog(), queryFn: () => servicesService.getPublicVpnCatalog(),
});
}
export function useAccountVpnCatalog() {
const { isAuthenticated } = useAuthSession();
return useQuery({
queryKey: withScope(queryKeys.services.vpn.combined(), "account"),
enabled: isAuthenticated,
queryFn: () => servicesService.getAccountVpnCatalog(),
}); });
} }
/** /**
* Lookup helpers by SKU * Lookup helpers by SKU (explicit scope)
*/ */
export function useInternetPlan(sku?: string) { export function usePublicInternetPlan(sku?: string) {
const { data, ...rest } = useInternetCatalog(); const { data, ...rest } = usePublicInternetCatalog();
const plan = (data?.plans || []).find(p => p.sku === sku); const plan = (data?.plans || []).find(p => p.sku === sku);
return { plan, ...rest } as const; return { plan, ...rest } as const;
} }
export function useSimPlan(sku?: string) { export function useAccountInternetPlan(sku?: string) {
const { data, ...rest } = useSimCatalog(); const { data, ...rest } = useAccountInternetCatalog();
const plan = (data?.plans || []).find(p => p.sku === sku); const plan = (data?.plans || []).find(p => p.sku === sku);
return { plan, ...rest } as const; return { plan, ...rest } as const;
} }
export function useVpnPlan(sku?: string) { export function usePublicSimPlan(sku?: string) {
const { data, ...rest } = useVpnCatalog(); const { data, ...rest } = usePublicSimCatalog();
const plan = (data?.plans || []).find(p => p.sku === sku);
return { plan, ...rest } as const;
}
export function useAccountSimPlan(sku?: string) {
const { data, ...rest } = useAccountSimCatalog();
const plan = (data?.plans || []).find(p => p.sku === sku);
return { plan, ...rest } as const;
}
export function usePublicVpnPlan(sku?: string) {
const { data, ...rest } = usePublicVpnCatalog();
const plan = (data?.plans || []).find(p => p.sku === sku);
return { plan, ...rest } as const;
}
export function useAccountVpnPlan(sku?: string) {
const { data, ...rest } = useAccountVpnCatalog();
const plan = (data?.plans || []).find(p => p.sku === sku); const plan = (data?.plans || []).find(p => p.sku === sku);
return { plan, ...rest } as const; return { plan, ...rest } as const;
} }
@ -64,20 +113,20 @@ export function useVpnPlan(sku?: string) {
/** /**
* Addon/installation lookup helpers by SKU * Addon/installation lookup helpers by SKU
*/ */
export function useInternetInstallation(sku?: string) { export function useAccountInternetInstallation(sku?: string) {
const { data, ...rest } = useInternetCatalog(); const { data, ...rest } = useAccountInternetCatalog();
const installation = (data?.installations || []).find(i => i.sku === sku); const installation = (data?.installations || []).find(i => i.sku === sku);
return { installation, ...rest } as const; return { installation, ...rest } as const;
} }
export function useInternetAddon(sku?: string) { export function useAccountInternetAddon(sku?: string) {
const { data, ...rest } = useInternetCatalog(); const { data, ...rest } = useAccountInternetCatalog();
const addon = (data?.addons || []).find(a => a.sku === sku); const addon = (data?.addons || []).find(a => a.sku === sku);
return { addon, ...rest } as const; return { addon, ...rest } as const;
} }
export function useSimAddon(sku?: string) { export function useAccountSimAddon(sku?: string) {
const { data, ...rest } = useSimCatalog(); const { data, ...rest } = useAccountSimCatalog();
const addon = (data?.addons || []).find(a => a.sku === sku); const addon = (data?.addons || []).find(a => a.sku === sku);
return { addon, ...rest } as const; return { addon, ...rest } as const;
} }

View File

@ -2,7 +2,7 @@
import { useEffect, useCallback, useMemo, useRef } from "react"; import { useEffect, useCallback, useMemo, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useSimCatalog, useSimPlan } from "."; import { useAccountSimCatalog } from ".";
import { useCatalogStore } from "../services/services.store"; import { useCatalogStore } from "../services/services.store";
import { useServicesBasePath } from "./useServicesBasePath"; import { useServicesBasePath } from "./useServicesBasePath";
import { import {
@ -68,8 +68,12 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const lastRestoredSignatureRef = useRef<string | null>(null); const lastRestoredSignatureRef = useRef<string | null>(null);
// Fetch services data from BFF // Fetch services data from BFF
const { data: simData, isLoading: simLoading } = useSimCatalog(); const { data: simData, isLoading: simLoading } = useAccountSimCatalog();
const { plan: selectedPlan } = useSimPlan(configState.planSku || urlPlanSku || planId); const selectedPlanSku = configState.planSku || urlPlanSku || planId;
const selectedPlan = useMemo(() => {
if (!selectedPlanSku) return null;
return (simData?.plans ?? []).find(p => p.sku === selectedPlanSku) ?? null;
}, [simData?.plans, selectedPlanSku]);
// Initialize/restore state on mount // Initialize/restore state on mount
useEffect(() => { useEffect(() => {

View File

@ -21,8 +21,14 @@ import {
import type { Address } from "@customer-portal/domain/customer"; import type { Address } from "@customer-portal/domain/customer";
export const servicesService = { export const servicesService = {
async getInternetCatalog(): Promise<InternetCatalogCollection> { // ============================================================================
const response = await apiClient.GET<InternetCatalogCollection>("/api/services/internet/plans"); // Public (non-personalized) catalog endpoints
// ============================================================================
async getPublicInternetCatalog(): Promise<InternetCatalogCollection> {
const response = await apiClient.GET<InternetCatalogCollection>(
"/api/public/services/internet/plans"
);
const data = getDataOrThrow<InternetCatalogCollection>( const data = getDataOrThrow<InternetCatalogCollection>(
response, response,
"Failed to load internet services" "Failed to load internet services"
@ -30,6 +36,52 @@ export const servicesService = {
return data; // BFF already validated return data; // BFF already validated
}, },
/**
* @deprecated Use getPublicInternetCatalog() or getAccountInternetCatalog() for clear separation.
*/
async getInternetCatalog(): Promise<InternetCatalogCollection> {
return this.getPublicInternetCatalog();
},
async getPublicSimCatalog(): Promise<SimCatalogCollection> {
const response = await apiClient.GET<SimCatalogCollection>("/api/public/services/sim/plans");
const data = getDataOrDefault<SimCatalogCollection>(response, EMPTY_SIM_CATALOG);
return data; // BFF already validated
},
async getPublicVpnCatalog(): Promise<VpnCatalogCollection> {
const response = await apiClient.GET<VpnCatalogCollection>("/api/public/services/vpn/plans");
const data = getDataOrDefault<VpnCatalogCollection>(response, EMPTY_VPN_CATALOG);
return data; // BFF already validated
},
// ============================================================================
// Account (authenticated + personalized) catalog endpoints
// ============================================================================
async getAccountInternetCatalog(): Promise<InternetCatalogCollection> {
const response = await apiClient.GET<InternetCatalogCollection>(
"/api/account/services/internet/plans"
);
const data = getDataOrThrow<InternetCatalogCollection>(
response,
"Failed to load internet services"
);
return data; // BFF already validated
},
async getAccountSimCatalog(): Promise<SimCatalogCollection> {
const response = await apiClient.GET<SimCatalogCollection>("/api/account/services/sim/plans");
const data = getDataOrDefault<SimCatalogCollection>(response, EMPTY_SIM_CATALOG);
return data; // BFF already validated
},
async getAccountVpnCatalog(): Promise<VpnCatalogCollection> {
const response = await apiClient.GET<VpnCatalogCollection>("/api/account/services/vpn/plans");
const data = getDataOrDefault<VpnCatalogCollection>(response, EMPTY_VPN_CATALOG);
return data; // BFF already validated
},
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> { async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
const response = await apiClient.GET<InternetInstallationCatalogItem[]>( const response = await apiClient.GET<InternetInstallationCatalogItem[]>(
"/api/services/internet/installations" "/api/services/internet/installations"
@ -46,10 +98,11 @@ export const servicesService = {
return internetAddonCatalogItemSchema.array().parse(data); return internetAddonCatalogItemSchema.array().parse(data);
}, },
/**
* @deprecated Use getPublicSimCatalog() or getAccountSimCatalog() for clear separation.
*/
async getSimCatalog(): Promise<SimCatalogCollection> { async getSimCatalog(): Promise<SimCatalogCollection> {
const response = await apiClient.GET<SimCatalogCollection>("/api/services/sim/plans"); return this.getPublicSimCatalog();
const data = getDataOrDefault<SimCatalogCollection>(response, EMPTY_SIM_CATALOG);
return data; // BFF already validated
}, },
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> { async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
@ -66,10 +119,11 @@ export const servicesService = {
return simCatalogProductSchema.array().parse(data); return simCatalogProductSchema.array().parse(data);
}, },
/**
* @deprecated Use getPublicVpnCatalog() or getAccountVpnCatalog() for clear separation.
*/
async getVpnCatalog(): Promise<VpnCatalogCollection> { async getVpnCatalog(): Promise<VpnCatalogCollection> {
const response = await apiClient.GET<VpnCatalogCollection>("/api/services/vpn/plans"); return this.getPublicVpnCatalog();
const data = getDataOrDefault<VpnCatalogCollection>(response, EMPTY_VPN_CATALOG);
return data; // BFF already validated
}, },
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> { async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {

View File

@ -1,15 +1,59 @@
"use client"; "use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { useInternetConfigure } from "@/features/services/hooks/useInternetConfigure"; import { useInternetConfigure } from "@/features/services/hooks/useInternetConfigure";
import { useInternetEligibility } from "@/features/services/hooks/useInternetEligibility";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { InternetConfigureView as InternetConfigureInnerView } from "@/features/services/components/internet/InternetConfigureView"; import { InternetConfigureView as InternetConfigureInnerView } from "@/features/services/components/internet/InternetConfigureView";
import { Spinner } from "@/components/atoms/Spinner";
export function InternetConfigureContainer() { export function InternetConfigureContainer() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const servicesBasePath = useServicesBasePath();
const eligibilityQuery = useInternetEligibility();
const vm = useInternetConfigure(); const vm = useInternetConfigure();
// Keep /internet/configure strictly in the post-eligibility path.
useEffect(() => {
if (!pathname.startsWith("/account")) return;
if (!eligibilityQuery.isSuccess) return;
if (eligibilityQuery.data.status === "eligible") return;
router.replace(`${servicesBasePath}/internet`);
}, [
eligibilityQuery.data?.status,
eligibilityQuery.isSuccess,
pathname,
router,
servicesBasePath,
]);
if (pathname.startsWith("/account")) {
if (eligibilityQuery.isLoading) {
return (
<div className="max-w-2xl mx-auto py-12">
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)] text-center">
<Spinner className="mx-auto mb-4" />
<p className="text-sm text-muted-foreground">Checking availability</p>
</div>
</div>
);
}
if (eligibilityQuery.isSuccess && eligibilityQuery.data.status !== "eligible") {
return (
<div className="max-w-2xl mx-auto py-12">
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)] text-center">
<Spinner className="mx-auto mb-4" />
<p className="text-sm text-muted-foreground">Redirecting</p>
</div>
</div>
);
}
}
// Debug: log current state // Debug: log current state
logger.debug("InternetConfigure state", { logger.debug("InternetConfigure state", {
plan: vm.plan?.sku, plan: vm.plan?.sku,

View File

@ -0,0 +1,113 @@
"use client";
import { useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { CheckCircle, Clock } from "lucide-react";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { useInternetEligibility } from "@/features/services/hooks";
import { useAuthSession } from "@/features/auth/services/auth.store";
export function InternetEligibilityRequestSubmittedView() {
const servicesBasePath = useServicesBasePath();
const searchParams = useSearchParams();
const requestIdFromQuery = searchParams?.get("requestId")?.trim() || null;
const { user } = useAuthSession();
const eligibilityQuery = useInternetEligibility();
const addressLabel = useMemo(() => {
const a = user?.address;
if (!a) return "";
return [a.address1, a.address2, a.city, a.state, a.postcode, a.country || a.countryCode]
.filter(Boolean)
.map(part => String(part).trim())
.filter(part => part.length > 0)
.join(", ");
}, [user?.address]);
const requestId = requestIdFromQuery ?? eligibilityQuery.data?.requestId ?? null;
const status = eligibilityQuery.data?.status;
const isPending = status === "pending";
const isEligible = status === "eligible";
const isIneligible = status === "ineligible";
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 pb-20 pt-8">
<ServicesBackLink href={`${servicesBasePath}/internet`} label="Back to Internet" />
<div className="mt-8 bg-card border border-border rounded-2xl p-8 shadow-[var(--cp-shadow-1)]">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-success/10 border border-success/20 flex-shrink-0">
<CheckCircle className="h-6 w-6 text-success" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold text-foreground">Availability request submitted</h1>
<p className="text-sm text-muted-foreground mt-2">
We&apos;ll verify NTT service availability for your address. This typically takes 1-2
business days.
</p>
{addressLabel && (
<div className="mt-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Address on file
</div>
<div className="text-sm text-foreground mt-1">{addressLabel}</div>
</div>
)}
{requestId && (
<div className="mt-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Request ID
</div>
<div className="text-sm text-foreground mt-1">{requestId}</div>
</div>
)}
<div className="mt-6 flex flex-col sm:flex-row gap-3">
<Button as="a" href={`${servicesBasePath}/internet`} variant="default">
View Internet status
</Button>
<Button as="a" href="/account/settings" variant="outline">
Update address
</Button>
</div>
</div>
</div>
{(isPending || isEligible || isIneligible) && (
<div className="mt-6">
{isPending && (
<AlertBanner variant="info" title="Review in progress" elevated>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-info" />
<span>
We&apos;ll email you once our team completes the manual serviceability check.
</span>
</div>
</AlertBanner>
)}
{isEligible && (
<AlertBanner variant="success" title="Youre eligible" elevated>
Your address is eligible. You can now choose a plan and complete your order.
</AlertBanner>
)}
{isIneligible && (
<AlertBanner variant="warning" title="Service not available" elevated>
It looks like service isn&apos;t available at your address. Please contact support
if you think this is incorrect.
</AlertBanner>
)}
</div>
)}
</div>
</div>
);
}
export default InternetEligibilityRequestSubmittedView;

View File

@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Server, CheckCircle, Clock, TriangleAlert, MapPin } from "lucide-react"; import { Server, CheckCircle, Clock, TriangleAlert, MapPin } from "lucide-react";
import { useInternetCatalog } from "@/features/services/hooks"; import { useAccountInternetCatalog } from "@/features/services/hooks";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import type { import type {
InternetPlanCatalogItem, InternetPlanCatalogItem,
@ -119,6 +119,7 @@ function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): Ti
const config = tierDescriptions[tier]; const config = tierDescriptions[tier];
result.push({ result.push({
tier, tier,
planSku: plan.sku,
monthlyPrice: plan.monthlyPrice ?? 0, monthlyPrice: plan.monthlyPrice ?? 0,
description: config.description, description: config.description,
features: config.features, features: config.features,
@ -294,7 +295,7 @@ export function InternetPlansContainer() {
const servicesBasePath = useServicesBasePath(); const servicesBasePath = useServicesBasePath();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { user } = useAuthSession(); const { user } = useAuthSession();
const { data, isLoading, error } = useInternetCatalog(); const { data, isLoading, error } = useAccountInternetCatalog();
const eligibilityQuery = useInternetEligibility(); const eligibilityQuery = useInternetEligibility();
const eligibilityLoading = eligibilityQuery.isLoading; const eligibilityLoading = eligibilityQuery.isLoading;
const refetchEligibility = eligibilityQuery.refetch; const refetchEligibility = eligibilityQuery.refetch;
@ -401,7 +402,18 @@ export function InternetPlansContainer() {
window.confirm(`Request availability check for:\n\n${addressLabel}`); window.confirm(`Request availability check for:\n\n${addressLabel}`);
if (!confirmed) return; if (!confirmed) return;
eligibilityRequest.mutate({ address: user?.address ?? undefined }); setAutoRequestId(null);
setAutoRequestStatus("submitting");
try {
const result = await submitEligibilityRequest({ address: user?.address ?? undefined });
setAutoRequestId(result.requestId ?? null);
setAutoRequestStatus("submitted");
await refetchEligibility();
const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : "";
router.push(`${servicesBasePath}/internet/request-submitted${query}`);
} catch {
setAutoRequestStatus("failed");
}
}; };
// Auto eligibility request effect // Auto eligibility request effect
@ -432,11 +444,13 @@ export function InternetPlansContainer() {
setAutoRequestId(result.requestId ?? null); setAutoRequestId(result.requestId ?? null);
setAutoRequestStatus("submitted"); setAutoRequestStatus("submitted");
await refetchEligibility(); await refetchEligibility();
const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : "";
router.replace(`${servicesBasePath}/internet/request-submitted${query}`);
return;
} catch { } catch {
setAutoRequestStatus("failed"); setAutoRequestStatus("failed");
} finally {
router.replace(`${servicesBasePath}/internet`);
} }
router.replace(`${servicesBasePath}/internet`);
}; };
void submit(); void submit();

View File

@ -5,7 +5,7 @@ import { WifiIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon } from "@heroicons/r
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { useInternetPlan } from "@/features/services/hooks"; import { usePublicInternetPlan } from "@/features/services/hooks";
import { CardPricing } from "@/features/services/components/base/CardPricing"; import { CardPricing } from "@/features/services/components/base/CardPricing";
import { Skeleton } from "@/components/atoms/loading-skeleton"; import { Skeleton } from "@/components/atoms/loading-skeleton";
@ -18,7 +18,7 @@ export function PublicInternetConfigureView() {
const servicesBasePath = useServicesBasePath(); const servicesBasePath = useServicesBasePath();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const planSku = searchParams?.get("planSku"); const planSku = searchParams?.get("planSku");
const { plan, isLoading } = useInternetPlan(planSku || undefined); const { plan, isLoading } = usePublicInternetPlan(planSku || undefined);
const redirectTo = planSku const redirectTo = planSku
? `/account/services/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}` ? `/account/services/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}`

View File

@ -13,7 +13,7 @@ import {
Wrench, Wrench,
Globe, Globe,
} from "lucide-react"; } from "lucide-react";
import { useInternetCatalog } from "@/features/services/hooks"; import { usePublicInternetCatalog } from "@/features/services/hooks";
import type { import type {
InternetPlanCatalogItem, InternetPlanCatalogItem,
InternetInstallationCatalogItem, InternetInstallationCatalogItem,
@ -133,7 +133,7 @@ export function PublicInternetPlansContent({
heroTitle = "Internet Service Plans", heroTitle = "Internet Service Plans",
heroDescription = "NTT Optical Fiber with full English support", heroDescription = "NTT Optical Fiber with full English support",
}: PublicInternetPlansContentProps) { }: PublicInternetPlansContentProps) {
const { data: servicesCatalog, isLoading, error } = useInternetCatalog(); const { data: servicesCatalog, isLoading, error } = usePublicInternetCatalog();
const servicesBasePath = useServicesBasePath(); const servicesBasePath = useServicesBasePath();
const defaultCtaPath = `${servicesBasePath}/internet/configure`; const defaultCtaPath = `${servicesBasePath}/internet/configure`;
const ctaPath = propCtaPath ?? defaultCtaPath; const ctaPath = propCtaPath ?? defaultCtaPath;

View File

@ -5,7 +5,7 @@ import { DevicePhoneMobileIcon, CheckIcon, BoltIcon } from "@heroicons/react/24/
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { useSimPlan } from "@/features/services/hooks"; import { usePublicSimPlan } from "@/features/services/hooks";
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
import { CardPricing } from "@/features/services/components/base/CardPricing"; import { CardPricing } from "@/features/services/components/base/CardPricing";
import { Skeleton } from "@/components/atoms/loading-skeleton"; import { Skeleton } from "@/components/atoms/loading-skeleton";
@ -20,7 +20,7 @@ export function PublicSimConfigureView() {
const servicesBasePath = useServicesBasePath(); const servicesBasePath = useServicesBasePath();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const planSku = searchParams?.get("planSku"); const planSku = searchParams?.get("planSku");
const { plan, isLoading } = useSimPlan(planSku || undefined); const { plan, isLoading } = usePublicSimPlan(planSku || undefined);
const redirectTarget = planSku const redirectTarget = planSku
? `/account/services/sim/configure?planSku=${encodeURIComponent(planSku)}` ? `/account/services/sim/configure?planSku=${encodeURIComponent(planSku)}`

View File

@ -1,554 +1,42 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import { useRouter } from "next/navigation";
Smartphone,
Check,
Phone,
Globe,
ArrowLeft,
Signal,
Sparkles,
ChevronDown,
Info,
CircleDollarSign,
TriangleAlert,
CreditCard,
Calendar,
ArrowRightLeft,
ArrowRight,
} from "lucide-react";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { Button } from "@/components/atoms/button";
import { useSimCatalog } from "@/features/services/hooks";
import type { SimCatalogProduct } from "@customer-portal/domain/services"; import type { SimCatalogProduct } from "@customer-portal/domain/services";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { usePublicSimCatalog } from "@/features/services/hooks";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { CardPricing } from "@/features/services/components/base/CardPricing";
import { import {
ServiceHighlights, SimPlansContent,
HighlightFeature, type SimPlansTab,
} from "@/features/services/components/base/ServiceHighlights"; } from "@/features/services/components/sim/SimPlansContent";
interface PlansByType {
DataOnly: SimCatalogProduct[];
DataSmsVoice: SimCatalogProduct[];
VoiceOnly: SimCatalogProduct[];
}
// Collapsible section component
function CollapsibleSection({
title,
icon: Icon,
defaultOpen = false,
children,
}: {
title: string;
icon: React.ElementType;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border border-border rounded-xl overflow-hidden bg-card">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-primary" />
<span className="font-medium text-foreground">{title}</span>
</div>
<ChevronDown
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
</button>
<div
className={`overflow-hidden transition-all duration-300 ${isOpen ? "max-h-[2000px]" : "max-h-0"}`}
>
<div className="p-4 pt-0 border-t border-border">{children}</div>
</div>
</div>
);
}
// Compact plan card component for a cleaner grid
function SimPlanCardCompact({
plan,
onSelect,
}: {
plan: SimCatalogProduct;
onSelect: (sku: string) => void;
}) {
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
return (
<div className="group relative bg-card border border-border rounded-2xl p-6 shadow-sm hover:shadow-lg hover:border-primary transition-all duration-300 transform hover:-translate-y-1 h-full flex flex-col">
{/* Data Size Badge */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center group-hover:bg-primary group-hover:text-primary-foreground transition-colors duration-300">
<Signal className="w-6 h-6 text-primary group-hover:text-primary-foreground" />
</div>
<span className="text-xl font-bold text-foreground">{plan.simDataSize}</span>
</div>
</div>
{/* Price */}
<div className="mb-5">
<CardPricing monthlyPrice={displayPrice} size="md" alignment="left" />
</div>
{/* Plan name */}
<p className="text-sm text-muted-foreground mb-6 line-clamp-2 flex-grow">{plan.name}</p>
{/* CTA */}
<Button
className="w-full mt-auto"
variant="outline"
onClick={() => onSelect(plan.sku)}
rightIcon={
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-1" />
}
>
Select Plan
</Button>
</div>
);
}
/** /**
* Public SIM Plans View * Public SIM Plans View
* *
* Displays SIM plans for unauthenticated users. * Displays SIM plans for unauthenticated users.
* Clean, focused design with plan selection. * Uses the shared plans UI, with a public navigation handler.
*/ */
export function PublicSimPlansView() { export function PublicSimPlansView() {
const router = useRouter();
const servicesBasePath = useServicesBasePath(); const servicesBasePath = useServicesBasePath();
const { data, isLoading, error } = useSimCatalog(); const { data, isLoading, error } = usePublicSimCatalog();
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice");
"data-voice"
);
const handleSelectPlan = (planSku: string) => { const handleSelectPlan = (planSku: string) => {
window.location.href = `${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`; router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`);
}; };
const simFeatures: HighlightFeature[] = [
{
icon: <Signal className="h-6 w-6" />,
title: "NTT Docomo Network",
description: "Best area coverage among the main three carriers in Japan",
highlight: "Nationwide coverage",
},
{
icon: <CircleDollarSign className="h-6 w-6" />,
title: "First Month Free",
description: "Basic fee waived on signup to get you started risk-free",
highlight: "Great value",
},
{
icon: <CreditCard className="h-6 w-6" />,
title: "Foreign Cards Accepted",
description: "We accept both foreign and Japanese credit cards",
highlight: "No hassle",
},
{
icon: <Calendar className="h-6 w-6" />,
title: "No Binding Contract",
description: "Minimum 4 months service (1st month free + 3 billing months)",
highlight: "Flexible contract",
},
{
icon: <ArrowRightLeft className="h-6 w-6" />,
title: "Number Portability",
description: "Easily switch to us keeping your current Japanese number",
highlight: "Keep your number",
},
{
icon: <Smartphone className="h-6 w-6" />,
title: "Free Plan Changes",
description: "Switch data plans anytime for the next billing cycle",
highlight: "Flexibility",
},
];
if (isLoading) {
return ( return (
<div className="max-w-6xl mx-auto px-4 pb-16"> <SimPlansContent
<ServicesBackLink href={servicesBasePath} label="Back to Services" /> variant="public"
<div className="text-center mb-12 pt-8"> plans={plans}
<Skeleton className="h-10 w-80 mx-auto mb-4" /> isLoading={isLoading}
<Skeleton className="h-6 w-96 max-w-full mx-auto" /> error={error}
</div> activeTab={activeTab}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5"> onTabChange={setActiveTab}
{Array.from({ length: 4 }).map((_, i) => ( onSelectPlan={handleSelectPlan}
<div key={i} className="bg-card rounded-2xl border border-border p-5 space-y-4"> />
<Skeleton className="h-10 w-24" />
<Skeleton className="h-6 w-20" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
</div>
);
}
if (error) {
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred";
return (
<div className="max-w-6xl mx-auto px-4">
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-8 text-center">
<div className="text-destructive font-medium text-lg mb-2">Failed to load SIM plans</div>
<div className="text-destructive/80 text-sm mb-6">{errorMessage}</div>
<Button as="a" href={servicesBasePath} leftIcon={<ArrowLeft className="w-4 h-4" />}>
Back to Services
</Button>
</div>
</div>
);
}
const plansByType = simPlans.reduce<PlansByType>(
(acc, plan) => {
const planType = plan.simPlanType || "DataOnly";
if (planType === "DataOnly") acc.DataOnly.push(plan);
else if (planType === "VoiceOnly") acc.VoiceOnly.push(plan);
else acc.DataSmsVoice.push(plan);
return acc;
},
{ DataOnly: [], DataSmsVoice: [], VoiceOnly: [] }
);
const currentPlans =
activeTab === "data-voice"
? plansByType.DataSmsVoice
: activeTab === "data-only"
? plansByType.DataOnly
: plansByType.VoiceOnly;
return (
<div className="max-w-6xl mx-auto px-4 pb-16">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
{/* Hero Section - Clean & Minimal */}
<div className="text-center pt-8 pb-6">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 mb-6">
<Sparkles className="w-4 h-4 text-primary" />
<span className="text-sm font-medium text-primary">Powered by NTT DOCOMO</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-4">Mobile SIM Plans</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Get connected with Japan's best network coverage. Choose eSIM for quick digital delivery
or physical SIM shipped to your door.
</p>
</div>
{/* Service Highlights */}
<ServiceHighlights features={simFeatures} className="mb-12" />
{/* Plan Type Tabs */}
<div className="flex justify-center mb-8 mt-6">
<div className="inline-flex rounded-xl bg-muted p-1 border border-border">
<button
onClick={() => setActiveTab("data-voice")}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "data-voice"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Phone className="h-4 w-4" />
<span className="hidden sm:inline">Data + Voice</span>
<span className="sm:hidden">All-in</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{plansByType.DataSmsVoice.length}
</span>
</button>
<button
onClick={() => setActiveTab("data-only")}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "data-only"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Globe className="h-4 w-4" />
<span className="hidden sm:inline">Data Only</span>
<span className="sm:hidden">Data</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{plansByType.DataOnly.length}
</span>
</button>
<button
onClick={() => setActiveTab("voice-only")}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "voice-only"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Check className="h-4 w-4" />
<span className="hidden sm:inline">Voice Only</span>
<span className="sm:hidden">Voice</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{plansByType.VoiceOnly.length}
</span>
</button>
</div>
</div>
{/* Plan Cards Grid */}
<div id="plans" className="min-h-[300px]">
{currentPlans.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 animate-in fade-in duration-300">
{currentPlans.map(plan => (
<SimPlanCardCompact key={plan.id} plan={plan} onSelect={handleSelectPlan} />
))}
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
No plans available in this category.
</div>
)}
</div>
{/* Collapsible Information Sections */}
<div className="mt-12 space-y-4">
{/* Calling & SMS Rates */}
<CollapsibleSection title="Calling & SMS Rates" icon={Phone}>
<div className="space-y-6 pt-4">
{/* Domestic Rates */}
<div>
<h4 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<span className="w-5 h-3 rounded-sm bg-[#BC002D] relative overflow-hidden flex items-center justify-center">
<span className="w-2 h-2 rounded-full bg-white" />
</span>
Domestic (Japan)
</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="text-sm text-muted-foreground mb-1">Voice Calls</div>
<div className="text-xl font-bold text-foreground">
¥10<span className="text-sm font-normal text-muted-foreground">/30 sec</span>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="text-sm text-muted-foreground mb-1">SMS</div>
<div className="text-xl font-bold text-foreground">
¥3<span className="text-sm font-normal text-muted-foreground">/message</span>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage.
</p>
</div>
{/* Unlimited Option */}
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
<div className="flex items-start gap-3">
<Phone className="w-5 h-5 text-success mt-0.5" />
<div>
<h4 className="font-medium text-foreground">Unlimited Domestic Calling</h4>
<p className="text-sm text-muted-foreground">
Add unlimited domestic calls for{" "}
<span className="font-semibold text-success">¥3,000/month</span> (available at
checkout)
</p>
</div>
</div>
</div>
{/* International Note */}
<div className="text-sm text-muted-foreground">
<p>
International calling rates vary by country (¥31-148/30 sec). See{" "}
<a
href="https://www.docomo.ne.jp/service/world/worldcall/call/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
NTT Docomo's website
</a>{" "}
for full details.
</p>
</div>
</div>
</CollapsibleSection>
{/* Fees & Discounts */}
<CollapsibleSection title="Fees & Discounts" icon={CircleDollarSign}>
<div className="space-y-6 pt-4">
{/* Fees */}
<div>
<h4 className="text-sm font-medium text-foreground mb-3">One-time Fees</h4>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-muted-foreground">Activation Fee</span>
<span className="font-medium text-foreground">¥1,500</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-muted-foreground">SIM Replacement (lost/damaged)</span>
<span className="font-medium text-foreground">¥1,500</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-muted-foreground">eSIM Re-download</span>
<span className="font-medium text-foreground">¥1,500</span>
</div>
</div>
</div>
{/* Discounts */}
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
<h4 className="font-medium text-foreground mb-2">Family Discount</h4>
<p className="text-sm text-muted-foreground">
<span className="font-semibold text-success">¥300/month off</span> per additional
Voice SIM on your account
</p>
</div>
<p className="text-xs text-muted-foreground">All prices exclude 10% consumption tax.</p>
</div>
</CollapsibleSection>
{/* Important Information & Terms */}
<CollapsibleSection title="Important Information & Terms" icon={Info}>
<div className="space-y-6 pt-4 text-sm">
{/* Key Notices */}
<div>
<h4 className="font-medium text-foreground mb-3 flex items-center gap-2">
<TriangleAlert className="w-4 h-4 text-warning" />
Important Notices
</h4>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
ID verification with official documents (name, date of birth, address, photo) is
required during checkout.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
A compatible unlocked device is required. Check compatibility on our website.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
Service may not be available in areas with weak signal. See{" "}
<a
href="https://www.nttdocomo.co.jp/English/support/area/index.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
NTT Docomo coverage map
</a>
.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
SIM is activated as 4G by default. 5G can be requested via your account portal.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
International data roaming is not available. Voice/SMS roaming can be enabled
upon request (¥50,000/month limit).
</span>
</li>
</ul>
</div>
{/* Contract Terms */}
<div>
<h4 className="font-medium text-foreground mb-3">Contract Terms</h4>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">Minimum contract:</strong> 3 full billing
months. First month (sign-up to end of month) is free and doesn't count.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">Billing cycle:</strong> 1st to end of month.
Regular billing starts the 1st of the following month after sign-up.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">Cancellation:</strong> Can be requested
after 3rd month via cancellation form. Monthly fee is incurred in full for
cancellation month.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">SIM return:</strong> SIM card must be
returned after service termination.
</span>
</li>
</ul>
</div>
{/* Additional Options */}
<div>
<h4 className="font-medium text-foreground mb-3">Additional Options</h4>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>Call waiting and voice mail available as separate paid options.</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>Data plan changes are free and take effect next billing month.</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
Voice plan changes require new SIM issuance and standard policies apply.
</span>
</li>
</ul>
</div>
{/* Disclaimer */}
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-xs text-muted-foreground">
Payment is by credit card only. Data service is not suitable for activities
requiring continuous large data transfers. See full Terms of Service for complete
details.
</p>
</div>
</div>
</CollapsibleSection>
</div>
{/* Terms Footer */}
<div className="mt-8 text-center text-sm text-muted-foreground">
<p>
All prices exclude 10% consumption tax.{" "}
<a href="#" className="text-primary hover:underline">
View full Terms of Service
</a>
</p>
</div>
</div>
); );
} }

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { ShieldCheck, Zap } from "lucide-react"; import { ShieldCheck, Zap } from "lucide-react";
import { useVpnCatalog } from "@/features/services/hooks"; import { usePublicVpnCatalog } from "@/features/services/hooks";
import { LoadingCard } from "@/components/atoms"; import { LoadingCard } from "@/components/atoms";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
@ -17,7 +17,7 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa
*/ */
export function PublicVpnPlansView() { export function PublicVpnPlansView() {
const servicesBasePath = useServicesBasePath(); const servicesBasePath = useServicesBasePath();
const { data, isLoading, error } = useVpnCatalog(); const { data, isLoading, error } = usePublicVpnCatalog();
const vpnPlans = data?.plans || []; const vpnPlans = data?.plans || [];
const activationFees = data?.activationFees || []; const activationFees = data?.activationFees || [];

View File

@ -1,616 +1,42 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import {
Smartphone,
Check,
Phone,
Globe,
ArrowLeft,
Signal,
Sparkles,
CreditCard,
ChevronDown,
Info,
CircleDollarSign,
TriangleAlert,
Calendar,
ArrowRightLeft,
ArrowRight,
Users,
} from "lucide-react";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { useSimCatalog } from "@/features/services/hooks";
import type { SimCatalogProduct } from "@customer-portal/domain/services";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import { CardPricing } from "@/features/services/components/base/CardPricing";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { SimCatalogProduct } from "@customer-portal/domain/services";
import { useAccountSimCatalog } from "@/features/services/hooks";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { import {
ServiceHighlights, SimPlansContent,
HighlightFeature, type SimPlansTab,
} from "@/features/services/components/base/ServiceHighlights"; } from "@/features/services/components/sim/SimPlansContent";
interface PlansByType {
DataOnly: SimCatalogProduct[];
DataSmsVoice: SimCatalogProduct[];
VoiceOnly: SimCatalogProduct[];
}
// Collapsible section component
function CollapsibleSection({
title,
icon: Icon,
defaultOpen = false,
children,
}: {
title: string;
icon: React.ElementType;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border border-border rounded-xl overflow-hidden bg-card">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<Icon className="w-5 h-5 text-primary" />
<span className="font-medium text-foreground">{title}</span>
</div>
<ChevronDown
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
/>
</button>
<div
className={`overflow-hidden transition-all duration-300 ${isOpen ? "max-h-[2000px]" : "max-h-0"}`}
>
<div className="p-4 pt-0 border-t border-border">{children}</div>
</div>
</div>
);
}
// Compact plan card component
function SimPlanCardCompact({
plan,
isFamily,
onSelect,
}: {
plan: SimCatalogProduct;
isFamily?: boolean;
onSelect: (sku: string) => void;
}) {
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
return (
<div
className={`group relative bg-card rounded-2xl p-5 transition-all duration-200 ${
isFamily
? "border-2 border-success/50 hover:border-success hover:shadow-[var(--cp-shadow-2)]"
: "border border-border hover:border-primary/50 hover:shadow-[var(--cp-shadow-2)]"
}`}
>
{/* Family Discount Badge */}
{isFamily && (
<div className="absolute -top-3 left-4 flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-success text-success-foreground text-xs font-medium">
<Users className="w-3.5 h-3.5" />
Family Discount
</div>
)}
{/* Data Size Badge */}
<div className="flex items-center justify-between mb-4 mt-1">
<div className="flex items-center gap-2">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center ${isFamily ? "bg-success/10" : "bg-primary/10"}`}
>
<Signal className={`w-5 h-5 ${isFamily ? "text-success" : "text-primary"}`} />
</div>
<span className="text-lg font-bold text-foreground">{plan.simDataSize}</span>
</div>
</div>
{/* Price */}
<div className="mb-4">
<CardPricing monthlyPrice={displayPrice} size="sm" alignment="left" />
{isFamily && (
<div className="text-xs text-success font-medium mt-1">Discounted price applied</div>
)}
</div>
{/* Plan name */}
<p className="text-sm text-muted-foreground mb-5 line-clamp-2">{plan.name}</p>
{/* CTA */}
<Button
className={`w-full ${isFamily ? "group-hover:bg-success group-hover:text-success-foreground" : "group-hover:bg-primary group-hover:text-primary-foreground"}`}
variant="outline"
onClick={() => onSelect(plan.sku)}
rightIcon={<ArrowRight className="w-4 h-4" />}
>
Select Plan
</Button>
</div>
);
}
/**
* Account SIM Plans Container
*
* Fetches account context (payment methods + personalised catalog) and
* renders the shared SIM plans UI.
*/
export function SimPlansContainer() { export function SimPlansContainer() {
const servicesBasePath = useServicesBasePath();
const router = useRouter(); const router = useRouter();
const { data, isLoading, error } = useSimCatalog(); const servicesBasePath = useServicesBasePath();
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); const { data, isLoading, error } = useAccountSimCatalog();
const [hasExistingSim, setHasExistingSim] = useState(false); const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice");
"data-voice"
);
const { data: paymentMethods, isLoading: paymentMethodsLoading } = usePaymentMethods();
useEffect(() => {
setHasExistingSim(simPlans.some(p => p.simHasFamilyDiscount));
}, [simPlans]);
const handleSelectPlan = (planSku: string) => { const handleSelectPlan = (planSku: string) => {
router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`); router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`);
}; };
const simFeatures: HighlightFeature[] = [
{
icon: <Signal className="h-6 w-6" />,
title: "NTT Docomo Network",
description: "Best area coverage among the main three carriers in Japan",
highlight: "Nationwide coverage",
},
{
icon: <CircleDollarSign className="h-6 w-6" />,
title: "First Month Free",
description: "Basic fee waived on signup to get you started risk-free",
highlight: "Great value",
},
{
icon: <CreditCard className="h-6 w-6" />,
title: "Foreign Cards Accepted",
description: "We accept both foreign and Japanese credit cards",
highlight: "No hassle",
},
{
icon: <Calendar className="h-6 w-6" />,
title: "No Binding Contract",
description: "Minimum 4 months service (1st month free + 3 billing months)",
highlight: "Flexible contract",
},
{
icon: <ArrowRightLeft className="h-6 w-6" />,
title: "Number Portability",
description: "Easily switch to us keeping your current Japanese number",
highlight: "Keep your number",
},
{
icon: <Smartphone className="h-6 w-6" />,
title: "Free Plan Changes",
description: "Switch data plans anytime for the next billing cycle",
highlight: "Flexibility",
},
];
if (isLoading) {
return ( return (
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8"> <SimPlansContent
<ServicesBackLink href={servicesBasePath} label="Back to Services" /> variant="account"
<div className="text-center mb-12 pt-8"> plans={plans}
<Skeleton className="h-10 w-80 mx-auto mb-4" /> isLoading={isLoading}
<Skeleton className="h-6 w-96 max-w-full mx-auto" /> error={error}
</div> activeTab={activeTab}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5"> onTabChange={setActiveTab}
{Array.from({ length: 4 }).map((_, i) => ( onSelectPlan={handleSelectPlan}
<div key={i} className="bg-card rounded-2xl border border-border p-5">
<div className="flex items-center justify-between mb-6 mt-1">
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-10 rounded-xl" />
<Skeleton className="h-6 w-16" />
</div>
</div>
<div className="mb-6 space-y-2">
<Skeleton className="h-8 w-32" />
</div>
<div className="space-y-2 mb-6">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<Skeleton className="h-10 w-full rounded-md" />
</div>
))}
</div>
</div>
);
}
if (error) {
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred";
return (
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-8 text-center mt-8">
<div className="text-destructive font-medium text-lg mb-2">Failed to load SIM plans</div>
<div className="text-destructive/80 text-sm mb-6">{errorMessage}</div>
<Button as="a" href={servicesBasePath} leftIcon={<ArrowLeft className="w-4 h-4" />}>
Back to Services
</Button>
</div>
</div>
);
}
const plansByType = simPlans.reduce<PlansByType>(
(acc, plan) => {
const planType = plan.simPlanType || "DataOnly";
if (planType === "DataOnly") acc.DataOnly.push(plan);
else if (planType === "VoiceOnly") acc.VoiceOnly.push(plan);
else acc.DataSmsVoice.push(plan);
return acc;
},
{ DataOnly: [], DataSmsVoice: [], VoiceOnly: [] }
);
const getCurrentPlans = () => {
const plans =
activeTab === "data-voice"
? plansByType.DataSmsVoice
: activeTab === "data-only"
? plansByType.DataOnly
: plansByType.VoiceOnly;
const regularPlans = plans.filter(p => !p.simHasFamilyDiscount);
const familyPlans = plans.filter(p => p.simHasFamilyDiscount);
return { regularPlans, familyPlans };
};
const { regularPlans, familyPlans } = getCurrentPlans();
return (
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
{/* Hero Section */}
<div className="text-center pt-8 pb-8">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 mb-6">
<Sparkles className="w-4 h-4 text-primary" />
<span className="text-sm font-medium text-primary">Powered by NTT DOCOMO</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
Choose Your SIM Plan
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
Get connected with Japan's best network coverage. Choose eSIM for quick digital delivery
or physical SIM shipped to your door.
</p>
</div>
{/* Requirements Banners */}
<div className="space-y-4 mb-8">
{paymentMethodsLoading ? (
<AlertBanner variant="info" title="Checking requirements…">
<p className="text-sm text-foreground/80">Loading your payment method status.</p>
</AlertBanner>
) : (
<>
{paymentMethods && paymentMethods.totalCount === 0 && (
<AlertBanner variant="warning" title="Add a payment method to order SIM">
<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>
)}
</>
)}
{hasExistingSim && (
<AlertBanner variant="success" title="Family Discount Available">
<p className="text-sm">
You already have a SIM subscription. Discounted pricing is automatically shown for
additional lines.
</p>
</AlertBanner>
)}
</div>
{/* Service Highlights (Shared with Public View) */}
<ServiceHighlights features={simFeatures} className="mb-12" />
{/* Plan Type Tabs */}
<div className="flex justify-center mb-8">
<div className="inline-flex rounded-xl bg-muted p-1 border border-border">
<button
onClick={() => setActiveTab("data-voice")}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "data-voice"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Phone className="h-4 w-4" />
<span className="hidden sm:inline">Data + Voice</span>
<span className="sm:hidden">All-in</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{plansByType.DataSmsVoice.length}
</span>
</button>
<button
onClick={() => setActiveTab("data-only")}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "data-only"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Globe className="h-4 w-4" />
<span className="hidden sm:inline">Data Only</span>
<span className="sm:hidden">Data</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{plansByType.DataOnly.length}
</span>
</button>
<button
onClick={() => setActiveTab("voice-only")}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "voice-only"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Check className="h-4 w-4" />
<span className="hidden sm:inline">Voice Only</span>
<span className="sm:hidden">Voice</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
{plansByType.VoiceOnly.length}
</span>
</button>
</div>
</div>
{/* Plan Cards Grid */}
<div id="plans" className="min-h-[300px]">
{regularPlans.length > 0 || familyPlans.length > 0 ? (
<div className="space-y-8 animate-in fade-in duration-300">
{/* Regular Plans */}
{regularPlans.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{regularPlans.map(plan => (
<SimPlanCardCompact key={plan.id} plan={plan} onSelect={handleSelectPlan} />
))}
</div>
)}
{/* Family Discount Plans */}
{hasExistingSim && familyPlans.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-4">
<Users className="h-5 w-5 text-success" />
<h3 className="text-lg font-semibold text-foreground">Family Discount Plans</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{familyPlans.map(plan => (
<SimPlanCardCompact
key={plan.id}
plan={plan}
isFamily
onSelect={handleSelectPlan}
/> />
))}
</div>
</div>
)}
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
No plans available in this category.
</div>
)}
</div>
{/* Collapsible Information Sections */}
<div className="mt-8 space-y-4">
{/* Calling & SMS Rates */}
<CollapsibleSection title="Calling & SMS Rates" icon={Phone}>
<div className="space-y-6 pt-4">
{/* Domestic Rates */}
<div>
<h4 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<span className="w-5 h-3 rounded-sm bg-[#BC002D] relative overflow-hidden flex items-center justify-center">
<span className="w-2 h-2 rounded-full bg-white" />
</span>
Domestic (Japan)
</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="text-sm text-muted-foreground mb-1">Voice Calls</div>
<div className="text-xl font-bold text-foreground">
¥10<span className="text-sm font-normal text-muted-foreground">/30 sec</span>
</div>
</div>
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<div className="text-sm text-muted-foreground mb-1">SMS</div>
<div className="text-xl font-bold text-foreground">
¥3<span className="text-sm font-normal text-muted-foreground">/message</span>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage.
</p>
</div>
{/* Unlimited Option */}
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
<div className="flex items-start gap-3">
<Phone className="w-5 h-5 text-success mt-0.5" />
<div>
<h4 className="font-medium text-foreground">Unlimited Domestic Calling</h4>
<p className="text-sm text-muted-foreground">
Add unlimited domestic calls for{" "}
<span className="font-semibold text-success">¥3,000/month</span> (available at
checkout)
</p>
</div>
</div>
</div>
{/* International Note */}
<div className="text-sm text-muted-foreground">
<p>
International calling rates vary by country (¥31-148/30 sec). See{" "}
<a
href="https://www.docomo.ne.jp/service/world/worldcall/call/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
NTT Docomo's website
</a>{" "}
for full details.
</p>
</div>
</div>
</CollapsibleSection>
{/* Fees & Discounts */}
<CollapsibleSection title="Fees & Discounts" icon={CircleDollarSign}>
<div className="space-y-6 pt-4">
{/* Fees */}
<div>
<h4 className="text-sm font-medium text-foreground mb-3">One-time Fees</h4>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-muted-foreground">Activation Fee</span>
<span className="font-medium text-foreground">¥1,500</span>
</div>
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-muted-foreground">SIM Replacement (lost/damaged)</span>
<span className="font-medium text-foreground">¥1,500</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-muted-foreground">eSIM Re-download</span>
<span className="font-medium text-foreground">¥1,500</span>
</div>
</div>
</div>
{/* Discounts */}
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
<h4 className="font-medium text-foreground mb-2">Family Discount</h4>
<p className="text-sm text-muted-foreground">
<span className="font-semibold text-success">¥300/month off</span> per additional
Voice SIM on your account
</p>
</div>
<p className="text-xs text-muted-foreground">All prices exclude 10% consumption tax.</p>
</div>
</CollapsibleSection>
{/* Important Information & Terms */}
<CollapsibleSection title="Important Information & Terms" icon={Info}>
<div className="space-y-6 pt-4 text-sm">
{/* Key Notices */}
<div>
<h4 className="font-medium text-foreground mb-3 flex items-center gap-2">
<TriangleAlert className="w-4 h-4 text-warning" />
Important Notices
</h4>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>ID verification with official documents is required during checkout.</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
A compatible unlocked device is required. Check compatibility on our website.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
SIM is activated as 4G by default. 5G can be requested via your account portal.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
International data roaming is not available. Voice/SMS roaming can be enabled
upon request.
</span>
</li>
</ul>
</div>
{/* Contract Terms */}
<div>
<h4 className="font-medium text-foreground mb-3">Contract Terms</h4>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">Minimum contract:</strong> 3 full billing
months.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">Cancellation:</strong> Can be requested
after 3rd month via cancellation form.
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-foreground"></span>
<span>
<strong className="text-foreground">SIM return:</strong> SIM card must be
returned after service termination.
</span>
</li>
</ul>
</div>
{/* Disclaimer */}
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-xs text-muted-foreground">
Payment is by credit card only. Data service is not suitable for activities
requiring continuous large data transfers. See full Terms of Service for complete
details.
</p>
</div>
</div>
</CollapsibleSection>
</div>
{/* Terms Footer */}
<div className="mt-8 text-center text-sm text-muted-foreground">
<p>
All prices exclude 10% consumption tax.{" "}
<a href="#" className="text-primary hover:underline">
View full Terms of Service
</a>
</p>
</div>
</div>
); );
} }

View File

@ -2,7 +2,7 @@
import { PageLayout } from "@/components/templates/PageLayout"; import { PageLayout } from "@/components/templates/PageLayout";
import { ShieldCheckIcon } from "@heroicons/react/24/outline"; import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useVpnCatalog } from "@/features/services/hooks"; import { useAccountVpnCatalog } from "@/features/services/hooks";
import { LoadingCard } from "@/components/atoms"; import { LoadingCard } from "@/components/atoms";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
@ -13,7 +13,7 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa
export function VpnPlansView() { export function VpnPlansView() {
const servicesBasePath = useServicesBasePath(); const servicesBasePath = useServicesBasePath();
const { data, isLoading, error } = useVpnCatalog(); const { data, isLoading, error } = useAccountVpnCatalog();
const vpnPlans = data?.plans || []; const vpnPlans = data?.plans || [];
const activationFees = data?.activationFees || []; const activationFees = data?.activationFees || [];

View File

@ -7,8 +7,9 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react"; import { useEffect, useState } from "react";
import { isApiError } from "@/lib/api/runtime/client"; import { isApiError } from "@/lib/api/runtime/client";
import { useAuthStore } from "@/features/auth/services/auth.store";
interface QueryProviderProps { interface QueryProviderProps {
children: React.ReactNode; children: React.ReactNode;
@ -48,6 +49,18 @@ export function QueryProvider({ children }: QueryProviderProps) {
}) })
); );
// Security + correctness: clear cached queries on logout so a previous user's
// account-scoped data cannot remain in memory.
useEffect(() => {
const unsubscribe = useAuthStore.subscribe((state, prevState) => {
if (prevState.isAuthenticated && !state.isAuthenticated) {
queryClient.clear();
}
});
return unsubscribe;
}, [queryClient]);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}

View File

@ -41,7 +41,7 @@ Core system design documents:
| -------------------------------------------------------------- | ------------------------- | | -------------------------------------------------------------- | ------------------------- |
| [System Overview](./architecture/system-overview.md) | High-level architecture | | [System Overview](./architecture/system-overview.md) | High-level architecture |
| [Monorepo Structure](./architecture/monorepo.md) | Monorepo organization | | [Monorepo Structure](./architecture/monorepo.md) | Monorepo organization |
| [Product Catalog](./architecture/product-catalog.md) | Catalog design | | [Product Catalog](./architecture/product-catalog.md) | Product catalog design |
| [Modular Provisioning](./architecture/modular-provisioning.md) | Provisioning architecture | | [Modular Provisioning](./architecture/modular-provisioning.md) | Provisioning architecture |
| [Domain Layer](./architecture/domain-layer.md) | Domain-driven design | | [Domain Layer](./architecture/domain-layer.md) | Domain-driven design |
| [Orders Architecture](./architecture/orders.md) | Order system design | | [Orders Architecture](./architecture/orders.md) | Order system design |
@ -56,7 +56,7 @@ Feature guides explaining how the portal functions:
| ------------------------------------------------------------------ | --------------------------- | | ------------------------------------------------------------------ | --------------------------- |
| [System Overview](./how-it-works/system-overview.md) | Systems and data ownership | | [System Overview](./how-it-works/system-overview.md) | Systems and data ownership |
| [Accounts & Identity](./how-it-works/accounts-and-identity.md) | Sign-up and WHMCS linking | | [Accounts & Identity](./how-it-works/accounts-and-identity.md) | Sign-up and WHMCS linking |
| [Catalog & Checkout](./how-it-works/catalog-and-checkout.md) | Products and checkout rules | | [Services & Checkout](./how-it-works/services-and-checkout.md) | Products and checkout rules |
| [Orders & Provisioning](./how-it-works/orders-and-provisioning.md) | Order fulfillment flow | | [Orders & Provisioning](./how-it-works/orders-and-provisioning.md) | Order fulfillment flow |
| [Billing & Payments](./how-it-works/billing-and-payments.md) | Invoices and payments | | [Billing & Payments](./how-it-works/billing-and-payments.md) | Invoices and payments |
| [Subscriptions](./how-it-works/subscriptions.md) | Active services | | [Subscriptions](./how-it-works/subscriptions.md) | Active services |

View File

@ -59,7 +59,7 @@ packages/domain/
│ │ ├── raw.types.ts # Salesforce Order/OrderItem │ │ ├── raw.types.ts # Salesforce Order/OrderItem
│ │ └── mapper.ts # Transform Salesforce → OrderDetails │ │ └── mapper.ts # Transform Salesforce → OrderDetails
│ └── index.ts │ └── index.ts
├── catalog/ ├── services/
│ ├── contract.ts # CatalogProduct, InternetPlan, SimProduct, VpnProduct │ ├── contract.ts # CatalogProduct, InternetPlan, SimProduct, VpnProduct
│ ├── schema.ts │ ├── schema.ts
│ ├── providers/ │ ├── providers/
@ -90,7 +90,7 @@ import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { Subscription } from "@customer-portal/domain/subscriptions";
import type { SimDetails } from "@customer-portal/domain/sim"; import type { SimDetails } from "@customer-portal/domain/sim";
import type { OrderSummary } from "@customer-portal/domain/orders"; import type { OrderSummary } from "@customer-portal/domain/orders";
import type { CatalogProduct } from "@customer-portal/domain/services"; import type { InternetPlanCatalogItem } from "@customer-portal/domain/services";
``` ```
### Import Schemas ### Import Schemas
@ -233,11 +233,11 @@ External API Request
- **Providers**: WHMCS (provisioning), Salesforce (order management) - **Providers**: WHMCS (provisioning), Salesforce (order management)
- **Use Cases**: Order fulfillment, order history, order details - **Use Cases**: Order fulfillment, order history, order details
### Catalog ### Services
- **Contracts**: `InternetPlanCatalogItem`, `SimCatalogProduct`, `VpnCatalogProduct` - **Contracts**: `InternetPlanCatalogItem`, `SimCatalogProduct`, `VpnCatalogProduct`
- **Providers**: Salesforce (Product2) - **Providers**: Salesforce (Product2)
- **Use Cases**: Product catalog display, product selection - **Use Cases**: Product catalog display, product selection, service browsing
### Common ### Common

View File

@ -69,7 +69,7 @@ src/
users/ # User management users/ # User management
me-status/ # Aggregated customer status (dashboard + gating signals) me-status/ # Aggregated customer status (dashboard + gating signals)
id-mappings/ # Portal-WHMCS-Salesforce ID mappings id-mappings/ # Portal-WHMCS-Salesforce ID mappings
catalog/ # Product catalog services/ # Services catalog (browsing/purchasing)
orders/ # Order creation and fulfillment orders/ # Order creation and fulfillment
invoices/ # Invoice management invoices/ # Invoice management
subscriptions/ # Service and subscription management subscriptions/ # Service and subscription management
@ -96,6 +96,11 @@ src/
- Reuse `packages/domain` for domain types - Reuse `packages/domain` for domain types
- External integrations in dedicated modules - External integrations in dedicated modules
### **API Boundary: Public vs Account**
- **Public APIs** (`/api/public/*`): strictly non-personalized endpoints intended for marketing pages and unauthenticated browsing.
- **Account APIs** (`/api/account/*`): authenticated endpoints that may return personalized responses (e.g. eligibility-gated catalogs, SIM family discount availability).
## 📦 **Shared Packages** ## 📦 **Shared Packages**
### **Domain Package (`packages/domain/`)** ### **Domain Package (`packages/domain/`)**
@ -106,7 +111,7 @@ The domain package is the single source of truth for shared types, validation sc
packages/domain/ packages/domain/
├── auth/ # Authentication types and validation ├── auth/ # Authentication types and validation
├── billing/ # Invoice and payment types ├── billing/ # Invoice and payment types
├── catalog/ # Product catalog types ├── services/ # Services catalog types
├── checkout/ # Checkout flow types ├── checkout/ # Checkout flow types
├── common/ # Shared utilities and base types ├── common/ # Shared utilities and base types
├── customer/ # Customer profile types ├── customer/ # Customer profile types

View File

@ -74,8 +74,8 @@ packages/domain/
│ ├── raw.types.ts │ ├── raw.types.ts
│ └── mapper.ts │ └── mapper.ts
├── catalog/ ├── services/
│ ├── contract.ts # CatalogProduct (UI view model) │ ├── contract.ts # Service catalog products (UI view model)
│ ├── schema.ts │ ├── schema.ts
│ ├── index.ts │ ├── index.ts
│ └── providers/ │ └── providers/

View File

@ -223,7 +223,7 @@ function isAddonProduct(product: Product): boolean {
const product = fromSalesforceProduct2(salesforceProduct, pricebookEntry); const product = fromSalesforceProduct2(salesforceProduct, pricebookEntry);
if (isCatalogVisible(product)) { if (isCatalogVisible(product)) {
displayInCatalog(product); displayInServices(product);
} }
// Type-safe access to specific fields // Type-safe access to specific fields

View File

@ -21,7 +21,7 @@ apps/portal/src/
│ ├── account/ │ ├── account/
│ ├── auth/ │ ├── auth/
│ ├── billing/ │ ├── billing/
│ ├── catalog/ │ ├── services/
│ ├── dashboard/ │ ├── dashboard/
│ ├── marketing/ │ ├── marketing/
│ ├── orders/ │ ├── orders/
@ -209,9 +209,9 @@ Only `layout.tsx`, `page.tsx`, and `loading.tsx` files live inside the route gro
### Current Feature Hooks/Services ### Current Feature Hooks/Services
- Catalog - Services
- Hooks: `useInternetCatalog`, `useSimCatalog`, `useVpnCatalog`, `useProducts*`, `useCalculateOrder`, `useSubmitOrder` - Hooks: `useInternetCatalog`, `useSimCatalog`, `useVpnCatalog`, `useProducts*`, `useCalculateOrder`, `useSubmitOrder`
- Service: `catalogService` (internet/sim/vpn endpoints consolidated) - Service: `servicesService` (internet/sim/vpn endpoints consolidated)
- Billing - Billing
- Hooks: `useInvoices`, `usePaymentMethods`, `usePaymentGateways`, `usePaymentRefresh` - Hooks: `useInvoices`, `usePaymentMethods`, `usePaymentGateways`, `usePaymentRefresh`
- Service: `BillingService` - Service: `BillingService`

View File

@ -11,7 +11,7 @@ Start with `system-overview.md`, then jump into the feature you care about.
| [Complete Guide](./COMPLETE-GUIDE.md) | Single, end-to-end explanation of how the portal works | | [Complete Guide](./COMPLETE-GUIDE.md) | Single, end-to-end explanation of how the portal works |
| [System Overview](./system-overview.md) | High-level architecture, data ownership, and caching | | [System Overview](./system-overview.md) | High-level architecture, data ownership, and caching |
| [Accounts & Identity](./accounts-and-identity.md) | Sign-up, WHMCS linking, and address/profile updates | | [Accounts & Identity](./accounts-and-identity.md) | Sign-up, WHMCS linking, and address/profile updates |
| [Catalog & Checkout](./catalog-and-checkout.md) | Product source, pricing, and checkout flow | | [Services & Checkout](./services-and-checkout.md) | Product source, pricing, and checkout flow |
| [Eligibility & Verification](./eligibility-and-verification.md) | Internet eligibility + SIM ID verification | | [Eligibility & Verification](./eligibility-and-verification.md) | Internet eligibility + SIM ID verification |
| [Orders & Provisioning](./orders-and-provisioning.md) | Order lifecycle in Salesforce → WHMCS fulfillment | | [Orders & Provisioning](./orders-and-provisioning.md) | Order lifecycle in Salesforce → WHMCS fulfillment |
| [Billing & Payments](./billing-and-payments.md) | Invoices, payment methods, billing links | | [Billing & Payments](./billing-and-payments.md) | Invoices, payment methods, billing links |

View File

@ -20,12 +20,25 @@ This guide describes how eligibility and verification work in the customer porta
### How It Works ### How It Works
1. Customer navigates to `/account/services/internet` 1. Customer navigates to `/account/services/internet`
2. Customer enters service address and requests eligibility check 2. Customer clicks **Check Availability** (requires a service address on file)
3. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review 3. Portal calls `POST /api/services/internet/eligibility-request` and shows an immediate confirmation screen at `/account/services/internet/request-submitted`
4. Agent performs NTT serviceability check (manual process) 4. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review
5. Agent updates Account eligibility fields 5. Agent performs NTT serviceability check (manual process)
6. Salesforce Flow sends email notification to customer 6. Agent updates Account eligibility fields
7. Customer returns and sees eligible plans 7. Salesforce Flow sends email notification to customer
8. Customer returns and sees eligible plans
### Caching & Rate Limiting (Security + Load)
- **BFF cache (Redis)**:
- Internet catalog data is cached in Redis (CDC-driven invalidation, no TTL) so repeated portal hits **do not repeatedly query Salesforce**.
- Eligibility details are cached per Salesforce Account ID and are invalidated/updated when Salesforce emits Account change events.
- **Portal cache (React Query)**:
- The portal caches service catalog responses in-memory, scoped by auth state, and will refetch when stale.
- On logout, the portal clears cached queries to avoid cross-user leakage on shared devices.
- **Rate limiting**:
- Public catalog endpoints are rate-limited per IP + User-Agent to prevent abuse.
- `POST /api/services/internet/eligibility-request` is authenticated and rate-limited, and the BFF is idempotent when a request is already pending (no duplicate Cases created).
### Subscription Type Detection ### Subscription Type Detection
@ -50,12 +63,15 @@ const isInternetService =
| `Internet_Eligibility__c` | Text | Agent | After check | | `Internet_Eligibility__c` | Text | Agent | After check |
| `Internet_Eligibility_Request_Date_Time__c` | DateTime | Portal | On request | | `Internet_Eligibility_Request_Date_Time__c` | DateTime | Portal | On request |
| `Internet_Eligibility_Checked_Date_Time__c` | DateTime | Agent | After check | | `Internet_Eligibility_Checked_Date_Time__c` | DateTime | Agent | After check |
| `Internet_Eligibility_Notes__c` | Text | Agent | After check |
| `Internet_Eligibility_Case_Id__c` | Lookup | Portal | On request | **Notes:**
- The portal returns an API-level **request id** (Salesforce Case ID) from `POST /api/services/internet/eligibility-request` for display/auditing.
- The portal UI reads eligibility status/value from the Account fields above; it does not rely on an Account-stored Case ID.
### Status Values ### Status Values
| Status | Shop Page UI | Checkout Gating | | Status | Services Page UI | Checkout Gating |
| --------------- | --------------------------------------- | --------------- | | --------------- | --------------------------------------- | --------------- |
| `Not Requested` | Show "Request eligibility check" button | Block submit | | `Not Requested` | Show "Request eligibility check" button | Block submit |
| `Pending` | Show "Review in progress" | Block submit | | `Pending` | Show "Review in progress" | Block submit |
@ -109,9 +125,10 @@ The Profile page is the primary location. The standalone page is used when redir
## Portal UI Locations ## Portal UI Locations
| Location | What's Shown | | Location | What's Shown |
| ---------------------------- | ----------------------------------------------- | | ---------------------------------------------- | -------------------------------------------------------------- |
| `/account/settings` | Profile, Address, ID Verification (with upload) | | `/account/settings` | Profile, Address, ID Verification (with upload) |
| `/account/services/internet` | Eligibility status and eligible plans | | `/account/services/internet` | Eligibility status and eligible plans |
| `/account/services/internet/request-submitted` | Immediate confirmation after submitting an eligibility request |
| Subscription detail | Service-specific actions (cancel, etc.) | | Subscription detail | Service-specific actions (cancel, etc.) |
## Cancellation Flow ## Cancellation Flow

View File

@ -1,4 +1,4 @@
# Catalog & Checkout # Services & Checkout
Where product data comes from, what we validate, and how we keep it fresh. Where product data comes from, what we validate, and how we keep it fresh.
@ -21,24 +21,24 @@ Where product data comes from, what we validate, and how we keep it fresh.
- SKUs selected exist in the Salesforce pricebook. - SKUs selected exist in the Salesforce pricebook.
- For Internet orders, we block duplicates when WHMCS already shows an active Internet service (in production). - For Internet orders, we block duplicates when WHMCS already shows an active Internet service (in production).
For the intended Salesforce-driven workflow model (Cases + Account fields + portal UX), see `docs/portal-guides/eligibility-and-verification.md`. For the intended Salesforce-driven workflow model (Cases + Account fields + portal UX), see `docs/how-it-works/eligibility-and-verification.md`.
## Checkout Data Captured ## Checkout Data Captured
- Address snapshot: we copy the customers address (or the one they update during checkout) into the Salesforce Order billing fields so the order shows the exact data used. - Address snapshot: we copy the customer's address (or the one they update during checkout) into the Salesforce Order billing fields so the order shows the exact data used.
- Activation preferences: stored on the Salesforce Order (activation type/schedule, SIM specifics, MNP details when applicable). - Activation preferences: stored on the Salesforce Order (activation type/schedule, SIM specifics, MNP details when applicable).
- No card data is stored in the portal; we only verify that WHMCS already has a payment method. - No card data is stored in the portal; we only verify that WHMCS already has a payment method.
## Caching for Catalog Calls ## Caching for Product Catalog
- Catalog data uses Salesforce Change Data Capture (CDC) events; there is no time-based expiry. When Salesforce signals a product change, the cache is cleared. - Product catalog data uses Salesforce Change Data Capture (CDC) events; there is no time-based expiry. When Salesforce signals a product change, the cache is cleared.
- Volatile catalog bits (e.g., fast-changing reference data) use a 60s TTL. - Volatile catalog bits (e.g., fast-changing reference data) use a 60s TTL.
- Eligibility per account is cached with no TTL and cleared when Salesforce changes. - Eligibility per account is cached with no TTL and cleared when Salesforce changes.
- Request coalescing is used so multiple users hitting the same catalog do not spam Salesforce. - Request coalescing is used so multiple users hitting the same services endpoint do not spam Salesforce.
## If something goes wrong ## If something goes wrong
- Missing payment method: checkout is blocked with a clear “add a payment method” message. - Missing payment method: checkout is blocked with a clear "add a payment method" message.
- Ineligible address or duplicate Internet: we stop the order and explain why (eligibility failed or active Internet already exists). - Ineligible address or duplicate Internet: we stop the order and explain why (eligibility failed or active Internet already exists).
- Salesforce pricebook issues: we return a friendly “catalog unavailable, please try again later.” - Salesforce pricebook issues: we return a friendly "services unavailable, please try again later."
- Cache failures: we fall back to live Salesforce reads to avoid empty screens. - Cache failures: we fall back to live Salesforce reads to avoid empty screens.

View File

@ -6,7 +6,7 @@ Purpose: explain what the portal does, which systems own which data, and how fre
- Portal UI (Next.js) + BFF API (NestJS): handles all user traffic and calls external systems. - Portal UI (Next.js) + BFF API (NestJS): handles all user traffic and calls external systems.
- Postgres: stores portal users and the cross-system mapping `user_id ↔ whmcs_client_id ↔ sf_account_id`. - Postgres: stores portal users and the cross-system mapping `user_id ↔ whmcs_client_id ↔ sf_account_id`.
- Redis cache: keeps short-lived copies of data to reduce load; keys are always scoped per user to avoid mixing data. - Redis cache: reduces load with a mix of **global** caches (e.g. product catalog) and **account-scoped** caches (e.g. eligibility) to avoid mixing customer data.
- WHMCS: system of record for billing (clients, addresses, invoices, payment methods, subscriptions). - WHMCS: system of record for billing (clients, addresses, invoices, payment methods, subscriptions).
- Salesforce: system of record for CRM (accounts/contacts), product catalog/pricebook, orders, and support cases. - Salesforce: system of record for CRM (accounts/contacts), product catalog/pricebook, orders, and support cases.
- Freebit: SIM provisioning only, used during mobile/SIM order fulfillment. - Freebit: SIM provisioning only, used during mobile/SIM order fulfillment.
@ -15,7 +15,7 @@ Purpose: explain what the portal does, which systems own which data, and how fre
- Sign-up: portal verifies the customer number in Salesforce → creates a WHMCS client (billing account) → stores the portal user + mapping → updates Salesforce with portal status + WHMCS ID. - Sign-up: portal verifies the customer number in Salesforce → creates a WHMCS client (billing account) → stores the portal user + mapping → updates Salesforce with portal status + WHMCS ID.
- Login/Linking: existing WHMCS users validate their WHMCS credentials; we create the portal user, map IDs, and mark the Salesforce account as portal-active. - Login/Linking: existing WHMCS users validate their WHMCS credentials; we create the portal user, map IDs, and mark the Salesforce account as portal-active.
- Catalog & Checkout: products/prices come from the Salesforce portal pricebook; eligibility is checked per account; we require a WHMCS payment method before allowing checkout. - Services & Checkout: products/prices come from the Salesforce portal pricebook; eligibility is checked per account; we require a WHMCS payment method before allowing checkout.
- Orders: created in Salesforce with an address snapshot; Salesforce change events trigger fulfillment, which creates the matching WHMCS order and updates Salesforce statuses. - Orders: created in Salesforce with an address snapshot; Salesforce change events trigger fulfillment, which creates the matching WHMCS order and updates Salesforce statuses.
- Billing: invoices, payment methods, and subscriptions are read from WHMCS; secure SSO links are generated for paying invoices inside WHMCS. - Billing: invoices, payment methods, and subscriptions are read from WHMCS; secure SSO links are generated for paying invoices inside WHMCS.
- Support: cases are created/read directly in Salesforce with Origin = “Portal Website.” - Support: cases are created/read directly in Salesforce with Origin = “Portal Website.”
@ -29,7 +29,7 @@ Purpose: explain what the portal does, which systems own which data, and how fre
## Caching & Freshness (Redis) ## Caching & Freshness (Redis)
- Catalog: event-driven (Salesforce CDC), no TTL; “volatile” bits use 60s TTL; eligibility per account is cached without TTL and invalidated on change. - Services catalog: event-driven (Salesforce CDC), no TTL; "volatile" bits use 60s TTL; eligibility per account is cached without TTL and invalidated on change.
- Orders: event-driven (Salesforce CDC), no TTL; invalidated when Salesforce emits order/order-item changes or when we create/provision an order. - Orders: event-driven (Salesforce CDC), no TTL; invalidated when Salesforce emits order/order-item changes or when we create/provision an order.
- Invoices: list cached 90s; invoice detail cached 5m; invalidated by WHMCS webhooks and by write operations. - Invoices: list cached 90s; invoice detail cached 5m; invalidated by WHMCS webhooks and by write operations.
- Subscriptions/services: list cached 5m; single subscription cached 10m; invalidated on WHMCS cache busts (webhooks or profile updates). - Subscriptions/services: list cached 5m; single subscription cached 10m; invalidated on WHMCS cache busts (webhooks or profile updates).
@ -44,3 +44,43 @@ Purpose: explain what the portal does, which systems own which data, and how fre
- If WHMCS or Salesforce is briefly unavailable, the portal surfaces a friendly “try again later” message rather than partial data. - If WHMCS or Salesforce is briefly unavailable, the portal surfaces a friendly “try again later” message rather than partial data.
- Fulfillment writes error codes/messages back to Salesforce (e.g., missing payment method) so the team can see why a provision was paused. - Fulfillment writes error codes/messages back to Salesforce (e.g., missing payment method) so the team can see why a provision was paused.
- Caches are cleared on writes and key webhooks so stale data is minimized; when cache access fails, we fall back to live reads. - Caches are cleared on writes and key webhooks so stale data is minimized; when cache access fails, we fall back to live reads.
## Public vs Account API Boundary (Security + Caching)
The BFF exposes two “flavors” of service catalog endpoints:
- **Public catalog (never personalized)**: `GET /api/public/services/*`
- Ignores cookies/tokens (no optional session attach).
- Safe to cache publicly (subject to TTL) and heavily rate limit.
- **Account catalog (authenticated + personalized)**: `GET /api/account/services/*`
- Requires auth and can return account-specific catalog variants (e.g. SIM family discount availability).
- Uses `Cache-Control: private, no-store` at the HTTP layer; server-side caching is handled in Redis.
### How "public caching" works (and why high traffic usually won't hit Salesforce)
There are **two independent caching layers** involved:
- **Redis (server-side) catalog cache**:
- Catalog reads are cached in Redis via `ServicesCacheService`.
- For catalog data (plans/addons/etc) the TTL is intentionally **null** (no TTL): values persist until explicitly invalidated.
- Invalidation is driven by Salesforce **CDC** events (Product2 / PricebookEntry) and an account **Platform Event** for eligibility updates.
- Result: even if the public catalog is requested millions of times, the BFF typically serves from Redis and only re-queries Salesforce when a relevant Salesforce change event arrives (or on cold start / cache miss).
- **HTTP cache (browser/CDN)**:
- Public catalog responses include `Cache-Control: public, max-age=..., s-maxage=...`.
- This reduces load on the BFF by allowing browsers/shared caches/CDNs to reuse responses for the TTL window.
- This layer is TTL-based, so **staleness up to the TTL** is expected unless your CDN is configured for explicit purge.
### What to worry about at "million visits" scale
- **CDN cookie forwarding / cache key fragmentation**:
- Browsers will still send cookies to `/api/public/*` by default; the BFF ignores them, but a CDN might treat cookies as part of the cache key unless configured not to.
- Make sure your CDN/proxy config does **not** include cookies (and ideally not `Authorization`) in the cache key for `/api/public/services/*`.
- **BFF + Redis load (even if Salesforce is protected)**:
- Redis caching prevents Salesforce read amplification, but the BFF/Redis still need to handle request volume.
- Rate limiting on public endpoints is intentional to cap abuse and protect infrastructure.
- **CDC subscription health / fallback behavior**:
- If Salesforce CDC subscriptions are disabled or unhealthy, invalidations may not arrive and Redis caches can become stale until manually cleared.
- Monitor the CDC subscriber and cache health metrics (`GET /api/health/services/cache`).

View File

@ -70,8 +70,11 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
| Status | `Internet_Eligibility_Status__c` | Pending, Checked | | Status | `Internet_Eligibility_Status__c` | Pending, Checked |
| Requested At | `Internet_Eligibility_Request_Date_Time__c` | When request was made | | Requested At | `Internet_Eligibility_Request_Date_Time__c` | When request was made |
| Checked At | `Internet_Eligibility_Checked_Date_Time__c` | When eligibility was checked | | Checked At | `Internet_Eligibility_Checked_Date_Time__c` | When eligibility was checked |
| Notes | `Internet_Eligibility_Notes__c` | Agent notes |
| Case ID | `Internet_Eligibility_Case_Id__c` | Linked Case for request | **Notes:**
- The portal does **not** store the Case ID or agent notes on the Account in our current Salesforce environment.
- Agent notes should live inside the **Case Description** / Case activity history.
**ID Verification Fields:** **ID Verification Fields:**
@ -179,6 +182,7 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
│ │ │ │
│ 1. CUSTOMER ENTERS ADDRESS │ │ 1. CUSTOMER ENTERS ADDRESS │
│ └─ Portal: POST /api/services/internet/eligibility-request │ │ └─ Portal: POST /api/services/internet/eligibility-request │
│ Portal UI: shows confirmation at /account/services/internet/request-submitted │
│ │ │ │
│ 2. CHECK IF ELIGIBILITY ALREADY KNOWN │ │ 2. CHECK IF ELIGIBILITY ALREADY KNOWN │
│ └─ Query: SELECT Internet_Eligibility__c FROM Account │ │ └─ Query: SELECT Internet_Eligibility__c FROM Account │
@ -215,7 +219,6 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
│ 5. UPDATE ACCOUNT │ │ 5. UPDATE ACCOUNT │
│ └─ Internet_Eligibility_Status__c = "Pending" │ │ └─ Internet_Eligibility_Status__c = "Pending" │
│ └─ Internet_Eligibility_Request_Date_Time__c = now() │ │ └─ Internet_Eligibility_Request_Date_Time__c = now() │
│ └─ Internet_Eligibility_Case_Id__c = Case.Id │
│ │ │ │
│ 6. CS PROCESSES CASE │ │ 6. CS PROCESSES CASE │
│ └─ Checks with NTT / provider │ │ └─ Checks with NTT / provider │
@ -223,9 +226,9 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
│ - Internet_Eligibility__c = result │ │ - Internet_Eligibility__c = result │
│ - Internet_Eligibility_Status__c = "Checked" │ │ - Internet_Eligibility_Status__c = "Checked" │
│ - Internet_Eligibility_Checked_Date_Time__c = now() │ │ - Internet_Eligibility_Checked_Date_Time__c = now() │
│ └─ Updates Opportunity: │ └─ Opportunity stages are not automatically moved to "Ready" by
- Eligible → Stage: Ready the eligibility check. CS updates stages later as part of the
- Not Eligible → Stage: Void order review / lifecycle workflow.
│ │ │ │
│ 7. PORTAL DETECTS CHANGE (via CDC or polling) │ │ 7. PORTAL DETECTS CHANGE (via CDC or polling) │
│ └─ Shows eligibility result to customer │ │ └─ Shows eligibility result to customer │

View File

@ -89,8 +89,11 @@ The Account stores customer information and status fields.
| Eligibility Status | `Internet_Eligibility_Status__c` | Picklist | `Pending`, `Checked` | | Eligibility Status | `Internet_Eligibility_Status__c` | Picklist | `Pending`, `Checked` |
| Request Date | `Internet_Eligibility_Request_Date_Time__c` | DateTime | When request was made | | Request Date | `Internet_Eligibility_Request_Date_Time__c` | DateTime | When request was made |
| Checked Date | `Internet_Eligibility_Checked_Date_Time__c` | DateTime | When checked by CS | | Checked Date | `Internet_Eligibility_Checked_Date_Time__c` | DateTime | When checked by CS |
| Notes | `Internet_Eligibility_Notes__c` | Text Area | Agent notes |
| Case ID | `Internet_Eligibility_Case_Id__c` | Text | Linked Case ID | **Notes:**
- The portal does **not** require Account-level fields for a Case ID or agent notes.
- Agent notes should live in the **Case Description** / Case activity history.
**ID Verification Fields:** **ID Verification Fields:**

View File

@ -191,7 +191,7 @@ pnpm update [package-name]@latest
## 📚 Additional Resources ## 📚 Additional Resources
- **Security Policy**: See `SECURITY.md` - **Security Policy**: See `SECURITY.md`
- **Complete Guide**: See `docs/portal-guides/COMPLETE-GUIDE.md` - **Complete Guide**: See `docs/how-it-works/COMPLETE-GUIDE.md`
- **GitHub Security**: [https://docs.github.com/en/code-security](https://docs.github.com/en/code-security) - **GitHub Security**: [https://docs.github.com/en/code-security](https://docs.github.com/en/code-security)
- **npm Security**: [https://docs.npmjs.com/security](https://docs.npmjs.com/security) - **npm Security**: [https://docs.npmjs.com/security](https://docs.npmjs.com/security)