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:
parent
a4d5d03d91
commit
0f8435e6bd
@ -481,7 +481,7 @@ rm -rf node_modules && pnpm install
|
||||
- **[Deployment Guide](docs/DEPLOY.md)** - Production deployment instructions
|
||||
- **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions
|
||||
- **[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
|
||||
|
||||
|
||||
@ -119,8 +119,8 @@ Security audits are automatically run on:
|
||||
|
||||
### Internal Documentation
|
||||
|
||||
- [Environment Configuration](./docs/portal-guides/COMPLETE-GUIDE.md)
|
||||
- [Deployment Guide](./docs/portal-guides/)
|
||||
- [Environment Configuration](./docs/how-it-works/COMPLETE-GUIDE.md)
|
||||
- [Deployment Guide](./docs/getting-started/)
|
||||
|
||||
### External Resources
|
||||
|
||||
|
||||
@ -54,6 +54,20 @@ export const envSchema = z.object({
|
||||
"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(),
|
||||
|
||||
WHMCS_BASE_URL: z.string().url().optional(),
|
||||
@ -139,9 +153,6 @@ export const envSchema = z.object({
|
||||
ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z
|
||||
.string()
|
||||
.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_SUBMITTED_AT_FIELD: z
|
||||
|
||||
@ -2,3 +2,12 @@ import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const IS_PUBLIC_KEY = "isPublic";
|
||||
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);
|
||||
|
||||
@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
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 { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
@ -45,8 +45,17 @@ export class GlobalAuthGuard implements CanActivate {
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
const isPublicNoSession = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_NO_SESSION_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
if (isPublicNoSession) {
|
||||
this.logger.debug(`Strict public route accessed (no session attach): ${route}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = extractAccessTokenFromRequest(request);
|
||||
if (token) {
|
||||
try {
|
||||
|
||||
61
apps/bff/src/modules/services/account-services.controller.ts
Normal file
61
apps/bff/src/modules/services/account-services.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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 { z } from "zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
@ -31,6 +31,7 @@ export class InternetEligibilityController {
|
||||
|
||||
@Get("eligibility")
|
||||
@RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap)
|
||||
@Header("Cache-Control", "private, no-store")
|
||||
async getEligibility(@Req() req: RequestWithUser): Promise<InternetEligibilityDetails> {
|
||||
return this.internetCatalog.getEligibilityDetailsForUser(req.user.id);
|
||||
}
|
||||
@ -38,6 +39,7 @@ export class InternetEligibilityController {
|
||||
@Post("eligibility-request")
|
||||
@RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP
|
||||
@UsePipes(new ZodValidationPipe(eligibilityRequestSchema))
|
||||
@Header("Cache-Control", "private, no-store")
|
||||
async requestEligibility(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() body: EligibilityRequest
|
||||
|
||||
54
apps/bff/src/modules/services/public-services.controller.ts
Normal file
54
apps/bff/src/modules/services/public-services.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||
import {
|
||||
parseInternetCatalog,
|
||||
parseSimCatalog,
|
||||
parseVpnCatalog,
|
||||
type InternetAddonCatalogItem,
|
||||
type InternetInstallationCatalogItem,
|
||||
type InternetPlanCatalogItem,
|
||||
@ -12,6 +13,7 @@ import {
|
||||
type SimCatalogCollection,
|
||||
type SimCatalogProduct,
|
||||
type VpnCatalogProduct,
|
||||
type VpnCatalogCollection,
|
||||
} from "@customer-portal/domain/services";
|
||||
import { InternetServicesService } from "./services/internet-services.service.js";
|
||||
import { SimServicesService } from "./services/sim-services.service.js";
|
||||
@ -100,8 +102,10 @@ export class ServicesController {
|
||||
@Get("vpn/plans")
|
||||
@RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute
|
||||
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||
async getVpnPlans(): Promise<VpnCatalogProduct[]> {
|
||||
return this.vpnCatalog.getPlans();
|
||||
async getVpnPlans(): Promise<VpnCatalogCollection> {
|
||||
// Backwards-compatible: return the full VPN catalog (plans + activation fees)
|
||||
const catalog = await this.vpnCatalog.getCatalogData();
|
||||
return parseVpnCatalog(catalog);
|
||||
}
|
||||
|
||||
@Get("vpn/activation-fees")
|
||||
|
||||
@ -2,6 +2,8 @@ import { Module, forwardRef } from "@nestjs/common";
|
||||
import { ServicesController } from "./services.controller.js";
|
||||
import { ServicesHealthController } from "./services-health.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 { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
import { CoreConfigModule } from "@bff/core/config/config.module.js";
|
||||
@ -22,7 +24,13 @@ import { ServicesCacheService } from "./services/services-cache.service.js";
|
||||
CacheModule,
|
||||
QueueModule,
|
||||
],
|
||||
controllers: [ServicesController, ServicesHealthController, InternetEligibilityController],
|
||||
controllers: [
|
||||
ServicesController,
|
||||
PublicServicesController,
|
||||
AccountServicesController,
|
||||
ServicesHealthController,
|
||||
InternetEligibilityController,
|
||||
],
|
||||
providers: [
|
||||
BaseServicesService,
|
||||
InternetServicesService,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
||||
import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js";
|
||||
|
||||
@ -43,10 +44,10 @@ interface LegacyCatalogCachePayload<T> {
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServicesCacheService {
|
||||
// CDC-driven invalidation: null TTL means cache persists until explicit invalidation
|
||||
private readonly SERVICES_TTL: number | null = null;
|
||||
private readonly STATIC_TTL: number | null = null;
|
||||
private readonly ELIGIBILITY_TTL: number | null = null;
|
||||
// CDC-driven invalidation + safety TTL (self-heal if events are missed)
|
||||
private readonly SERVICES_TTL: number | null;
|
||||
private readonly STATIC_TTL: number | null;
|
||||
private readonly ELIGIBILITY_TTL: number | null;
|
||||
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
|
||||
|
||||
private readonly metrics: ServicesCacheSnapshot = {
|
||||
@ -61,10 +62,21 @@ export class ServicesCacheService {
|
||||
// request the same data after CDC invalidation
|
||||
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>(
|
||||
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> {
|
||||
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> {
|
||||
return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, {
|
||||
|
||||
@ -184,22 +184,28 @@ export class SimServicesService extends BaseServicesService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check WHMCS for existing SIM services
|
||||
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId });
|
||||
const services = (products?.products?.product || []) as Array<{
|
||||
groupname?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
|
||||
// Look for active SIM services
|
||||
const hasActiveSim = services.some(
|
||||
service =>
|
||||
String(service.groupname || "")
|
||||
.toLowerCase()
|
||||
.includes("sim") && String(service.status || "").toLowerCase() === "active"
|
||||
const cacheKey = this.catalogCache.buildServicesKey(
|
||||
"sim",
|
||||
"has-existing-sim",
|
||||
String(mapping.whmcsClientId)
|
||||
);
|
||||
|
||||
return hasActiveSim;
|
||||
// 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 services = (products?.products?.product || []) as Array<{
|
||||
groupname?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
|
||||
// Look for active SIM services
|
||||
return services.some(service => {
|
||||
const group = String(service.groupname || "").toLowerCase();
|
||||
const status = String(service.status || "").toLowerCase();
|
||||
return group.includes("sim") && status === "active";
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to check existing SIM for user ${userId}`, error);
|
||||
return false; // Default to no existing SIM
|
||||
|
||||
@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||
import { BaseServicesService } from "./base-services.service.js";
|
||||
import { ServicesCacheService } from "./services-cache.service.js";
|
||||
import type {
|
||||
SalesforceProduct2WithPricebookEntries,
|
||||
VpnCatalogProduct,
|
||||
@ -14,43 +15,68 @@ export class VpnServicesService extends BaseServicesService {
|
||||
constructor(
|
||||
sf: SalesforceConnection,
|
||||
configService: ConfigService,
|
||||
@Inject(Logger) logger: Logger
|
||||
@Inject(Logger) logger: Logger,
|
||||
private readonly catalogCache: ServicesCacheService
|
||||
) {
|
||||
super(sf, configService, logger);
|
||||
}
|
||||
async getPlans(): Promise<VpnCatalogProduct[]> {
|
||||
const soql = this.buildServicesQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
"VPN Plans"
|
||||
);
|
||||
const cacheKey = this.catalogCache.buildServicesKey("vpn", "plans");
|
||||
|
||||
return records.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry);
|
||||
return {
|
||||
...product,
|
||||
description: product.description || product.name,
|
||||
} satisfies VpnCatalogProduct;
|
||||
});
|
||||
return this.catalogCache.getCachedServices(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const soql = this.buildServicesQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
"VPN Plans"
|
||||
);
|
||||
|
||||
return records.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry);
|
||||
return {
|
||||
...product,
|
||||
description: product.description || product.name,
|
||||
} satisfies VpnCatalogProduct;
|
||||
});
|
||||
},
|
||||
{
|
||||
resolveDependencies: plans => ({
|
||||
productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||
const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
"VPN Activation Fees"
|
||||
const cacheKey = this.catalogCache.buildServicesKey("vpn", "activation-fees");
|
||||
|
||||
return this.catalogCache.getCachedServices(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
"VPN Activation Fees"
|
||||
);
|
||||
|
||||
return records.map(record => {
|
||||
const pricebookEntry = this.extractPricebookEntry(record);
|
||||
const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry);
|
||||
|
||||
return {
|
||||
...product,
|
||||
description: product.description ?? product.name,
|
||||
} satisfies VpnCatalogProduct;
|
||||
});
|
||||
},
|
||||
{
|
||||
resolveDependencies: fees => ({
|
||||
productIds: fees.map(fee => fee.id).filter((id): id is string => Boolean(id)),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return records.map(record => {
|
||||
const pricebookEntry = this.extractPricebookEntry(record);
|
||||
const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry);
|
||||
|
||||
return {
|
||||
...product,
|
||||
description: product.description ?? product.name,
|
||||
} satisfies VpnCatalogProduct;
|
||||
});
|
||||
}
|
||||
|
||||
async getCatalogData() {
|
||||
|
||||
@ -6,6 +6,6 @@
|
||||
"rootDir": "./src",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
@ -15,6 +15,6 @@
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import { RedirectAuthenticatedToAccountServices } from "@/features/services/comp
|
||||
export default function PublicInternetConfigurePage() {
|
||||
return (
|
||||
<>
|
||||
<RedirectAuthenticatedToAccountServices targetPath="/account/services/internet/configure" />
|
||||
<RedirectAuthenticatedToAccountServices targetPath="/account/services/internet" />
|
||||
<PublicInternetConfigureView />
|
||||
</>
|
||||
);
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import InternetEligibilityRequestSubmittedView from "@/features/services/views/InternetEligibilityRequestSubmitted";
|
||||
|
||||
export default function AccountInternetEligibilityRequestSubmittedPage() {
|
||||
return <InternetEligibilityRequestSubmittedView />;
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface TierInfo {
|
||||
tier: "Silver" | "Gold" | "Platinum";
|
||||
planSku: string;
|
||||
monthlyPrice: number;
|
||||
description: string;
|
||||
features: string[];
|
||||
@ -62,6 +63,10 @@ export function InternetOfferingCard({
|
||||
previewMode = false,
|
||||
}: InternetOfferingCardProps) {
|
||||
const Icon = iconType === "home" ? Home : Building2;
|
||||
const resolveTierHref = (basePath: string, planSku: string): string => {
|
||||
const joiner = basePath.includes("?") ? "&" : "?";
|
||||
return `${basePath}${joiner}planSku=${encodeURIComponent(planSku)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -176,7 +181,7 @@ export function InternetOfferingCard({
|
||||
) : (
|
||||
<Button
|
||||
as="a"
|
||||
href={ctaPath}
|
||||
href={resolveTierHref(ctaPath, tier.planSku)}
|
||||
variant={tier.recommended ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="w-full mt-auto"
|
||||
|
||||
@ -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'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'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'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;
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useInternetCatalog, useInternetPlan } from ".";
|
||||
import { useAccountInternetCatalog } from ".";
|
||||
import { useCatalogStore } from "../services/services.store";
|
||||
import { useServicesBasePath } from "./useServicesBasePath";
|
||||
import type { AccessModeValue } from "@customer-portal/domain/orders";
|
||||
@ -55,8 +55,12 @@ export function useInternetConfigure(): UseInternetConfigureResult {
|
||||
const lastRestoredSignatureRef = useRef<string | null>(null);
|
||||
|
||||
// Fetch services data from BFF
|
||||
const { data: internetData, isLoading: internetLoading } = useInternetCatalog();
|
||||
const { plan: selectedPlan } = useInternetPlan(configState.planSku || urlPlanSku || undefined);
|
||||
const { data: internetData, isLoading: internetLoading } = useAccountInternetCatalog();
|
||||
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
|
||||
useEffect(() => {
|
||||
|
||||
@ -6,57 +6,106 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/api";
|
||||
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
|
||||
* Fetches plans and installations together
|
||||
* Internet catalog (public vs account)
|
||||
*/
|
||||
export function useInternetCatalog() {
|
||||
export function usePublicInternetCatalog() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.services.internet.combined(),
|
||||
queryFn: () => servicesService.getInternetCatalog(),
|
||||
queryKey: withScope(queryKeys.services.internet.combined(), "public"),
|
||||
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
|
||||
* Fetches plans, activation fees, and addons together
|
||||
* SIM catalog (public vs account)
|
||||
*/
|
||||
export function useSimCatalog() {
|
||||
export function usePublicSimCatalog() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.services.sim.combined(),
|
||||
queryFn: () => servicesService.getSimCatalog(),
|
||||
queryKey: withScope(queryKeys.services.sim.combined(), "public"),
|
||||
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
|
||||
* Fetches VPN plans and activation fees
|
||||
* VPN catalog (public vs account)
|
||||
*/
|
||||
export function useVpnCatalog() {
|
||||
export function usePublicVpnCatalog() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.services.vpn.combined(),
|
||||
queryFn: () => servicesService.getVpnCatalog(),
|
||||
queryKey: withScope(queryKeys.services.vpn.combined(), "public"),
|
||||
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) {
|
||||
const { data, ...rest } = useInternetCatalog();
|
||||
export function usePublicInternetPlan(sku?: string) {
|
||||
const { data, ...rest } = usePublicInternetCatalog();
|
||||
const plan = (data?.plans || []).find(p => p.sku === sku);
|
||||
return { plan, ...rest } as const;
|
||||
}
|
||||
|
||||
export function useSimPlan(sku?: string) {
|
||||
const { data, ...rest } = useSimCatalog();
|
||||
export function useAccountInternetPlan(sku?: string) {
|
||||
const { data, ...rest } = useAccountInternetCatalog();
|
||||
const plan = (data?.plans || []).find(p => p.sku === sku);
|
||||
return { plan, ...rest } as const;
|
||||
}
|
||||
|
||||
export function useVpnPlan(sku?: string) {
|
||||
const { data, ...rest } = useVpnCatalog();
|
||||
export function usePublicSimPlan(sku?: string) {
|
||||
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);
|
||||
return { plan, ...rest } as const;
|
||||
}
|
||||
@ -64,20 +113,20 @@ export function useVpnPlan(sku?: string) {
|
||||
/**
|
||||
* Addon/installation lookup helpers by SKU
|
||||
*/
|
||||
export function useInternetInstallation(sku?: string) {
|
||||
const { data, ...rest } = useInternetCatalog();
|
||||
export function useAccountInternetInstallation(sku?: string) {
|
||||
const { data, ...rest } = useAccountInternetCatalog();
|
||||
const installation = (data?.installations || []).find(i => i.sku === sku);
|
||||
return { installation, ...rest } as const;
|
||||
}
|
||||
|
||||
export function useInternetAddon(sku?: string) {
|
||||
const { data, ...rest } = useInternetCatalog();
|
||||
export function useAccountInternetAddon(sku?: string) {
|
||||
const { data, ...rest } = useAccountInternetCatalog();
|
||||
const addon = (data?.addons || []).find(a => a.sku === sku);
|
||||
return { addon, ...rest } as const;
|
||||
}
|
||||
|
||||
export function useSimAddon(sku?: string) {
|
||||
const { data, ...rest } = useSimCatalog();
|
||||
export function useAccountSimAddon(sku?: string) {
|
||||
const { data, ...rest } = useAccountSimCatalog();
|
||||
const addon = (data?.addons || []).find(a => a.sku === sku);
|
||||
return { addon, ...rest } as const;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSimCatalog, useSimPlan } from ".";
|
||||
import { useAccountSimCatalog } from ".";
|
||||
import { useCatalogStore } from "../services/services.store";
|
||||
import { useServicesBasePath } from "./useServicesBasePath";
|
||||
import {
|
||||
@ -68,8 +68,12 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||
const lastRestoredSignatureRef = useRef<string | null>(null);
|
||||
|
||||
// Fetch services data from BFF
|
||||
const { data: simData, isLoading: simLoading } = useSimCatalog();
|
||||
const { plan: selectedPlan } = useSimPlan(configState.planSku || urlPlanSku || planId);
|
||||
const { data: simData, isLoading: simLoading } = useAccountSimCatalog();
|
||||
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
|
||||
useEffect(() => {
|
||||
|
||||
@ -21,8 +21,14 @@ import {
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
|
||||
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>(
|
||||
response,
|
||||
"Failed to load internet services"
|
||||
@ -30,6 +36,52 @@ export const servicesService = {
|
||||
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[]> {
|
||||
const response = await apiClient.GET<InternetInstallationCatalogItem[]>(
|
||||
"/api/services/internet/installations"
|
||||
@ -46,10 +98,11 @@ export const servicesService = {
|
||||
return internetAddonCatalogItemSchema.array().parse(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* @deprecated Use getPublicSimCatalog() or getAccountSimCatalog() for clear separation.
|
||||
*/
|
||||
async getSimCatalog(): Promise<SimCatalogCollection> {
|
||||
const response = await apiClient.GET<SimCatalogCollection>("/api/services/sim/plans");
|
||||
const data = getDataOrDefault<SimCatalogCollection>(response, EMPTY_SIM_CATALOG);
|
||||
return data; // BFF already validated
|
||||
return this.getPublicSimCatalog();
|
||||
},
|
||||
|
||||
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||
@ -66,10 +119,11 @@ export const servicesService = {
|
||||
return simCatalogProductSchema.array().parse(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* @deprecated Use getPublicVpnCatalog() or getAccountVpnCatalog() for clear separation.
|
||||
*/
|
||||
async getVpnCatalog(): Promise<VpnCatalogCollection> {
|
||||
const response = await apiClient.GET<VpnCatalogCollection>("/api/services/vpn/plans");
|
||||
const data = getDataOrDefault<VpnCatalogCollection>(response, EMPTY_VPN_CATALOG);
|
||||
return data; // BFF already validated
|
||||
return this.getPublicVpnCatalog();
|
||||
},
|
||||
|
||||
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||
|
||||
@ -1,15 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { logger } from "@/lib/logger";
|
||||
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 { Spinner } from "@/components/atoms/Spinner";
|
||||
|
||||
export function InternetConfigureContainer() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const eligibilityQuery = useInternetEligibility();
|
||||
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
|
||||
logger.debug("InternetConfigure state", {
|
||||
plan: vm.plan?.sku,
|
||||
|
||||
@ -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'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'll email you once our team completes the manual serviceability check.
|
||||
</span>
|
||||
</div>
|
||||
</AlertBanner>
|
||||
)}
|
||||
{isEligible && (
|
||||
<AlertBanner variant="success" title="You’re 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't available at your address. Please contact support
|
||||
if you think this is incorrect.
|
||||
</AlertBanner>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InternetEligibilityRequestSubmittedView;
|
||||
@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
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 type {
|
||||
InternetPlanCatalogItem,
|
||||
@ -119,6 +119,7 @@ function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): Ti
|
||||
const config = tierDescriptions[tier];
|
||||
result.push({
|
||||
tier,
|
||||
planSku: plan.sku,
|
||||
monthlyPrice: plan.monthlyPrice ?? 0,
|
||||
description: config.description,
|
||||
features: config.features,
|
||||
@ -294,7 +295,7 @@ export function InternetPlansContainer() {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const searchParams = useSearchParams();
|
||||
const { user } = useAuthSession();
|
||||
const { data, isLoading, error } = useInternetCatalog();
|
||||
const { data, isLoading, error } = useAccountInternetCatalog();
|
||||
const eligibilityQuery = useInternetEligibility();
|
||||
const eligibilityLoading = eligibilityQuery.isLoading;
|
||||
const refetchEligibility = eligibilityQuery.refetch;
|
||||
@ -401,7 +402,18 @@ export function InternetPlansContainer() {
|
||||
window.confirm(`Request availability check for:\n\n${addressLabel}`);
|
||||
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
|
||||
@ -432,11 +444,13 @@ export function InternetPlansContainer() {
|
||||
setAutoRequestId(result.requestId ?? null);
|
||||
setAutoRequestStatus("submitted");
|
||||
await refetchEligibility();
|
||||
const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : "";
|
||||
router.replace(`${servicesBasePath}/internet/request-submitted${query}`);
|
||||
return;
|
||||
} catch {
|
||||
setAutoRequestStatus("failed");
|
||||
} finally {
|
||||
router.replace(`${servicesBasePath}/internet`);
|
||||
}
|
||||
router.replace(`${servicesBasePath}/internet`);
|
||||
};
|
||||
|
||||
void submit();
|
||||
|
||||
@ -5,7 +5,7 @@ import { WifiIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon } from "@heroicons/r
|
||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
||||
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 { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
|
||||
@ -18,7 +18,7 @@ export function PublicInternetConfigureView() {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const searchParams = useSearchParams();
|
||||
const planSku = searchParams?.get("planSku");
|
||||
const { plan, isLoading } = useInternetPlan(planSku || undefined);
|
||||
const { plan, isLoading } = usePublicInternetPlan(planSku || undefined);
|
||||
|
||||
const redirectTo = planSku
|
||||
? `/account/services/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}`
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
Wrench,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { useInternetCatalog } from "@/features/services/hooks";
|
||||
import { usePublicInternetCatalog } from "@/features/services/hooks";
|
||||
import type {
|
||||
InternetPlanCatalogItem,
|
||||
InternetInstallationCatalogItem,
|
||||
@ -133,7 +133,7 @@ export function PublicInternetPlansContent({
|
||||
heroTitle = "Internet Service Plans",
|
||||
heroDescription = "NTT Optical Fiber with full English support",
|
||||
}: PublicInternetPlansContentProps) {
|
||||
const { data: servicesCatalog, isLoading, error } = useInternetCatalog();
|
||||
const { data: servicesCatalog, isLoading, error } = usePublicInternetCatalog();
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const defaultCtaPath = `${servicesBasePath}/internet/configure`;
|
||||
const ctaPath = propCtaPath ?? defaultCtaPath;
|
||||
|
||||
@ -5,7 +5,7 @@ import { DevicePhoneMobileIcon, CheckIcon, BoltIcon } from "@heroicons/react/24/
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||
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 { CardPricing } from "@/features/services/components/base/CardPricing";
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
@ -20,7 +20,7 @@ export function PublicSimConfigureView() {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const searchParams = useSearchParams();
|
||||
const planSku = searchParams?.get("planSku");
|
||||
const { plan, isLoading } = useSimPlan(planSku || undefined);
|
||||
const { plan, isLoading } = usePublicSimPlan(planSku || undefined);
|
||||
|
||||
const redirectTarget = planSku
|
||||
? `/account/services/sim/configure?planSku=${encodeURIComponent(planSku)}`
|
||||
|
||||
@ -1,554 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
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 { useRouter } from "next/navigation";
|
||||
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 { CardPricing } from "@/features/services/components/base/CardPricing";
|
||||
import {
|
||||
ServiceHighlights,
|
||||
HighlightFeature,
|
||||
} from "@/features/services/components/base/ServiceHighlights";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
SimPlansContent,
|
||||
type SimPlansTab,
|
||||
} from "@/features/services/components/sim/SimPlansContent";
|
||||
|
||||
/**
|
||||
* Public SIM Plans View
|
||||
*
|
||||
* 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() {
|
||||
const router = useRouter();
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const { data, isLoading, error } = useSimCatalog();
|
||||
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
||||
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
|
||||
"data-voice"
|
||||
);
|
||||
const { data, isLoading, error } = usePublicSimCatalog();
|
||||
const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
||||
const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice");
|
||||
|
||||
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 (
|
||||
<div className="max-w-6xl mx-auto px-4 pb-16">
|
||||
<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 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>
|
||||
<SimPlansContent
|
||||
variant="public"
|
||||
plans={plans}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onSelectPlan={handleSelectPlan}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheck, Zap } from "lucide-react";
|
||||
import { useVpnCatalog } from "@/features/services/hooks";
|
||||
import { usePublicVpnCatalog } from "@/features/services/hooks";
|
||||
import { LoadingCard } from "@/components/atoms";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
@ -17,7 +17,7 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa
|
||||
*/
|
||||
export function PublicVpnPlansView() {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const { data, isLoading, error } = useVpnCatalog();
|
||||
const { data, isLoading, error } = usePublicVpnCatalog();
|
||||
const vpnPlans = data?.plans || [];
|
||||
const activationFees = data?.activationFees || [];
|
||||
|
||||
|
||||
@ -1,616 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, 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 { useMemo, useState } from "react";
|
||||
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 {
|
||||
ServiceHighlights,
|
||||
HighlightFeature,
|
||||
} from "@/features/services/components/base/ServiceHighlights";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
SimPlansContent,
|
||||
type SimPlansTab,
|
||||
} from "@/features/services/components/sim/SimPlansContent";
|
||||
|
||||
/**
|
||||
* Account SIM Plans Container
|
||||
*
|
||||
* Fetches account context (payment methods + personalised catalog) and
|
||||
* renders the shared SIM plans UI.
|
||||
*/
|
||||
export function SimPlansContainer() {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, error } = useSimCatalog();
|
||||
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
||||
const [hasExistingSim, setHasExistingSim] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
|
||||
"data-voice"
|
||||
);
|
||||
const { data: paymentMethods, isLoading: paymentMethodsLoading } = usePaymentMethods();
|
||||
|
||||
useEffect(() => {
|
||||
setHasExistingSim(simPlans.some(p => p.simHasFamilyDiscount));
|
||||
}, [simPlans]);
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const { data, isLoading, error } = useAccountSimCatalog();
|
||||
const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
||||
const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice");
|
||||
|
||||
const handleSelectPlan = (planSku: string) => {
|
||||
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 (
|
||||
<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 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>
|
||||
<SimPlansContent
|
||||
variant="account"
|
||||
plans={plans}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onSelectPlan={handleSelectPlan}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
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 { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
@ -13,7 +13,7 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa
|
||||
|
||||
export function VpnPlansView() {
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const { data, isLoading, error } = useVpnCatalog();
|
||||
const { data, isLoading, error } = useAccountVpnCatalog();
|
||||
const vpnPlans = data?.plans || [];
|
||||
const activationFees = data?.activationFees || [];
|
||||
|
||||
|
||||
@ -7,8 +7,9 @@
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { isApiError } from "@/lib/api/runtime/client";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
|
||||
interface QueryProviderProps {
|
||||
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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
|
||||
@ -41,7 +41,7 @@ Core system design documents:
|
||||
| -------------------------------------------------------------- | ------------------------- |
|
||||
| [System Overview](./architecture/system-overview.md) | High-level architecture |
|
||||
| [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 |
|
||||
| [Domain Layer](./architecture/domain-layer.md) | Domain-driven 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 |
|
||||
| [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 |
|
||||
| [Billing & Payments](./how-it-works/billing-and-payments.md) | Invoices and payments |
|
||||
| [Subscriptions](./how-it-works/subscriptions.md) | Active services |
|
||||
|
||||
@ -59,7 +59,7 @@ packages/domain/
|
||||
│ │ ├── raw.types.ts # Salesforce Order/OrderItem
|
||||
│ │ └── mapper.ts # Transform Salesforce → OrderDetails
|
||||
│ └── index.ts
|
||||
├── catalog/
|
||||
├── services/
|
||||
│ ├── contract.ts # CatalogProduct, InternetPlan, SimProduct, VpnProduct
|
||||
│ ├── schema.ts
|
||||
│ ├── providers/
|
||||
@ -90,7 +90,7 @@ import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
|
||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||
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
|
||||
@ -233,11 +233,11 @@ External API Request
|
||||
- **Providers**: WHMCS (provisioning), Salesforce (order management)
|
||||
- **Use Cases**: Order fulfillment, order history, order details
|
||||
|
||||
### Catalog
|
||||
### Services
|
||||
|
||||
- **Contracts**: `InternetPlanCatalogItem`, `SimCatalogProduct`, `VpnCatalogProduct`
|
||||
- **Providers**: Salesforce (Product2)
|
||||
- **Use Cases**: Product catalog display, product selection
|
||||
- **Use Cases**: Product catalog display, product selection, service browsing
|
||||
|
||||
### Common
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ src/
|
||||
users/ # User management
|
||||
me-status/ # Aggregated customer status (dashboard + gating signals)
|
||||
id-mappings/ # Portal-WHMCS-Salesforce ID mappings
|
||||
catalog/ # Product catalog
|
||||
services/ # Services catalog (browsing/purchasing)
|
||||
orders/ # Order creation and fulfillment
|
||||
invoices/ # Invoice management
|
||||
subscriptions/ # Service and subscription management
|
||||
@ -96,6 +96,11 @@ src/
|
||||
- Reuse `packages/domain` for domain types
|
||||
- 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**
|
||||
|
||||
### **Domain Package (`packages/domain/`)**
|
||||
@ -106,7 +111,7 @@ The domain package is the single source of truth for shared types, validation sc
|
||||
packages/domain/
|
||||
├── auth/ # Authentication types and validation
|
||||
├── billing/ # Invoice and payment types
|
||||
├── catalog/ # Product catalog types
|
||||
├── services/ # Services catalog types
|
||||
├── checkout/ # Checkout flow types
|
||||
├── common/ # Shared utilities and base types
|
||||
├── customer/ # Customer profile types
|
||||
|
||||
@ -74,8 +74,8 @@ packages/domain/
|
||||
│ ├── raw.types.ts
|
||||
│ └── mapper.ts
|
||||
│
|
||||
├── catalog/
|
||||
│ ├── contract.ts # CatalogProduct (UI view model)
|
||||
├── services/
|
||||
│ ├── contract.ts # Service catalog products (UI view model)
|
||||
│ ├── schema.ts
|
||||
│ ├── index.ts
|
||||
│ └── providers/
|
||||
|
||||
@ -223,7 +223,7 @@ function isAddonProduct(product: Product): boolean {
|
||||
const product = fromSalesforceProduct2(salesforceProduct, pricebookEntry);
|
||||
|
||||
if (isCatalogVisible(product)) {
|
||||
displayInCatalog(product);
|
||||
displayInServices(product);
|
||||
}
|
||||
|
||||
// Type-safe access to specific fields
|
||||
|
||||
@ -21,7 +21,7 @@ apps/portal/src/
|
||||
│ ├── account/
|
||||
│ ├── auth/
|
||||
│ ├── billing/
|
||||
│ ├── catalog/
|
||||
│ ├── services/
|
||||
│ ├── dashboard/
|
||||
│ ├── marketing/
|
||||
│ ├── orders/
|
||||
@ -209,9 +209,9 @@ Only `layout.tsx`, `page.tsx`, and `loading.tsx` files live inside the route gro
|
||||
|
||||
### Current Feature Hooks/Services
|
||||
|
||||
- Catalog
|
||||
- Services
|
||||
- Hooks: `useInternetCatalog`, `useSimCatalog`, `useVpnCatalog`, `useProducts*`, `useCalculateOrder`, `useSubmitOrder`
|
||||
- Service: `catalogService` (internet/sim/vpn endpoints consolidated)
|
||||
- Service: `servicesService` (internet/sim/vpn endpoints consolidated)
|
||||
- Billing
|
||||
- Hooks: `useInvoices`, `usePaymentMethods`, `usePaymentGateways`, `usePaymentRefresh`
|
||||
- Service: `BillingService`
|
||||
|
||||
@ -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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [Orders & Provisioning](./orders-and-provisioning.md) | Order lifecycle in Salesforce → WHMCS fulfillment |
|
||||
| [Billing & Payments](./billing-and-payments.md) | Invoices, payment methods, billing links |
|
||||
|
||||
@ -20,12 +20,25 @@ This guide describes how eligibility and verification work in the customer porta
|
||||
### How It Works
|
||||
|
||||
1. Customer navigates to `/account/services/internet`
|
||||
2. Customer enters service address and requests eligibility check
|
||||
3. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review
|
||||
4. Agent performs NTT serviceability check (manual process)
|
||||
5. Agent updates Account eligibility fields
|
||||
6. Salesforce Flow sends email notification to customer
|
||||
7. Customer returns and sees eligible plans
|
||||
2. Customer clicks **Check Availability** (requires a service address on file)
|
||||
3. Portal calls `POST /api/services/internet/eligibility-request` and shows an immediate confirmation screen at `/account/services/internet/request-submitted`
|
||||
4. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review
|
||||
5. Agent performs NTT serviceability check (manual process)
|
||||
6. Agent updates Account eligibility fields
|
||||
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
|
||||
|
||||
@ -50,12 +63,15 @@ const isInternetService =
|
||||
| `Internet_Eligibility__c` | Text | Agent | After check |
|
||||
| `Internet_Eligibility_Request_Date_Time__c` | DateTime | Portal | On request |
|
||||
| `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 | Shop Page UI | Checkout Gating |
|
||||
| Status | Services Page UI | Checkout Gating |
|
||||
| --------------- | --------------------------------------- | --------------- |
|
||||
| `Not Requested` | Show "Request eligibility check" button | Block submit |
|
||||
| `Pending` | Show "Review in progress" | Block submit |
|
||||
@ -108,11 +124,12 @@ The Profile page is the primary location. The standalone page is used when redir
|
||||
|
||||
## Portal UI Locations
|
||||
|
||||
| Location | What's Shown |
|
||||
| ---------------------------- | ----------------------------------------------- |
|
||||
| `/account/settings` | Profile, Address, ID Verification (with upload) |
|
||||
| `/account/services/internet` | Eligibility status and eligible plans |
|
||||
| Subscription detail | Service-specific actions (cancel, etc.) |
|
||||
| Location | What's Shown |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `/account/settings` | Profile, Address, ID Verification (with upload) |
|
||||
| `/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.) |
|
||||
|
||||
## Cancellation Flow
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# Catalog & Checkout
|
||||
# Services & Checkout
|
||||
|
||||
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.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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).
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- 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).
|
||||
- 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.
|
||||
@ -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.
|
||||
- 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).
|
||||
- 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.
|
||||
@ -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.
|
||||
- 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.
|
||||
- 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.”
|
||||
@ -29,7 +29,7 @@ Purpose: explain what the portal does, which systems own which data, and how fre
|
||||
|
||||
## 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.
|
||||
- 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).
|
||||
@ -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.
|
||||
- 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.
|
||||
|
||||
## 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`).
|
||||
|
||||
@ -70,8 +70,11 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
||||
| Status | `Internet_Eligibility_Status__c` | Pending, Checked |
|
||||
| Requested At | `Internet_Eligibility_Request_Date_Time__c` | When request was made |
|
||||
| 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:**
|
||||
|
||||
@ -179,6 +182,7 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
||||
│ │
|
||||
│ 1. CUSTOMER ENTERS ADDRESS │
|
||||
│ └─ Portal: POST /api/services/internet/eligibility-request │
|
||||
│ Portal UI: shows confirmation at /account/services/internet/request-submitted │
|
||||
│ │
|
||||
│ 2. CHECK IF ELIGIBILITY ALREADY KNOWN │
|
||||
│ └─ Query: SELECT Internet_Eligibility__c FROM Account │
|
||||
@ -215,7 +219,6 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
||||
│ 5. UPDATE ACCOUNT │
|
||||
│ └─ Internet_Eligibility_Status__c = "Pending" │
|
||||
│ └─ Internet_Eligibility_Request_Date_Time__c = now() │
|
||||
│ └─ Internet_Eligibility_Case_Id__c = Case.Id │
|
||||
│ │
|
||||
│ 6. CS PROCESSES CASE │
|
||||
│ └─ Checks with NTT / provider │
|
||||
@ -223,9 +226,9 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
||||
│ - Internet_Eligibility__c = result │
|
||||
│ - Internet_Eligibility_Status__c = "Checked" │
|
||||
│ - Internet_Eligibility_Checked_Date_Time__c = now() │
|
||||
│ └─ Updates Opportunity: │
|
||||
│ - Eligible → Stage: Ready │
|
||||
│ - Not Eligible → Stage: Void │
|
||||
│ └─ Opportunity stages are not automatically moved to "Ready" by │
|
||||
│ the eligibility check. CS updates stages later as part of the │
|
||||
│ order review / lifecycle workflow. │
|
||||
│ │
|
||||
│ 7. PORTAL DETECTS CHANGE (via CDC or polling) │
|
||||
│ └─ Shows eligibility result to customer │
|
||||
|
||||
@ -89,8 +89,11 @@ The Account stores customer information and status fields.
|
||||
| Eligibility Status | `Internet_Eligibility_Status__c` | Picklist | `Pending`, `Checked` |
|
||||
| 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 |
|
||||
| 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:**
|
||||
|
||||
|
||||
@ -191,7 +191,7 @@ pnpm update [package-name]@latest
|
||||
## 📚 Additional Resources
|
||||
|
||||
- **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)
|
||||
- **npm Security**: [https://docs.npmjs.com/security](https://docs.npmjs.com/security)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user