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
|
- **[Deployment Guide](docs/DEPLOY.md)** - Production deployment instructions
|
||||||
- **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions
|
- **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions
|
||||||
- **[Logging](docs/LOGGING.md)** - Logging configuration and best practices
|
- **[Logging](docs/LOGGING.md)** - Logging configuration and best practices
|
||||||
- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/portal-guides/README.md`)
|
- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/how-it-works/README.md`)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@ -119,8 +119,8 @@ Security audits are automatically run on:
|
|||||||
|
|
||||||
### Internal Documentation
|
### Internal Documentation
|
||||||
|
|
||||||
- [Environment Configuration](./docs/portal-guides/COMPLETE-GUIDE.md)
|
- [Environment Configuration](./docs/how-it-works/COMPLETE-GUIDE.md)
|
||||||
- [Deployment Guide](./docs/portal-guides/)
|
- [Deployment Guide](./docs/getting-started/)
|
||||||
|
|
||||||
### External Resources
|
### External Resources
|
||||||
|
|
||||||
|
|||||||
@ -54,6 +54,20 @@ export const envSchema = z.object({
|
|||||||
"Authentication service is temporarily unavailable for maintenance. Please try again later."
|
"Authentication service is temporarily unavailable for maintenance. Please try again later."
|
||||||
),
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Services catalog/eligibility cache safety TTL.
|
||||||
|
*
|
||||||
|
* Primary invalidation is event-driven (Salesforce CDC / Platform Events).
|
||||||
|
* This TTL is a safety net to self-heal if events are missed.
|
||||||
|
*
|
||||||
|
* Set to 0 to disable safety TTL (pure event-driven).
|
||||||
|
*/
|
||||||
|
SERVICES_CACHE_SAFETY_TTL_SECONDS: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.default(60 * 60 * 12),
|
||||||
|
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
|
|
||||||
WHMCS_BASE_URL: z.string().url().optional(),
|
WHMCS_BASE_URL: z.string().url().optional(),
|
||||||
@ -139,9 +153,6 @@ export const envSchema = z.object({
|
|||||||
ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z
|
ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z
|
||||||
.string()
|
.string()
|
||||||
.default("Internet_Eligibility_Checked_Date_Time__c"),
|
.default("Internet_Eligibility_Checked_Date_Time__c"),
|
||||||
// Note: These fields are not used in the current Salesforce environment but kept in config schema for future compatibility
|
|
||||||
ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD: z.string().default("Internet_Eligibility_Notes__c"),
|
|
||||||
ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD: z.string().default("Internet_Eligibility_Case_Id__c"),
|
|
||||||
|
|
||||||
ACCOUNT_ID_VERIFICATION_STATUS_FIELD: z.string().default("Id_Verification_Status__c"),
|
ACCOUNT_ID_VERIFICATION_STATUS_FIELD: z.string().default("Id_Verification_Status__c"),
|
||||||
ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD: z
|
ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD: z
|
||||||
|
|||||||
@ -2,3 +2,12 @@ import { SetMetadata } from "@nestjs/common";
|
|||||||
|
|
||||||
export const IS_PUBLIC_KEY = "isPublic";
|
export const IS_PUBLIC_KEY = "isPublic";
|
||||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a route/controller as public *and* disables optional session attachment.
|
||||||
|
*
|
||||||
|
* Why: some endpoints must be strictly non-personalized for caching/security correctness
|
||||||
|
* (e.g. public service catalogs). These endpoints should ignore cookies/tokens entirely.
|
||||||
|
*/
|
||||||
|
export const IS_PUBLIC_NO_SESSION_KEY = "isPublicNoSession";
|
||||||
|
export const PublicNoSession = () => SetMetadata(IS_PUBLIC_NO_SESSION_KEY, true);
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core";
|
|||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js";
|
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js";
|
||||||
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator.js";
|
import { IS_PUBLIC_KEY, IS_PUBLIC_NO_SESSION_KEY } from "../../../decorators/public.decorator.js";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
|
import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
|
||||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||||
@ -45,8 +45,17 @@ export class GlobalAuthGuard implements CanActivate {
|
|||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
context.getClass(),
|
context.getClass(),
|
||||||
]);
|
]);
|
||||||
|
const isPublicNoSession = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_NO_SESSION_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (isPublic) {
|
if (isPublic) {
|
||||||
|
if (isPublicNoSession) {
|
||||||
|
this.logger.debug(`Strict public route accessed (no session attach): ${route}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const token = extractAccessTokenFromRequest(request);
|
const token = extractAccessTokenFromRequest(request);
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
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 { ZodValidationPipe } from "nestjs-zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||||
@ -31,6 +31,7 @@ export class InternetEligibilityController {
|
|||||||
|
|
||||||
@Get("eligibility")
|
@Get("eligibility")
|
||||||
@RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap)
|
@RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap)
|
||||||
|
@Header("Cache-Control", "private, no-store")
|
||||||
async getEligibility(@Req() req: RequestWithUser): Promise<InternetEligibilityDetails> {
|
async getEligibility(@Req() req: RequestWithUser): Promise<InternetEligibilityDetails> {
|
||||||
return this.internetCatalog.getEligibilityDetailsForUser(req.user.id);
|
return this.internetCatalog.getEligibilityDetailsForUser(req.user.id);
|
||||||
}
|
}
|
||||||
@ -38,6 +39,7 @@ export class InternetEligibilityController {
|
|||||||
@Post("eligibility-request")
|
@Post("eligibility-request")
|
||||||
@RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP
|
@RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP
|
||||||
@UsePipes(new ZodValidationPipe(eligibilityRequestSchema))
|
@UsePipes(new ZodValidationPipe(eligibilityRequestSchema))
|
||||||
|
@Header("Cache-Control", "private, no-store")
|
||||||
async requestEligibility(
|
async requestEligibility(
|
||||||
@Req() req: RequestWithUser,
|
@Req() req: RequestWithUser,
|
||||||
@Body() body: EligibilityRequest
|
@Body() body: EligibilityRequest
|
||||||
|
|||||||
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 {
|
import {
|
||||||
parseInternetCatalog,
|
parseInternetCatalog,
|
||||||
parseSimCatalog,
|
parseSimCatalog,
|
||||||
|
parseVpnCatalog,
|
||||||
type InternetAddonCatalogItem,
|
type InternetAddonCatalogItem,
|
||||||
type InternetInstallationCatalogItem,
|
type InternetInstallationCatalogItem,
|
||||||
type InternetPlanCatalogItem,
|
type InternetPlanCatalogItem,
|
||||||
@ -12,6 +13,7 @@ import {
|
|||||||
type SimCatalogCollection,
|
type SimCatalogCollection,
|
||||||
type SimCatalogProduct,
|
type SimCatalogProduct,
|
||||||
type VpnCatalogProduct,
|
type VpnCatalogProduct,
|
||||||
|
type VpnCatalogCollection,
|
||||||
} from "@customer-portal/domain/services";
|
} from "@customer-portal/domain/services";
|
||||||
import { InternetServicesService } from "./services/internet-services.service.js";
|
import { InternetServicesService } from "./services/internet-services.service.js";
|
||||||
import { SimServicesService } from "./services/sim-services.service.js";
|
import { SimServicesService } from "./services/sim-services.service.js";
|
||||||
@ -100,8 +102,10 @@ export class ServicesController {
|
|||||||
@Get("vpn/plans")
|
@Get("vpn/plans")
|
||||||
@RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute
|
@RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute
|
||||||
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
@Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes
|
||||||
async getVpnPlans(): Promise<VpnCatalogProduct[]> {
|
async getVpnPlans(): Promise<VpnCatalogCollection> {
|
||||||
return this.vpnCatalog.getPlans();
|
// Backwards-compatible: return the full VPN catalog (plans + activation fees)
|
||||||
|
const catalog = await this.vpnCatalog.getCatalogData();
|
||||||
|
return parseVpnCatalog(catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("vpn/activation-fees")
|
@Get("vpn/activation-fees")
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { Module, forwardRef } from "@nestjs/common";
|
|||||||
import { ServicesController } from "./services.controller.js";
|
import { ServicesController } from "./services.controller.js";
|
||||||
import { ServicesHealthController } from "./services-health.controller.js";
|
import { ServicesHealthController } from "./services-health.controller.js";
|
||||||
import { InternetEligibilityController } from "./internet-eligibility.controller.js";
|
import { InternetEligibilityController } from "./internet-eligibility.controller.js";
|
||||||
|
import { PublicServicesController } from "./public-services.controller.js";
|
||||||
|
import { AccountServicesController } from "./account-services.controller.js";
|
||||||
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
|
import { IntegrationsModule } from "@bff/integrations/integrations.module.js";
|
||||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||||
import { CoreConfigModule } from "@bff/core/config/config.module.js";
|
import { CoreConfigModule } from "@bff/core/config/config.module.js";
|
||||||
@ -22,7 +24,13 @@ import { ServicesCacheService } from "./services/services-cache.service.js";
|
|||||||
CacheModule,
|
CacheModule,
|
||||||
QueueModule,
|
QueueModule,
|
||||||
],
|
],
|
||||||
controllers: [ServicesController, ServicesHealthController, InternetEligibilityController],
|
controllers: [
|
||||||
|
ServicesController,
|
||||||
|
PublicServicesController,
|
||||||
|
AccountServicesController,
|
||||||
|
ServicesHealthController,
|
||||||
|
InternetEligibilityController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
BaseServicesService,
|
BaseServicesService,
|
||||||
InternetServicesService,
|
InternetServicesService,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
||||||
import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js";
|
import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js";
|
||||||
|
|
||||||
@ -43,10 +44,10 @@ interface LegacyCatalogCachePayload<T> {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ServicesCacheService {
|
export class ServicesCacheService {
|
||||||
// CDC-driven invalidation: null TTL means cache persists until explicit invalidation
|
// CDC-driven invalidation + safety TTL (self-heal if events are missed)
|
||||||
private readonly SERVICES_TTL: number | null = null;
|
private readonly SERVICES_TTL: number | null;
|
||||||
private readonly STATIC_TTL: number | null = null;
|
private readonly STATIC_TTL: number | null;
|
||||||
private readonly ELIGIBILITY_TTL: number | null = null;
|
private readonly ELIGIBILITY_TTL: number | null;
|
||||||
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
|
private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL
|
||||||
|
|
||||||
private readonly metrics: ServicesCacheSnapshot = {
|
private readonly metrics: ServicesCacheSnapshot = {
|
||||||
@ -61,10 +62,21 @@ export class ServicesCacheService {
|
|||||||
// request the same data after CDC invalidation
|
// request the same data after CDC invalidation
|
||||||
private readonly inflightRequests = new Map<string, Promise<unknown>>();
|
private readonly inflightRequests = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
constructor(private readonly cache: CacheService) {}
|
constructor(
|
||||||
|
private readonly cache: CacheService,
|
||||||
|
private readonly config: ConfigService
|
||||||
|
) {
|
||||||
|
const raw = this.config.get<number>("SERVICES_CACHE_SAFETY_TTL_SECONDS", 60 * 60 * 12);
|
||||||
|
const ttl = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null;
|
||||||
|
|
||||||
|
// Apply to CDC-driven buckets (catalog + static + eligibility)
|
||||||
|
this.SERVICES_TTL = ttl;
|
||||||
|
this.STATIC_TTL = ttl;
|
||||||
|
this.ELIGIBILITY_TTL = ttl;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or fetch catalog data (CDC-driven cache, no TTL)
|
* Get or fetch catalog data (CDC-driven cache with safety TTL)
|
||||||
*/
|
*/
|
||||||
async getCachedServices<T>(
|
async getCachedServices<T>(
|
||||||
key: string,
|
key: string,
|
||||||
@ -75,7 +87,7 @@ export class ServicesCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or fetch static catalog data (CDC-driven cache, no TTL)
|
* Get or fetch static catalog data (CDC-driven cache with safety TTL)
|
||||||
*/
|
*/
|
||||||
async getCachedStatic<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
async getCachedStatic<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
||||||
return this.getOrSet("static", key, this.STATIC_TTL, fetchFn);
|
return this.getOrSet("static", key, this.STATIC_TTL, fetchFn);
|
||||||
@ -89,7 +101,7 @@ export class ServicesCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or fetch eligibility data (CDC-driven cache, no TTL)
|
* Get or fetch eligibility data (event-driven cache with safety TTL)
|
||||||
*/
|
*/
|
||||||
async getCachedEligibility<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
async getCachedEligibility<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
|
||||||
return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, {
|
return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, {
|
||||||
|
|||||||
@ -184,7 +184,15 @@ export class SimServicesService extends BaseServicesService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check WHMCS for existing SIM services
|
const cacheKey = this.catalogCache.buildServicesKey(
|
||||||
|
"sim",
|
||||||
|
"has-existing-sim",
|
||||||
|
String(mapping.whmcsClientId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// This is per-account and can be somewhat expensive (WHMCS call).
|
||||||
|
// Cache briefly to reduce repeat reads during account page refreshes.
|
||||||
|
return await this.catalogCache.getCachedVolatile(cacheKey, async () => {
|
||||||
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId });
|
const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId });
|
||||||
const services = (products?.products?.product || []) as Array<{
|
const services = (products?.products?.product || []) as Array<{
|
||||||
groupname?: string;
|
groupname?: string;
|
||||||
@ -192,14 +200,12 @@ export class SimServicesService extends BaseServicesService {
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
// Look for active SIM services
|
// Look for active SIM services
|
||||||
const hasActiveSim = services.some(
|
return services.some(service => {
|
||||||
service =>
|
const group = String(service.groupname || "").toLowerCase();
|
||||||
String(service.groupname || "")
|
const status = String(service.status || "").toLowerCase();
|
||||||
.toLowerCase()
|
return group.includes("sim") && status === "active";
|
||||||
.includes("sim") && String(service.status || "").toLowerCase() === "active"
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
return hasActiveSim;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Failed to check existing SIM for user ${userId}`, error);
|
this.logger.warn(`Failed to check existing SIM for user ${userId}`, error);
|
||||||
return false; // Default to no existing SIM
|
return false; // Default to no existing SIM
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||||
import { BaseServicesService } from "./base-services.service.js";
|
import { BaseServicesService } from "./base-services.service.js";
|
||||||
|
import { ServicesCacheService } from "./services-cache.service.js";
|
||||||
import type {
|
import type {
|
||||||
SalesforceProduct2WithPricebookEntries,
|
SalesforceProduct2WithPricebookEntries,
|
||||||
VpnCatalogProduct,
|
VpnCatalogProduct,
|
||||||
@ -14,11 +15,17 @@ export class VpnServicesService extends BaseServicesService {
|
|||||||
constructor(
|
constructor(
|
||||||
sf: SalesforceConnection,
|
sf: SalesforceConnection,
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
@Inject(Logger) logger: Logger
|
@Inject(Logger) logger: Logger,
|
||||||
|
private readonly catalogCache: ServicesCacheService
|
||||||
) {
|
) {
|
||||||
super(sf, configService, logger);
|
super(sf, configService, logger);
|
||||||
}
|
}
|
||||||
async getPlans(): Promise<VpnCatalogProduct[]> {
|
async getPlans(): Promise<VpnCatalogProduct[]> {
|
||||||
|
const cacheKey = this.catalogCache.buildServicesKey("vpn", "plans");
|
||||||
|
|
||||||
|
return this.catalogCache.getCachedServices(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
const soql = this.buildServicesQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]);
|
const soql = this.buildServicesQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]);
|
||||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||||
soql,
|
soql,
|
||||||
@ -33,9 +40,21 @@ export class VpnServicesService extends BaseServicesService {
|
|||||||
description: product.description || product.name,
|
description: product.description || product.name,
|
||||||
} satisfies VpnCatalogProduct;
|
} satisfies VpnCatalogProduct;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolveDependencies: plans => ({
|
||||||
|
productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActivationFees(): Promise<VpnCatalogProduct[]> {
|
async getActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||||
|
const cacheKey = this.catalogCache.buildServicesKey("vpn", "activation-fees");
|
||||||
|
|
||||||
|
return this.catalogCache.getCachedServices(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]);
|
const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]);
|
||||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||||
soql,
|
soql,
|
||||||
@ -51,6 +70,13 @@ export class VpnServicesService extends BaseServicesService {
|
|||||||
description: product.description ?? product.name,
|
description: product.description ?? product.name,
|
||||||
} satisfies VpnCatalogProduct;
|
} satisfies VpnCatalogProduct;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolveDependencies: fees => ({
|
||||||
|
productIds: fees.map(fee => fee.id).filter((id): id is string => Boolean(id)),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCatalogData() {
|
async getCatalogData() {
|
||||||
|
|||||||
@ -6,6 +6,6 @@
|
|||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"]
|
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,6 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"]
|
"exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { RedirectAuthenticatedToAccountServices } from "@/features/services/comp
|
|||||||
export default function PublicInternetConfigurePage() {
|
export default function PublicInternetConfigurePage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RedirectAuthenticatedToAccountServices targetPath="/account/services/internet/configure" />
|
<RedirectAuthenticatedToAccountServices targetPath="/account/services/internet" />
|
||||||
<PublicInternetConfigureView />
|
<PublicInternetConfigureView />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 {
|
interface TierInfo {
|
||||||
tier: "Silver" | "Gold" | "Platinum";
|
tier: "Silver" | "Gold" | "Platinum";
|
||||||
|
planSku: string;
|
||||||
monthlyPrice: number;
|
monthlyPrice: number;
|
||||||
description: string;
|
description: string;
|
||||||
features: string[];
|
features: string[];
|
||||||
@ -62,6 +63,10 @@ export function InternetOfferingCard({
|
|||||||
previewMode = false,
|
previewMode = false,
|
||||||
}: InternetOfferingCardProps) {
|
}: InternetOfferingCardProps) {
|
||||||
const Icon = iconType === "home" ? Home : Building2;
|
const Icon = iconType === "home" ? Home : Building2;
|
||||||
|
const resolveTierHref = (basePath: string, planSku: string): string => {
|
||||||
|
const joiner = basePath.includes("?") ? "&" : "?";
|
||||||
|
return `${basePath}${joiner}planSku=${encodeURIComponent(planSku)}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -176,7 +181,7 @@ export function InternetOfferingCard({
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href={ctaPath}
|
href={resolveTierHref(ctaPath, tier.planSku)}
|
||||||
variant={tier.recommended ? "default" : "outline"}
|
variant={tier.recommended ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full mt-auto"
|
className="w-full mt-auto"
|
||||||
|
|||||||
@ -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 { useEffect, useMemo, useRef } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useInternetCatalog, useInternetPlan } from ".";
|
import { useAccountInternetCatalog } from ".";
|
||||||
import { useCatalogStore } from "../services/services.store";
|
import { useCatalogStore } from "../services/services.store";
|
||||||
import { useServicesBasePath } from "./useServicesBasePath";
|
import { useServicesBasePath } from "./useServicesBasePath";
|
||||||
import type { AccessModeValue } from "@customer-portal/domain/orders";
|
import type { AccessModeValue } from "@customer-portal/domain/orders";
|
||||||
@ -55,8 +55,12 @@ export function useInternetConfigure(): UseInternetConfigureResult {
|
|||||||
const lastRestoredSignatureRef = useRef<string | null>(null);
|
const lastRestoredSignatureRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Fetch services data from BFF
|
// Fetch services data from BFF
|
||||||
const { data: internetData, isLoading: internetLoading } = useInternetCatalog();
|
const { data: internetData, isLoading: internetLoading } = useAccountInternetCatalog();
|
||||||
const { plan: selectedPlan } = useInternetPlan(configState.planSku || urlPlanSku || undefined);
|
const selectedPlanSku = configState.planSku || urlPlanSku || undefined;
|
||||||
|
const selectedPlan = useMemo(() => {
|
||||||
|
if (!selectedPlanSku) return null;
|
||||||
|
return (internetData?.plans ?? []).find(p => p.sku === selectedPlanSku) ?? null;
|
||||||
|
}, [internetData?.plans, selectedPlanSku]);
|
||||||
|
|
||||||
// Initialize/restore state on mount
|
// Initialize/restore state on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -6,57 +6,106 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "@/lib/api";
|
import { queryKeys } from "@/lib/api";
|
||||||
import { servicesService } from "../services";
|
import { servicesService } from "../services";
|
||||||
|
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
|
type ServicesCatalogScope = "public" | "account";
|
||||||
|
|
||||||
|
function withScope<T extends readonly unknown[]>(key: T, scope: ServicesCatalogScope) {
|
||||||
|
return [...key, scope] as const;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internet services composite hook
|
* Internet catalog (public vs account)
|
||||||
* Fetches plans and installations together
|
|
||||||
*/
|
*/
|
||||||
export function useInternetCatalog() {
|
export function usePublicInternetCatalog() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.services.internet.combined(),
|
queryKey: withScope(queryKeys.services.internet.combined(), "public"),
|
||||||
queryFn: () => servicesService.getInternetCatalog(),
|
queryFn: () => servicesService.getPublicInternetCatalog(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccountInternetCatalog() {
|
||||||
|
const { isAuthenticated } = useAuthSession();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: withScope(queryKeys.services.internet.combined(), "account"),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: () => servicesService.getAccountInternetCatalog(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SIM services composite hook
|
* SIM catalog (public vs account)
|
||||||
* Fetches plans, activation fees, and addons together
|
|
||||||
*/
|
*/
|
||||||
export function useSimCatalog() {
|
export function usePublicSimCatalog() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.services.sim.combined(),
|
queryKey: withScope(queryKeys.services.sim.combined(), "public"),
|
||||||
queryFn: () => servicesService.getSimCatalog(),
|
queryFn: () => servicesService.getPublicSimCatalog(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccountSimCatalog() {
|
||||||
|
const { isAuthenticated } = useAuthSession();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: withScope(queryKeys.services.sim.combined(), "account"),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: () => servicesService.getAccountSimCatalog(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VPN services hook
|
* VPN catalog (public vs account)
|
||||||
* Fetches VPN plans and activation fees
|
|
||||||
*/
|
*/
|
||||||
export function useVpnCatalog() {
|
export function usePublicVpnCatalog() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.services.vpn.combined(),
|
queryKey: withScope(queryKeys.services.vpn.combined(), "public"),
|
||||||
queryFn: () => servicesService.getVpnCatalog(),
|
queryFn: () => servicesService.getPublicVpnCatalog(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccountVpnCatalog() {
|
||||||
|
const { isAuthenticated } = useAuthSession();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: withScope(queryKeys.services.vpn.combined(), "account"),
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: () => servicesService.getAccountVpnCatalog(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup helpers by SKU
|
* Lookup helpers by SKU (explicit scope)
|
||||||
*/
|
*/
|
||||||
export function useInternetPlan(sku?: string) {
|
export function usePublicInternetPlan(sku?: string) {
|
||||||
const { data, ...rest } = useInternetCatalog();
|
const { data, ...rest } = usePublicInternetCatalog();
|
||||||
const plan = (data?.plans || []).find(p => p.sku === sku);
|
const plan = (data?.plans || []).find(p => p.sku === sku);
|
||||||
return { plan, ...rest } as const;
|
return { plan, ...rest } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSimPlan(sku?: string) {
|
export function useAccountInternetPlan(sku?: string) {
|
||||||
const { data, ...rest } = useSimCatalog();
|
const { data, ...rest } = useAccountInternetCatalog();
|
||||||
const plan = (data?.plans || []).find(p => p.sku === sku);
|
const plan = (data?.plans || []).find(p => p.sku === sku);
|
||||||
return { plan, ...rest } as const;
|
return { plan, ...rest } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVpnPlan(sku?: string) {
|
export function usePublicSimPlan(sku?: string) {
|
||||||
const { data, ...rest } = useVpnCatalog();
|
const { data, ...rest } = usePublicSimCatalog();
|
||||||
|
const plan = (data?.plans || []).find(p => p.sku === sku);
|
||||||
|
return { plan, ...rest } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccountSimPlan(sku?: string) {
|
||||||
|
const { data, ...rest } = useAccountSimCatalog();
|
||||||
|
const plan = (data?.plans || []).find(p => p.sku === sku);
|
||||||
|
return { plan, ...rest } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePublicVpnPlan(sku?: string) {
|
||||||
|
const { data, ...rest } = usePublicVpnCatalog();
|
||||||
|
const plan = (data?.plans || []).find(p => p.sku === sku);
|
||||||
|
return { plan, ...rest } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccountVpnPlan(sku?: string) {
|
||||||
|
const { data, ...rest } = useAccountVpnCatalog();
|
||||||
const plan = (data?.plans || []).find(p => p.sku === sku);
|
const plan = (data?.plans || []).find(p => p.sku === sku);
|
||||||
return { plan, ...rest } as const;
|
return { plan, ...rest } as const;
|
||||||
}
|
}
|
||||||
@ -64,20 +113,20 @@ export function useVpnPlan(sku?: string) {
|
|||||||
/**
|
/**
|
||||||
* Addon/installation lookup helpers by SKU
|
* Addon/installation lookup helpers by SKU
|
||||||
*/
|
*/
|
||||||
export function useInternetInstallation(sku?: string) {
|
export function useAccountInternetInstallation(sku?: string) {
|
||||||
const { data, ...rest } = useInternetCatalog();
|
const { data, ...rest } = useAccountInternetCatalog();
|
||||||
const installation = (data?.installations || []).find(i => i.sku === sku);
|
const installation = (data?.installations || []).find(i => i.sku === sku);
|
||||||
return { installation, ...rest } as const;
|
return { installation, ...rest } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInternetAddon(sku?: string) {
|
export function useAccountInternetAddon(sku?: string) {
|
||||||
const { data, ...rest } = useInternetCatalog();
|
const { data, ...rest } = useAccountInternetCatalog();
|
||||||
const addon = (data?.addons || []).find(a => a.sku === sku);
|
const addon = (data?.addons || []).find(a => a.sku === sku);
|
||||||
return { addon, ...rest } as const;
|
return { addon, ...rest } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSimAddon(sku?: string) {
|
export function useAccountSimAddon(sku?: string) {
|
||||||
const { data, ...rest } = useSimCatalog();
|
const { data, ...rest } = useAccountSimCatalog();
|
||||||
const addon = (data?.addons || []).find(a => a.sku === sku);
|
const addon = (data?.addons || []).find(a => a.sku === sku);
|
||||||
return { addon, ...rest } as const;
|
return { addon, ...rest } as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useCallback, useMemo, useRef } from "react";
|
import { useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useSimCatalog, useSimPlan } from ".";
|
import { useAccountSimCatalog } from ".";
|
||||||
import { useCatalogStore } from "../services/services.store";
|
import { useCatalogStore } from "../services/services.store";
|
||||||
import { useServicesBasePath } from "./useServicesBasePath";
|
import { useServicesBasePath } from "./useServicesBasePath";
|
||||||
import {
|
import {
|
||||||
@ -68,8 +68,12 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
const lastRestoredSignatureRef = useRef<string | null>(null);
|
const lastRestoredSignatureRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Fetch services data from BFF
|
// Fetch services data from BFF
|
||||||
const { data: simData, isLoading: simLoading } = useSimCatalog();
|
const { data: simData, isLoading: simLoading } = useAccountSimCatalog();
|
||||||
const { plan: selectedPlan } = useSimPlan(configState.planSku || urlPlanSku || planId);
|
const selectedPlanSku = configState.planSku || urlPlanSku || planId;
|
||||||
|
const selectedPlan = useMemo(() => {
|
||||||
|
if (!selectedPlanSku) return null;
|
||||||
|
return (simData?.plans ?? []).find(p => p.sku === selectedPlanSku) ?? null;
|
||||||
|
}, [simData?.plans, selectedPlanSku]);
|
||||||
|
|
||||||
// Initialize/restore state on mount
|
// Initialize/restore state on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -21,8 +21,14 @@ import {
|
|||||||
import type { Address } from "@customer-portal/domain/customer";
|
import type { Address } from "@customer-portal/domain/customer";
|
||||||
|
|
||||||
export const servicesService = {
|
export const servicesService = {
|
||||||
async getInternetCatalog(): Promise<InternetCatalogCollection> {
|
// ============================================================================
|
||||||
const response = await apiClient.GET<InternetCatalogCollection>("/api/services/internet/plans");
|
// Public (non-personalized) catalog endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async getPublicInternetCatalog(): Promise<InternetCatalogCollection> {
|
||||||
|
const response = await apiClient.GET<InternetCatalogCollection>(
|
||||||
|
"/api/public/services/internet/plans"
|
||||||
|
);
|
||||||
const data = getDataOrThrow<InternetCatalogCollection>(
|
const data = getDataOrThrow<InternetCatalogCollection>(
|
||||||
response,
|
response,
|
||||||
"Failed to load internet services"
|
"Failed to load internet services"
|
||||||
@ -30,6 +36,52 @@ export const servicesService = {
|
|||||||
return data; // BFF already validated
|
return data; // BFF already validated
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getPublicInternetCatalog() or getAccountInternetCatalog() for clear separation.
|
||||||
|
*/
|
||||||
|
async getInternetCatalog(): Promise<InternetCatalogCollection> {
|
||||||
|
return this.getPublicInternetCatalog();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPublicSimCatalog(): Promise<SimCatalogCollection> {
|
||||||
|
const response = await apiClient.GET<SimCatalogCollection>("/api/public/services/sim/plans");
|
||||||
|
const data = getDataOrDefault<SimCatalogCollection>(response, EMPTY_SIM_CATALOG);
|
||||||
|
return data; // BFF already validated
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPublicVpnCatalog(): Promise<VpnCatalogCollection> {
|
||||||
|
const response = await apiClient.GET<VpnCatalogCollection>("/api/public/services/vpn/plans");
|
||||||
|
const data = getDataOrDefault<VpnCatalogCollection>(response, EMPTY_VPN_CATALOG);
|
||||||
|
return data; // BFF already validated
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Account (authenticated + personalized) catalog endpoints
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async getAccountInternetCatalog(): Promise<InternetCatalogCollection> {
|
||||||
|
const response = await apiClient.GET<InternetCatalogCollection>(
|
||||||
|
"/api/account/services/internet/plans"
|
||||||
|
);
|
||||||
|
const data = getDataOrThrow<InternetCatalogCollection>(
|
||||||
|
response,
|
||||||
|
"Failed to load internet services"
|
||||||
|
);
|
||||||
|
return data; // BFF already validated
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAccountSimCatalog(): Promise<SimCatalogCollection> {
|
||||||
|
const response = await apiClient.GET<SimCatalogCollection>("/api/account/services/sim/plans");
|
||||||
|
const data = getDataOrDefault<SimCatalogCollection>(response, EMPTY_SIM_CATALOG);
|
||||||
|
return data; // BFF already validated
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAccountVpnCatalog(): Promise<VpnCatalogCollection> {
|
||||||
|
const response = await apiClient.GET<VpnCatalogCollection>("/api/account/services/vpn/plans");
|
||||||
|
const data = getDataOrDefault<VpnCatalogCollection>(response, EMPTY_VPN_CATALOG);
|
||||||
|
return data; // BFF already validated
|
||||||
|
},
|
||||||
|
|
||||||
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
||||||
const response = await apiClient.GET<InternetInstallationCatalogItem[]>(
|
const response = await apiClient.GET<InternetInstallationCatalogItem[]>(
|
||||||
"/api/services/internet/installations"
|
"/api/services/internet/installations"
|
||||||
@ -46,10 +98,11 @@ export const servicesService = {
|
|||||||
return internetAddonCatalogItemSchema.array().parse(data);
|
return internetAddonCatalogItemSchema.array().parse(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getPublicSimCatalog() or getAccountSimCatalog() for clear separation.
|
||||||
|
*/
|
||||||
async getSimCatalog(): Promise<SimCatalogCollection> {
|
async getSimCatalog(): Promise<SimCatalogCollection> {
|
||||||
const response = await apiClient.GET<SimCatalogCollection>("/api/services/sim/plans");
|
return this.getPublicSimCatalog();
|
||||||
const data = getDataOrDefault<SimCatalogCollection>(response, EMPTY_SIM_CATALOG);
|
|
||||||
return data; // BFF already validated
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||||
@ -66,10 +119,11 @@ export const servicesService = {
|
|||||||
return simCatalogProductSchema.array().parse(data);
|
return simCatalogProductSchema.array().parse(data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use getPublicVpnCatalog() or getAccountVpnCatalog() for clear separation.
|
||||||
|
*/
|
||||||
async getVpnCatalog(): Promise<VpnCatalogCollection> {
|
async getVpnCatalog(): Promise<VpnCatalogCollection> {
|
||||||
const response = await apiClient.GET<VpnCatalogCollection>("/api/services/vpn/plans");
|
return this.getPublicVpnCatalog();
|
||||||
const data = getDataOrDefault<VpnCatalogCollection>(response, EMPTY_VPN_CATALOG);
|
|
||||||
return data; // BFF already validated
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||||
|
|||||||
@ -1,15 +1,59 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { useInternetConfigure } from "@/features/services/hooks/useInternetConfigure";
|
import { useInternetConfigure } from "@/features/services/hooks/useInternetConfigure";
|
||||||
|
import { useInternetEligibility } from "@/features/services/hooks/useInternetEligibility";
|
||||||
|
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||||
import { InternetConfigureView as InternetConfigureInnerView } from "@/features/services/components/internet/InternetConfigureView";
|
import { InternetConfigureView as InternetConfigureInnerView } from "@/features/services/components/internet/InternetConfigureView";
|
||||||
|
import { Spinner } from "@/components/atoms/Spinner";
|
||||||
|
|
||||||
export function InternetConfigureContainer() {
|
export function InternetConfigureContainer() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const servicesBasePath = useServicesBasePath();
|
||||||
|
const eligibilityQuery = useInternetEligibility();
|
||||||
const vm = useInternetConfigure();
|
const vm = useInternetConfigure();
|
||||||
|
|
||||||
|
// Keep /internet/configure strictly in the post-eligibility path.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pathname.startsWith("/account")) return;
|
||||||
|
if (!eligibilityQuery.isSuccess) return;
|
||||||
|
if (eligibilityQuery.data.status === "eligible") return;
|
||||||
|
router.replace(`${servicesBasePath}/internet`);
|
||||||
|
}, [
|
||||||
|
eligibilityQuery.data?.status,
|
||||||
|
eligibilityQuery.isSuccess,
|
||||||
|
pathname,
|
||||||
|
router,
|
||||||
|
servicesBasePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (pathname.startsWith("/account")) {
|
||||||
|
if (eligibilityQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-12">
|
||||||
|
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)] text-center">
|
||||||
|
<Spinner className="mx-auto mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground">Checking availability…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eligibilityQuery.isSuccess && eligibilityQuery.data.status !== "eligible") {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-12">
|
||||||
|
<div className="bg-card rounded-xl border border-border p-8 shadow-[var(--cp-shadow-1)] text-center">
|
||||||
|
<Spinner className="mx-auto mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground">Redirecting…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Debug: log current state
|
// Debug: log current state
|
||||||
logger.debug("InternetConfigure state", {
|
logger.debug("InternetConfigure state", {
|
||||||
plan: vm.plan?.sku,
|
plan: vm.plan?.sku,
|
||||||
|
|||||||
@ -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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Server, CheckCircle, Clock, TriangleAlert, MapPin } from "lucide-react";
|
import { Server, CheckCircle, Clock, TriangleAlert, MapPin } from "lucide-react";
|
||||||
import { useInternetCatalog } from "@/features/services/hooks";
|
import { useAccountInternetCatalog } from "@/features/services/hooks";
|
||||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||||
import type {
|
import type {
|
||||||
InternetPlanCatalogItem,
|
InternetPlanCatalogItem,
|
||||||
@ -119,6 +119,7 @@ function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): Ti
|
|||||||
const config = tierDescriptions[tier];
|
const config = tierDescriptions[tier];
|
||||||
result.push({
|
result.push({
|
||||||
tier,
|
tier,
|
||||||
|
planSku: plan.sku,
|
||||||
monthlyPrice: plan.monthlyPrice ?? 0,
|
monthlyPrice: plan.monthlyPrice ?? 0,
|
||||||
description: config.description,
|
description: config.description,
|
||||||
features: config.features,
|
features: config.features,
|
||||||
@ -294,7 +295,7 @@ export function InternetPlansContainer() {
|
|||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { user } = useAuthSession();
|
const { user } = useAuthSession();
|
||||||
const { data, isLoading, error } = useInternetCatalog();
|
const { data, isLoading, error } = useAccountInternetCatalog();
|
||||||
const eligibilityQuery = useInternetEligibility();
|
const eligibilityQuery = useInternetEligibility();
|
||||||
const eligibilityLoading = eligibilityQuery.isLoading;
|
const eligibilityLoading = eligibilityQuery.isLoading;
|
||||||
const refetchEligibility = eligibilityQuery.refetch;
|
const refetchEligibility = eligibilityQuery.refetch;
|
||||||
@ -401,7 +402,18 @@ export function InternetPlansContainer() {
|
|||||||
window.confirm(`Request availability check for:\n\n${addressLabel}`);
|
window.confirm(`Request availability check for:\n\n${addressLabel}`);
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
eligibilityRequest.mutate({ address: user?.address ?? undefined });
|
setAutoRequestId(null);
|
||||||
|
setAutoRequestStatus("submitting");
|
||||||
|
try {
|
||||||
|
const result = await submitEligibilityRequest({ address: user?.address ?? undefined });
|
||||||
|
setAutoRequestId(result.requestId ?? null);
|
||||||
|
setAutoRequestStatus("submitted");
|
||||||
|
await refetchEligibility();
|
||||||
|
const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : "";
|
||||||
|
router.push(`${servicesBasePath}/internet/request-submitted${query}`);
|
||||||
|
} catch {
|
||||||
|
setAutoRequestStatus("failed");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto eligibility request effect
|
// Auto eligibility request effect
|
||||||
@ -432,11 +444,13 @@ export function InternetPlansContainer() {
|
|||||||
setAutoRequestId(result.requestId ?? null);
|
setAutoRequestId(result.requestId ?? null);
|
||||||
setAutoRequestStatus("submitted");
|
setAutoRequestStatus("submitted");
|
||||||
await refetchEligibility();
|
await refetchEligibility();
|
||||||
|
const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : "";
|
||||||
|
router.replace(`${servicesBasePath}/internet/request-submitted${query}`);
|
||||||
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
setAutoRequestStatus("failed");
|
setAutoRequestStatus("failed");
|
||||||
} finally {
|
|
||||||
router.replace(`${servicesBasePath}/internet`);
|
|
||||||
}
|
}
|
||||||
|
router.replace(`${servicesBasePath}/internet`);
|
||||||
};
|
};
|
||||||
|
|
||||||
void submit();
|
void submit();
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { WifiIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon } from "@heroicons/r
|
|||||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||||
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
||||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||||
import { useInternetPlan } from "@/features/services/hooks";
|
import { usePublicInternetPlan } from "@/features/services/hooks";
|
||||||
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ export function PublicInternetConfigureView() {
|
|||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const planSku = searchParams?.get("planSku");
|
const planSku = searchParams?.get("planSku");
|
||||||
const { plan, isLoading } = useInternetPlan(planSku || undefined);
|
const { plan, isLoading } = usePublicInternetPlan(planSku || undefined);
|
||||||
|
|
||||||
const redirectTo = planSku
|
const redirectTo = planSku
|
||||||
? `/account/services/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}`
|
? `/account/services/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}`
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
Globe,
|
Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useInternetCatalog } from "@/features/services/hooks";
|
import { usePublicInternetCatalog } from "@/features/services/hooks";
|
||||||
import type {
|
import type {
|
||||||
InternetPlanCatalogItem,
|
InternetPlanCatalogItem,
|
||||||
InternetInstallationCatalogItem,
|
InternetInstallationCatalogItem,
|
||||||
@ -133,7 +133,7 @@ export function PublicInternetPlansContent({
|
|||||||
heroTitle = "Internet Service Plans",
|
heroTitle = "Internet Service Plans",
|
||||||
heroDescription = "NTT Optical Fiber with full English support",
|
heroDescription = "NTT Optical Fiber with full English support",
|
||||||
}: PublicInternetPlansContentProps) {
|
}: PublicInternetPlansContentProps) {
|
||||||
const { data: servicesCatalog, isLoading, error } = useInternetCatalog();
|
const { data: servicesCatalog, isLoading, error } = usePublicInternetCatalog();
|
||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const defaultCtaPath = `${servicesBasePath}/internet/configure`;
|
const defaultCtaPath = `${servicesBasePath}/internet/configure`;
|
||||||
const ctaPath = propCtaPath ?? defaultCtaPath;
|
const ctaPath = propCtaPath ?? defaultCtaPath;
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { DevicePhoneMobileIcon, CheckIcon, BoltIcon } from "@heroicons/react/24/
|
|||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||||
import { useSimPlan } from "@/features/services/hooks";
|
import { usePublicSimPlan } from "@/features/services/hooks";
|
||||||
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection";
|
||||||
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||||
@ -20,7 +20,7 @@ export function PublicSimConfigureView() {
|
|||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const planSku = searchParams?.get("planSku");
|
const planSku = searchParams?.get("planSku");
|
||||||
const { plan, isLoading } = useSimPlan(planSku || undefined);
|
const { plan, isLoading } = usePublicSimPlan(planSku || undefined);
|
||||||
|
|
||||||
const redirectTarget = planSku
|
const redirectTarget = planSku
|
||||||
? `/account/services/sim/configure?planSku=${encodeURIComponent(planSku)}`
|
? `/account/services/sim/configure?planSku=${encodeURIComponent(planSku)}`
|
||||||
|
|||||||
@ -1,554 +1,42 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import { useRouter } from "next/navigation";
|
||||||
Smartphone,
|
|
||||||
Check,
|
|
||||||
Phone,
|
|
||||||
Globe,
|
|
||||||
ArrowLeft,
|
|
||||||
Signal,
|
|
||||||
Sparkles,
|
|
||||||
ChevronDown,
|
|
||||||
Info,
|
|
||||||
CircleDollarSign,
|
|
||||||
TriangleAlert,
|
|
||||||
CreditCard,
|
|
||||||
Calendar,
|
|
||||||
ArrowRightLeft,
|
|
||||||
ArrowRight,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
|
||||||
import { Button } from "@/components/atoms/button";
|
|
||||||
import { useSimCatalog } from "@/features/services/hooks";
|
|
||||||
import type { SimCatalogProduct } from "@customer-portal/domain/services";
|
import type { SimCatalogProduct } from "@customer-portal/domain/services";
|
||||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
import { usePublicSimCatalog } from "@/features/services/hooks";
|
||||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||||
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
|
||||||
import {
|
import {
|
||||||
ServiceHighlights,
|
SimPlansContent,
|
||||||
HighlightFeature,
|
type SimPlansTab,
|
||||||
} from "@/features/services/components/base/ServiceHighlights";
|
} from "@/features/services/components/sim/SimPlansContent";
|
||||||
|
|
||||||
interface PlansByType {
|
|
||||||
DataOnly: SimCatalogProduct[];
|
|
||||||
DataSmsVoice: SimCatalogProduct[];
|
|
||||||
VoiceOnly: SimCatalogProduct[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapsible section component
|
|
||||||
function CollapsibleSection({
|
|
||||||
title,
|
|
||||||
icon: Icon,
|
|
||||||
defaultOpen = false,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border border-border rounded-xl overflow-hidden bg-card">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Icon className="w-5 h-5 text-primary" />
|
|
||||||
<span className="font-medium text-foreground">{title}</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden transition-all duration-300 ${isOpen ? "max-h-[2000px]" : "max-h-0"}`}
|
|
||||||
>
|
|
||||||
<div className="p-4 pt-0 border-t border-border">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact plan card component for a cleaner grid
|
|
||||||
function SimPlanCardCompact({
|
|
||||||
plan,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
plan: SimCatalogProduct;
|
|
||||||
onSelect: (sku: string) => void;
|
|
||||||
}) {
|
|
||||||
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="group relative bg-card border border-border rounded-2xl p-6 shadow-sm hover:shadow-lg hover:border-primary transition-all duration-300 transform hover:-translate-y-1 h-full flex flex-col">
|
|
||||||
{/* Data Size Badge */}
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center group-hover:bg-primary group-hover:text-primary-foreground transition-colors duration-300">
|
|
||||||
<Signal className="w-6 h-6 text-primary group-hover:text-primary-foreground" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xl font-bold text-foreground">{plan.simDataSize}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<div className="mb-5">
|
|
||||||
<CardPricing monthlyPrice={displayPrice} size="md" alignment="left" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan name */}
|
|
||||||
<p className="text-sm text-muted-foreground mb-6 line-clamp-2 flex-grow">{plan.name}</p>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<Button
|
|
||||||
className="w-full mt-auto"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onSelect(plan.sku)}
|
|
||||||
rightIcon={
|
|
||||||
<ArrowRight className="w-4 h-4 transition-transform group-hover:translate-x-1" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Select Plan
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public SIM Plans View
|
* Public SIM Plans View
|
||||||
*
|
*
|
||||||
* Displays SIM plans for unauthenticated users.
|
* Displays SIM plans for unauthenticated users.
|
||||||
* Clean, focused design with plan selection.
|
* Uses the shared plans UI, with a public navigation handler.
|
||||||
*/
|
*/
|
||||||
export function PublicSimPlansView() {
|
export function PublicSimPlansView() {
|
||||||
|
const router = useRouter();
|
||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const { data, isLoading, error } = useSimCatalog();
|
const { data, isLoading, error } = usePublicSimCatalog();
|
||||||
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
||||||
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
|
const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice");
|
||||||
"data-voice"
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectPlan = (planSku: string) => {
|
const handleSelectPlan = (planSku: string) => {
|
||||||
window.location.href = `${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`;
|
router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const simFeatures: HighlightFeature[] = [
|
|
||||||
{
|
|
||||||
icon: <Signal className="h-6 w-6" />,
|
|
||||||
title: "NTT Docomo Network",
|
|
||||||
description: "Best area coverage among the main three carriers in Japan",
|
|
||||||
highlight: "Nationwide coverage",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <CircleDollarSign className="h-6 w-6" />,
|
|
||||||
title: "First Month Free",
|
|
||||||
description: "Basic fee waived on signup to get you started risk-free",
|
|
||||||
highlight: "Great value",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <CreditCard className="h-6 w-6" />,
|
|
||||||
title: "Foreign Cards Accepted",
|
|
||||||
description: "We accept both foreign and Japanese credit cards",
|
|
||||||
highlight: "No hassle",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Calendar className="h-6 w-6" />,
|
|
||||||
title: "No Binding Contract",
|
|
||||||
description: "Minimum 4 months service (1st month free + 3 billing months)",
|
|
||||||
highlight: "Flexible contract",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <ArrowRightLeft className="h-6 w-6" />,
|
|
||||||
title: "Number Portability",
|
|
||||||
description: "Easily switch to us keeping your current Japanese number",
|
|
||||||
highlight: "Keep your number",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Smartphone className="h-6 w-6" />,
|
|
||||||
title: "Free Plan Changes",
|
|
||||||
description: "Switch data plans anytime for the next billing cycle",
|
|
||||||
highlight: "Flexibility",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 pb-16">
|
<SimPlansContent
|
||||||
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
|
variant="public"
|
||||||
<div className="text-center mb-12 pt-8">
|
plans={plans}
|
||||||
<Skeleton className="h-10 w-80 mx-auto mb-4" />
|
isLoading={isLoading}
|
||||||
<Skeleton className="h-6 w-96 max-w-full mx-auto" />
|
error={error}
|
||||||
</div>
|
activeTab={activeTab}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
onTabChange={setActiveTab}
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
onSelectPlan={handleSelectPlan}
|
||||||
<div key={i} className="bg-card rounded-2xl border border-border p-5 space-y-4">
|
/>
|
||||||
<Skeleton className="h-10 w-24" />
|
|
||||||
<Skeleton className="h-6 w-20" />
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-10 w-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred";
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
|
||||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-8 text-center">
|
|
||||||
<div className="text-destructive font-medium text-lg mb-2">Failed to load SIM plans</div>
|
|
||||||
<div className="text-destructive/80 text-sm mb-6">{errorMessage}</div>
|
|
||||||
<Button as="a" href={servicesBasePath} leftIcon={<ArrowLeft className="w-4 h-4" />}>
|
|
||||||
Back to Services
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plansByType = simPlans.reduce<PlansByType>(
|
|
||||||
(acc, plan) => {
|
|
||||||
const planType = plan.simPlanType || "DataOnly";
|
|
||||||
if (planType === "DataOnly") acc.DataOnly.push(plan);
|
|
||||||
else if (planType === "VoiceOnly") acc.VoiceOnly.push(plan);
|
|
||||||
else acc.DataSmsVoice.push(plan);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ DataOnly: [], DataSmsVoice: [], VoiceOnly: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentPlans =
|
|
||||||
activeTab === "data-voice"
|
|
||||||
? plansByType.DataSmsVoice
|
|
||||||
: activeTab === "data-only"
|
|
||||||
? plansByType.DataOnly
|
|
||||||
: plansByType.VoiceOnly;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto px-4 pb-16">
|
|
||||||
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
|
|
||||||
|
|
||||||
{/* Hero Section - Clean & Minimal */}
|
|
||||||
<div className="text-center pt-8 pb-6">
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 mb-6">
|
|
||||||
<Sparkles className="w-4 h-4 text-primary" />
|
|
||||||
<span className="text-sm font-medium text-primary">Powered by NTT DOCOMO</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-4">Mobile SIM Plans</h1>
|
|
||||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
|
||||||
Get connected with Japan's best network coverage. Choose eSIM for quick digital delivery
|
|
||||||
or physical SIM shipped to your door.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service Highlights */}
|
|
||||||
<ServiceHighlights features={simFeatures} className="mb-12" />
|
|
||||||
|
|
||||||
{/* Plan Type Tabs */}
|
|
||||||
<div className="flex justify-center mb-8 mt-6">
|
|
||||||
<div className="inline-flex rounded-xl bg-muted p-1 border border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("data-voice")}
|
|
||||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
|
||||||
activeTab === "data-voice"
|
|
||||||
? "bg-card text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Phone className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Data + Voice</span>
|
|
||||||
<span className="sm:hidden">All-in</span>
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
|
||||||
{plansByType.DataSmsVoice.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("data-only")}
|
|
||||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
|
||||||
activeTab === "data-only"
|
|
||||||
? "bg-card text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Data Only</span>
|
|
||||||
<span className="sm:hidden">Data</span>
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
|
||||||
{plansByType.DataOnly.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("voice-only")}
|
|
||||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
|
||||||
activeTab === "voice-only"
|
|
||||||
? "bg-card text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Voice Only</span>
|
|
||||||
<span className="sm:hidden">Voice</span>
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
|
||||||
{plansByType.VoiceOnly.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan Cards Grid */}
|
|
||||||
<div id="plans" className="min-h-[300px]">
|
|
||||||
{currentPlans.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5 animate-in fade-in duration-300">
|
|
||||||
{currentPlans.map(plan => (
|
|
||||||
<SimPlanCardCompact key={plan.id} plan={plan} onSelect={handleSelectPlan} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-16 text-muted-foreground">
|
|
||||||
No plans available in this category.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Collapsible Information Sections */}
|
|
||||||
<div className="mt-12 space-y-4">
|
|
||||||
{/* Calling & SMS Rates */}
|
|
||||||
<CollapsibleSection title="Calling & SMS Rates" icon={Phone}>
|
|
||||||
<div className="space-y-6 pt-4">
|
|
||||||
{/* Domestic Rates */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
|
|
||||||
<span className="w-5 h-3 rounded-sm bg-[#BC002D] relative overflow-hidden flex items-center justify-center">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-white" />
|
|
||||||
</span>
|
|
||||||
Domestic (Japan)
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
|
||||||
<div className="text-sm text-muted-foreground mb-1">Voice Calls</div>
|
|
||||||
<div className="text-xl font-bold text-foreground">
|
|
||||||
¥10<span className="text-sm font-normal text-muted-foreground">/30 sec</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
|
||||||
<div className="text-sm text-muted-foreground mb-1">SMS</div>
|
|
||||||
<div className="text-xl font-bold text-foreground">
|
|
||||||
¥3<span className="text-sm font-normal text-muted-foreground">/message</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unlimited Option */}
|
|
||||||
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Phone className="w-5 h-5 text-success mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-foreground">Unlimited Domestic Calling</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Add unlimited domestic calls for{" "}
|
|
||||||
<span className="font-semibold text-success">¥3,000/month</span> (available at
|
|
||||||
checkout)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* International Note */}
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
International calling rates vary by country (¥31-148/30 sec). See{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.docomo.ne.jp/service/world/worldcall/call/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
NTT Docomo's website
|
|
||||||
</a>{" "}
|
|
||||||
for full details.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
|
|
||||||
{/* Fees & Discounts */}
|
|
||||||
<CollapsibleSection title="Fees & Discounts" icon={CircleDollarSign}>
|
|
||||||
<div className="space-y-6 pt-4">
|
|
||||||
{/* Fees */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-foreground mb-3">One-time Fees</h4>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-muted-foreground">Activation Fee</span>
|
|
||||||
<span className="font-medium text-foreground">¥1,500</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-muted-foreground">SIM Replacement (lost/damaged)</span>
|
|
||||||
<span className="font-medium text-foreground">¥1,500</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<span className="text-muted-foreground">eSIM Re-download</span>
|
|
||||||
<span className="font-medium text-foreground">¥1,500</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Discounts */}
|
|
||||||
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
|
|
||||||
<h4 className="font-medium text-foreground mb-2">Family Discount</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<span className="font-semibold text-success">¥300/month off</span> per additional
|
|
||||||
Voice SIM on your account
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">All prices exclude 10% consumption tax.</p>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
|
|
||||||
{/* Important Information & Terms */}
|
|
||||||
<CollapsibleSection title="Important Information & Terms" icon={Info}>
|
|
||||||
<div className="space-y-6 pt-4 text-sm">
|
|
||||||
{/* Key Notices */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-foreground mb-3 flex items-center gap-2">
|
|
||||||
<TriangleAlert className="w-4 h-4 text-warning" />
|
|
||||||
Important Notices
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-2 text-muted-foreground">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
ID verification with official documents (name, date of birth, address, photo) is
|
|
||||||
required during checkout.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
A compatible unlocked device is required. Check compatibility on our website.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
Service may not be available in areas with weak signal. See{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.nttdocomo.co.jp/English/support/area/index.html"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
NTT Docomo coverage map
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
SIM is activated as 4G by default. 5G can be requested via your account portal.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
International data roaming is not available. Voice/SMS roaming can be enabled
|
|
||||||
upon request (¥50,000/month limit).
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contract Terms */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-foreground mb-3">Contract Terms</h4>
|
|
||||||
<ul className="space-y-2 text-muted-foreground">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
<strong className="text-foreground">Minimum contract:</strong> 3 full billing
|
|
||||||
months. First month (sign-up to end of month) is free and doesn't count.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
<strong className="text-foreground">Billing cycle:</strong> 1st to end of month.
|
|
||||||
Regular billing starts the 1st of the following month after sign-up.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
<strong className="text-foreground">Cancellation:</strong> Can be requested
|
|
||||||
after 3rd month via cancellation form. Monthly fee is incurred in full for
|
|
||||||
cancellation month.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
<strong className="text-foreground">SIM return:</strong> SIM card must be
|
|
||||||
returned after service termination.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional Options */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-foreground mb-3">Additional Options</h4>
|
|
||||||
<ul className="space-y-2 text-muted-foreground">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>Call waiting and voice mail available as separate paid options.</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>Data plan changes are free and take effect next billing month.</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
Voice plan changes require new SIM issuance and standard policies apply.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Disclaimer */}
|
|
||||||
<div className="p-4 bg-muted/50 rounded-lg">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Payment is by credit card only. Data service is not suitable for activities
|
|
||||||
requiring continuous large data transfers. See full Terms of Service for complete
|
|
||||||
details.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Terms Footer */}
|
|
||||||
<div className="mt-8 text-center text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
All prices exclude 10% consumption tax.{" "}
|
|
||||||
<a href="#" className="text-primary hover:underline">
|
|
||||||
View full Terms of Service
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ShieldCheck, Zap } from "lucide-react";
|
import { ShieldCheck, Zap } from "lucide-react";
|
||||||
import { useVpnCatalog } from "@/features/services/hooks";
|
import { usePublicVpnCatalog } from "@/features/services/hooks";
|
||||||
import { LoadingCard } from "@/components/atoms";
|
import { LoadingCard } from "@/components/atoms";
|
||||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
@ -17,7 +17,7 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa
|
|||||||
*/
|
*/
|
||||||
export function PublicVpnPlansView() {
|
export function PublicVpnPlansView() {
|
||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const { data, isLoading, error } = useVpnCatalog();
|
const { data, isLoading, error } = usePublicVpnCatalog();
|
||||||
const vpnPlans = data?.plans || [];
|
const vpnPlans = data?.plans || [];
|
||||||
const activationFees = data?.activationFees || [];
|
const activationFees = data?.activationFees || [];
|
||||||
|
|
||||||
|
|||||||
@ -1,616 +1,42 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
|
||||||
Smartphone,
|
|
||||||
Check,
|
|
||||||
Phone,
|
|
||||||
Globe,
|
|
||||||
ArrowLeft,
|
|
||||||
Signal,
|
|
||||||
Sparkles,
|
|
||||||
CreditCard,
|
|
||||||
ChevronDown,
|
|
||||||
Info,
|
|
||||||
CircleDollarSign,
|
|
||||||
TriangleAlert,
|
|
||||||
Calendar,
|
|
||||||
ArrowRightLeft,
|
|
||||||
ArrowRight,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
|
||||||
import { Button } from "@/components/atoms/button";
|
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
|
||||||
import { useSimCatalog } from "@/features/services/hooks";
|
|
||||||
import type { SimCatalogProduct } from "@customer-portal/domain/services";
|
|
||||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
|
||||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
|
||||||
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
|
||||||
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { SimCatalogProduct } from "@customer-portal/domain/services";
|
||||||
|
import { useAccountSimCatalog } from "@/features/services/hooks";
|
||||||
|
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||||
import {
|
import {
|
||||||
ServiceHighlights,
|
SimPlansContent,
|
||||||
HighlightFeature,
|
type SimPlansTab,
|
||||||
} from "@/features/services/components/base/ServiceHighlights";
|
} from "@/features/services/components/sim/SimPlansContent";
|
||||||
|
|
||||||
interface PlansByType {
|
|
||||||
DataOnly: SimCatalogProduct[];
|
|
||||||
DataSmsVoice: SimCatalogProduct[];
|
|
||||||
VoiceOnly: SimCatalogProduct[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapsible section component
|
|
||||||
function CollapsibleSection({
|
|
||||||
title,
|
|
||||||
icon: Icon,
|
|
||||||
defaultOpen = false,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
defaultOpen?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="border border-border rounded-xl overflow-hidden bg-card">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Icon className="w-5 h-5 text-primary" />
|
|
||||||
<span className="font-medium text-foreground">{title}</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
className={`w-5 h-5 text-muted-foreground transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
className={`overflow-hidden transition-all duration-300 ${isOpen ? "max-h-[2000px]" : "max-h-0"}`}
|
|
||||||
>
|
|
||||||
<div className="p-4 pt-0 border-t border-border">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact plan card component
|
|
||||||
function SimPlanCardCompact({
|
|
||||||
plan,
|
|
||||||
isFamily,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
plan: SimCatalogProduct;
|
|
||||||
isFamily?: boolean;
|
|
||||||
onSelect: (sku: string) => void;
|
|
||||||
}) {
|
|
||||||
const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`group relative bg-card rounded-2xl p-5 transition-all duration-200 ${
|
|
||||||
isFamily
|
|
||||||
? "border-2 border-success/50 hover:border-success hover:shadow-[var(--cp-shadow-2)]"
|
|
||||||
: "border border-border hover:border-primary/50 hover:shadow-[var(--cp-shadow-2)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Family Discount Badge */}
|
|
||||||
{isFamily && (
|
|
||||||
<div className="absolute -top-3 left-4 flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-success text-success-foreground text-xs font-medium">
|
|
||||||
<Users className="w-3.5 h-3.5" />
|
|
||||||
Family Discount
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Data Size Badge */}
|
|
||||||
<div className="flex items-center justify-between mb-4 mt-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={`w-10 h-10 rounded-xl flex items-center justify-center ${isFamily ? "bg-success/10" : "bg-primary/10"}`}
|
|
||||||
>
|
|
||||||
<Signal className={`w-5 h-5 ${isFamily ? "text-success" : "text-primary"}`} />
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold text-foreground">{plan.simDataSize}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<CardPricing monthlyPrice={displayPrice} size="sm" alignment="left" />
|
|
||||||
{isFamily && (
|
|
||||||
<div className="text-xs text-success font-medium mt-1">Discounted price applied</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan name */}
|
|
||||||
<p className="text-sm text-muted-foreground mb-5 line-clamp-2">{plan.name}</p>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<Button
|
|
||||||
className={`w-full ${isFamily ? "group-hover:bg-success group-hover:text-success-foreground" : "group-hover:bg-primary group-hover:text-primary-foreground"}`}
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onSelect(plan.sku)}
|
|
||||||
rightIcon={<ArrowRight className="w-4 h-4" />}
|
|
||||||
>
|
|
||||||
Select Plan
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account SIM Plans Container
|
||||||
|
*
|
||||||
|
* Fetches account context (payment methods + personalised catalog) and
|
||||||
|
* renders the shared SIM plans UI.
|
||||||
|
*/
|
||||||
export function SimPlansContainer() {
|
export function SimPlansContainer() {
|
||||||
const servicesBasePath = useServicesBasePath();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data, isLoading, error } = useSimCatalog();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
const { data, isLoading, error } = useAccountSimCatalog();
|
||||||
const [hasExistingSim, setHasExistingSim] = useState(false);
|
const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
||||||
const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">(
|
const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice");
|
||||||
"data-voice"
|
|
||||||
);
|
|
||||||
const { data: paymentMethods, isLoading: paymentMethodsLoading } = usePaymentMethods();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasExistingSim(simPlans.some(p => p.simHasFamilyDiscount));
|
|
||||||
}, [simPlans]);
|
|
||||||
|
|
||||||
const handleSelectPlan = (planSku: string) => {
|
const handleSelectPlan = (planSku: string) => {
|
||||||
router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`);
|
router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const simFeatures: HighlightFeature[] = [
|
|
||||||
{
|
|
||||||
icon: <Signal className="h-6 w-6" />,
|
|
||||||
title: "NTT Docomo Network",
|
|
||||||
description: "Best area coverage among the main three carriers in Japan",
|
|
||||||
highlight: "Nationwide coverage",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <CircleDollarSign className="h-6 w-6" />,
|
|
||||||
title: "First Month Free",
|
|
||||||
description: "Basic fee waived on signup to get you started risk-free",
|
|
||||||
highlight: "Great value",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <CreditCard className="h-6 w-6" />,
|
|
||||||
title: "Foreign Cards Accepted",
|
|
||||||
description: "We accept both foreign and Japanese credit cards",
|
|
||||||
highlight: "No hassle",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Calendar className="h-6 w-6" />,
|
|
||||||
title: "No Binding Contract",
|
|
||||||
description: "Minimum 4 months service (1st month free + 3 billing months)",
|
|
||||||
highlight: "Flexible contract",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <ArrowRightLeft className="h-6 w-6" />,
|
|
||||||
title: "Number Portability",
|
|
||||||
description: "Easily switch to us keeping your current Japanese number",
|
|
||||||
highlight: "Keep your number",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Smartphone className="h-6 w-6" />,
|
|
||||||
title: "Free Plan Changes",
|
|
||||||
description: "Switch data plans anytime for the next billing cycle",
|
|
||||||
highlight: "Flexibility",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
|
<SimPlansContent
|
||||||
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
|
variant="account"
|
||||||
<div className="text-center mb-12 pt-8">
|
plans={plans}
|
||||||
<Skeleton className="h-10 w-80 mx-auto mb-4" />
|
isLoading={isLoading}
|
||||||
<Skeleton className="h-6 w-96 max-w-full mx-auto" />
|
error={error}
|
||||||
</div>
|
activeTab={activeTab}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
onTabChange={setActiveTab}
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
onSelectPlan={handleSelectPlan}
|
||||||
<div key={i} className="bg-card rounded-2xl border border-border p-5">
|
|
||||||
<div className="flex items-center justify-between mb-6 mt-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
|
||||||
<Skeleton className="h-6 w-16" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-6 space-y-2">
|
|
||||||
<Skeleton className="h-8 w-32" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 mb-6">
|
|
||||||
<Skeleton className="h-4 w-full" />
|
|
||||||
<Skeleton className="h-4 w-3/4" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-10 w-full rounded-md" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred";
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
|
|
||||||
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
|
|
||||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-8 text-center mt-8">
|
|
||||||
<div className="text-destructive font-medium text-lg mb-2">Failed to load SIM plans</div>
|
|
||||||
<div className="text-destructive/80 text-sm mb-6">{errorMessage}</div>
|
|
||||||
<Button as="a" href={servicesBasePath} leftIcon={<ArrowLeft className="w-4 h-4" />}>
|
|
||||||
Back to Services
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plansByType = simPlans.reduce<PlansByType>(
|
|
||||||
(acc, plan) => {
|
|
||||||
const planType = plan.simPlanType || "DataOnly";
|
|
||||||
if (planType === "DataOnly") acc.DataOnly.push(plan);
|
|
||||||
else if (planType === "VoiceOnly") acc.VoiceOnly.push(plan);
|
|
||||||
else acc.DataSmsVoice.push(plan);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ DataOnly: [], DataSmsVoice: [], VoiceOnly: [] }
|
|
||||||
);
|
|
||||||
|
|
||||||
const getCurrentPlans = () => {
|
|
||||||
const plans =
|
|
||||||
activeTab === "data-voice"
|
|
||||||
? plansByType.DataSmsVoice
|
|
||||||
: activeTab === "data-only"
|
|
||||||
? plansByType.DataOnly
|
|
||||||
: plansByType.VoiceOnly;
|
|
||||||
|
|
||||||
const regularPlans = plans.filter(p => !p.simHasFamilyDiscount);
|
|
||||||
const familyPlans = plans.filter(p => p.simHasFamilyDiscount);
|
|
||||||
|
|
||||||
return { regularPlans, familyPlans };
|
|
||||||
};
|
|
||||||
|
|
||||||
const { regularPlans, familyPlans } = getCurrentPlans();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto px-4 pb-16 pt-8">
|
|
||||||
<ServicesBackLink href={servicesBasePath} label="Back to Services" />
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div className="text-center pt-8 pb-8">
|
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20 mb-6">
|
|
||||||
<Sparkles className="w-4 h-4 text-primary" />
|
|
||||||
<span className="text-sm font-medium text-primary">Powered by NTT DOCOMO</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-4">
|
|
||||||
Choose Your SIM Plan
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
|
||||||
Get connected with Japan's best network coverage. Choose eSIM for quick digital delivery
|
|
||||||
or physical SIM shipped to your door.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Requirements Banners */}
|
|
||||||
<div className="space-y-4 mb-8">
|
|
||||||
{paymentMethodsLoading ? (
|
|
||||||
<AlertBanner variant="info" title="Checking requirements…">
|
|
||||||
<p className="text-sm text-foreground/80">Loading your payment method status.</p>
|
|
||||||
</AlertBanner>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{paymentMethods && paymentMethods.totalCount === 0 && (
|
|
||||||
<AlertBanner variant="warning" title="Add a payment method to order SIM">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
|
||||||
<p className="text-sm text-foreground/80">
|
|
||||||
SIM orders require a saved payment method on your account.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
as="a"
|
|
||||||
href="/account/billing/payments"
|
|
||||||
size="sm"
|
|
||||||
className="sm:ml-auto whitespace-nowrap"
|
|
||||||
>
|
|
||||||
Add payment method
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</AlertBanner>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasExistingSim && (
|
|
||||||
<AlertBanner variant="success" title="Family Discount Available">
|
|
||||||
<p className="text-sm">
|
|
||||||
You already have a SIM subscription. Discounted pricing is automatically shown for
|
|
||||||
additional lines.
|
|
||||||
</p>
|
|
||||||
</AlertBanner>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service Highlights (Shared with Public View) */}
|
|
||||||
<ServiceHighlights features={simFeatures} className="mb-12" />
|
|
||||||
|
|
||||||
{/* Plan Type Tabs */}
|
|
||||||
<div className="flex justify-center mb-8">
|
|
||||||
<div className="inline-flex rounded-xl bg-muted p-1 border border-border">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("data-voice")}
|
|
||||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
|
||||||
activeTab === "data-voice"
|
|
||||||
? "bg-card text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Phone className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Data + Voice</span>
|
|
||||||
<span className="sm:hidden">All-in</span>
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
|
||||||
{plansByType.DataSmsVoice.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("data-only")}
|
|
||||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
|
||||||
activeTab === "data-only"
|
|
||||||
? "bg-card text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Data Only</span>
|
|
||||||
<span className="sm:hidden">Data</span>
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
|
||||||
{plansByType.DataOnly.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("voice-only")}
|
|
||||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
|
||||||
activeTab === "voice-only"
|
|
||||||
? "bg-card text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Voice Only</span>
|
|
||||||
<span className="sm:hidden">Voice</span>
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary">
|
|
||||||
{plansByType.VoiceOnly.length}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan Cards Grid */}
|
|
||||||
<div id="plans" className="min-h-[300px]">
|
|
||||||
{regularPlans.length > 0 || familyPlans.length > 0 ? (
|
|
||||||
<div className="space-y-8 animate-in fade-in duration-300">
|
|
||||||
{/* Regular Plans */}
|
|
||||||
{regularPlans.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
|
||||||
{regularPlans.map(plan => (
|
|
||||||
<SimPlanCardCompact key={plan.id} plan={plan} onSelect={handleSelectPlan} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Family Discount Plans */}
|
|
||||||
{hasExistingSim && familyPlans.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Users className="h-5 w-5 text-success" />
|
|
||||||
<h3 className="text-lg font-semibold text-foreground">Family Discount Plans</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
|
||||||
{familyPlans.map(plan => (
|
|
||||||
<SimPlanCardCompact
|
|
||||||
key={plan.id}
|
|
||||||
plan={plan}
|
|
||||||
isFamily
|
|
||||||
onSelect={handleSelectPlan}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-16 text-muted-foreground">
|
|
||||||
No plans available in this category.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Collapsible Information Sections */}
|
|
||||||
<div className="mt-8 space-y-4">
|
|
||||||
{/* Calling & SMS Rates */}
|
|
||||||
<CollapsibleSection title="Calling & SMS Rates" icon={Phone}>
|
|
||||||
<div className="space-y-6 pt-4">
|
|
||||||
{/* Domestic Rates */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
|
|
||||||
<span className="w-5 h-3 rounded-sm bg-[#BC002D] relative overflow-hidden flex items-center justify-center">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-white" />
|
|
||||||
</span>
|
|
||||||
Domestic (Japan)
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
|
||||||
<div className="text-sm text-muted-foreground mb-1">Voice Calls</div>
|
|
||||||
<div className="text-xl font-bold text-foreground">
|
|
||||||
¥10<span className="text-sm font-normal text-muted-foreground">/30 sec</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 border border-border">
|
|
||||||
<div className="text-sm text-muted-foreground mb-1">SMS</div>
|
|
||||||
<div className="text-xl font-bold text-foreground">
|
|
||||||
¥3<span className="text-sm font-normal text-muted-foreground">/message</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unlimited Option */}
|
|
||||||
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Phone className="w-5 h-5 text-success mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-foreground">Unlimited Domestic Calling</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Add unlimited domestic calls for{" "}
|
|
||||||
<span className="font-semibold text-success">¥3,000/month</span> (available at
|
|
||||||
checkout)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* International Note */}
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
International calling rates vary by country (¥31-148/30 sec). See{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.docomo.ne.jp/service/world/worldcall/call/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
NTT Docomo's website
|
|
||||||
</a>{" "}
|
|
||||||
for full details.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
|
|
||||||
{/* Fees & Discounts */}
|
|
||||||
<CollapsibleSection title="Fees & Discounts" icon={CircleDollarSign}>
|
|
||||||
<div className="space-y-6 pt-4">
|
|
||||||
{/* Fees */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-foreground mb-3">One-time Fees</h4>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-muted-foreground">Activation Fee</span>
|
|
||||||
<span className="font-medium text-foreground">¥1,500</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
|
||||||
<span className="text-muted-foreground">SIM Replacement (lost/damaged)</span>
|
|
||||||
<span className="font-medium text-foreground">¥1,500</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2">
|
|
||||||
<span className="text-muted-foreground">eSIM Re-download</span>
|
|
||||||
<span className="font-medium text-foreground">¥1,500</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Discounts */}
|
|
||||||
<div className="p-4 bg-success/5 border border-success/20 rounded-lg">
|
|
||||||
<h4 className="font-medium text-foreground mb-2">Family Discount</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
<span className="font-semibold text-success">¥300/month off</span> per additional
|
|
||||||
Voice SIM on your account
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">All prices exclude 10% consumption tax.</p>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
|
|
||||||
{/* Important Information & Terms */}
|
|
||||||
<CollapsibleSection title="Important Information & Terms" icon={Info}>
|
|
||||||
<div className="space-y-6 pt-4 text-sm">
|
|
||||||
{/* Key Notices */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-foreground mb-3 flex items-center gap-2">
|
|
||||||
<TriangleAlert className="w-4 h-4 text-warning" />
|
|
||||||
Important Notices
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-2 text-muted-foreground">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>ID verification with official documents is required during checkout.</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
A compatible unlocked device is required. Check compatibility on our website.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
SIM is activated as 4G by default. 5G can be requested via your account portal.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
International data roaming is not available. Voice/SMS roaming can be enabled
|
|
||||||
upon request.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contract Terms */}
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-foreground mb-3">Contract Terms</h4>
|
|
||||||
<ul className="space-y-2 text-muted-foreground">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
<strong className="text-foreground">Minimum contract:</strong> 3 full billing
|
|
||||||
months.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
<strong className="text-foreground">Cancellation:</strong> Can be requested
|
|
||||||
after 3rd month via cancellation form.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="text-foreground">•</span>
|
|
||||||
<span>
|
|
||||||
<strong className="text-foreground">SIM return:</strong> SIM card must be
|
|
||||||
returned after service termination.
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Disclaimer */}
|
|
||||||
<div className="p-4 bg-muted/50 rounded-lg">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Payment is by credit card only. Data service is not suitable for activities
|
|
||||||
requiring continuous large data transfers. See full Terms of Service for complete
|
|
||||||
details.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Terms Footer */}
|
|
||||||
<div className="mt-8 text-center text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
All prices exclude 10% consumption tax.{" "}
|
|
||||||
<a href="#" className="text-primary hover:underline">
|
|
||||||
View full Terms of Service
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
||||||
import { useVpnCatalog } from "@/features/services/hooks";
|
import { useAccountVpnCatalog } from "@/features/services/hooks";
|
||||||
import { LoadingCard } from "@/components/atoms";
|
import { LoadingCard } from "@/components/atoms";
|
||||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||||
@ -13,7 +13,7 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa
|
|||||||
|
|
||||||
export function VpnPlansView() {
|
export function VpnPlansView() {
|
||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const { data, isLoading, error } = useVpnCatalog();
|
const { data, isLoading, error } = useAccountVpnCatalog();
|
||||||
const vpnPlans = data?.plans || [];
|
const vpnPlans = data?.plans || [];
|
||||||
const activationFees = data?.activationFees || [];
|
const activationFees = data?.activationFees || [];
|
||||||
|
|
||||||
|
|||||||
@ -7,8 +7,9 @@
|
|||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { isApiError } from "@/lib/api/runtime/client";
|
import { isApiError } from "@/lib/api/runtime/client";
|
||||||
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||||
|
|
||||||
interface QueryProviderProps {
|
interface QueryProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -48,6 +49,18 @@ export function QueryProvider({ children }: QueryProviderProps) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Security + correctness: clear cached queries on logout so a previous user's
|
||||||
|
// account-scoped data cannot remain in memory.
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = useAuthStore.subscribe((state, prevState) => {
|
||||||
|
if (prevState.isAuthenticated && !state.isAuthenticated) {
|
||||||
|
queryClient.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -41,7 +41,7 @@ Core system design documents:
|
|||||||
| -------------------------------------------------------------- | ------------------------- |
|
| -------------------------------------------------------------- | ------------------------- |
|
||||||
| [System Overview](./architecture/system-overview.md) | High-level architecture |
|
| [System Overview](./architecture/system-overview.md) | High-level architecture |
|
||||||
| [Monorepo Structure](./architecture/monorepo.md) | Monorepo organization |
|
| [Monorepo Structure](./architecture/monorepo.md) | Monorepo organization |
|
||||||
| [Product Catalog](./architecture/product-catalog.md) | Catalog design |
|
| [Product Catalog](./architecture/product-catalog.md) | Product catalog design |
|
||||||
| [Modular Provisioning](./architecture/modular-provisioning.md) | Provisioning architecture |
|
| [Modular Provisioning](./architecture/modular-provisioning.md) | Provisioning architecture |
|
||||||
| [Domain Layer](./architecture/domain-layer.md) | Domain-driven design |
|
| [Domain Layer](./architecture/domain-layer.md) | Domain-driven design |
|
||||||
| [Orders Architecture](./architecture/orders.md) | Order system design |
|
| [Orders Architecture](./architecture/orders.md) | Order system design |
|
||||||
@ -56,7 +56,7 @@ Feature guides explaining how the portal functions:
|
|||||||
| ------------------------------------------------------------------ | --------------------------- |
|
| ------------------------------------------------------------------ | --------------------------- |
|
||||||
| [System Overview](./how-it-works/system-overview.md) | Systems and data ownership |
|
| [System Overview](./how-it-works/system-overview.md) | Systems and data ownership |
|
||||||
| [Accounts & Identity](./how-it-works/accounts-and-identity.md) | Sign-up and WHMCS linking |
|
| [Accounts & Identity](./how-it-works/accounts-and-identity.md) | Sign-up and WHMCS linking |
|
||||||
| [Catalog & Checkout](./how-it-works/catalog-and-checkout.md) | Products and checkout rules |
|
| [Services & Checkout](./how-it-works/services-and-checkout.md) | Products and checkout rules |
|
||||||
| [Orders & Provisioning](./how-it-works/orders-and-provisioning.md) | Order fulfillment flow |
|
| [Orders & Provisioning](./how-it-works/orders-and-provisioning.md) | Order fulfillment flow |
|
||||||
| [Billing & Payments](./how-it-works/billing-and-payments.md) | Invoices and payments |
|
| [Billing & Payments](./how-it-works/billing-and-payments.md) | Invoices and payments |
|
||||||
| [Subscriptions](./how-it-works/subscriptions.md) | Active services |
|
| [Subscriptions](./how-it-works/subscriptions.md) | Active services |
|
||||||
|
|||||||
@ -59,7 +59,7 @@ packages/domain/
|
|||||||
│ │ ├── raw.types.ts # Salesforce Order/OrderItem
|
│ │ ├── raw.types.ts # Salesforce Order/OrderItem
|
||||||
│ │ └── mapper.ts # Transform Salesforce → OrderDetails
|
│ │ └── mapper.ts # Transform Salesforce → OrderDetails
|
||||||
│ └── index.ts
|
│ └── index.ts
|
||||||
├── catalog/
|
├── services/
|
||||||
│ ├── contract.ts # CatalogProduct, InternetPlan, SimProduct, VpnProduct
|
│ ├── contract.ts # CatalogProduct, InternetPlan, SimProduct, VpnProduct
|
||||||
│ ├── schema.ts
|
│ ├── schema.ts
|
||||||
│ ├── providers/
|
│ ├── providers/
|
||||||
@ -90,7 +90,7 @@ import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
|
|||||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||||
import type { OrderSummary } from "@customer-portal/domain/orders";
|
import type { OrderSummary } from "@customer-portal/domain/orders";
|
||||||
import type { CatalogProduct } from "@customer-portal/domain/services";
|
import type { InternetPlanCatalogItem } from "@customer-portal/domain/services";
|
||||||
```
|
```
|
||||||
|
|
||||||
### Import Schemas
|
### Import Schemas
|
||||||
@ -233,11 +233,11 @@ External API Request
|
|||||||
- **Providers**: WHMCS (provisioning), Salesforce (order management)
|
- **Providers**: WHMCS (provisioning), Salesforce (order management)
|
||||||
- **Use Cases**: Order fulfillment, order history, order details
|
- **Use Cases**: Order fulfillment, order history, order details
|
||||||
|
|
||||||
### Catalog
|
### Services
|
||||||
|
|
||||||
- **Contracts**: `InternetPlanCatalogItem`, `SimCatalogProduct`, `VpnCatalogProduct`
|
- **Contracts**: `InternetPlanCatalogItem`, `SimCatalogProduct`, `VpnCatalogProduct`
|
||||||
- **Providers**: Salesforce (Product2)
|
- **Providers**: Salesforce (Product2)
|
||||||
- **Use Cases**: Product catalog display, product selection
|
- **Use Cases**: Product catalog display, product selection, service browsing
|
||||||
|
|
||||||
### Common
|
### Common
|
||||||
|
|
||||||
|
|||||||
@ -69,7 +69,7 @@ src/
|
|||||||
users/ # User management
|
users/ # User management
|
||||||
me-status/ # Aggregated customer status (dashboard + gating signals)
|
me-status/ # Aggregated customer status (dashboard + gating signals)
|
||||||
id-mappings/ # Portal-WHMCS-Salesforce ID mappings
|
id-mappings/ # Portal-WHMCS-Salesforce ID mappings
|
||||||
catalog/ # Product catalog
|
services/ # Services catalog (browsing/purchasing)
|
||||||
orders/ # Order creation and fulfillment
|
orders/ # Order creation and fulfillment
|
||||||
invoices/ # Invoice management
|
invoices/ # Invoice management
|
||||||
subscriptions/ # Service and subscription management
|
subscriptions/ # Service and subscription management
|
||||||
@ -96,6 +96,11 @@ src/
|
|||||||
- Reuse `packages/domain` for domain types
|
- Reuse `packages/domain` for domain types
|
||||||
- External integrations in dedicated modules
|
- External integrations in dedicated modules
|
||||||
|
|
||||||
|
### **API Boundary: Public vs Account**
|
||||||
|
|
||||||
|
- **Public APIs** (`/api/public/*`): strictly non-personalized endpoints intended for marketing pages and unauthenticated browsing.
|
||||||
|
- **Account APIs** (`/api/account/*`): authenticated endpoints that may return personalized responses (e.g. eligibility-gated catalogs, SIM family discount availability).
|
||||||
|
|
||||||
## 📦 **Shared Packages**
|
## 📦 **Shared Packages**
|
||||||
|
|
||||||
### **Domain Package (`packages/domain/`)**
|
### **Domain Package (`packages/domain/`)**
|
||||||
@ -106,7 +111,7 @@ The domain package is the single source of truth for shared types, validation sc
|
|||||||
packages/domain/
|
packages/domain/
|
||||||
├── auth/ # Authentication types and validation
|
├── auth/ # Authentication types and validation
|
||||||
├── billing/ # Invoice and payment types
|
├── billing/ # Invoice and payment types
|
||||||
├── catalog/ # Product catalog types
|
├── services/ # Services catalog types
|
||||||
├── checkout/ # Checkout flow types
|
├── checkout/ # Checkout flow types
|
||||||
├── common/ # Shared utilities and base types
|
├── common/ # Shared utilities and base types
|
||||||
├── customer/ # Customer profile types
|
├── customer/ # Customer profile types
|
||||||
|
|||||||
@ -74,8 +74,8 @@ packages/domain/
|
|||||||
│ ├── raw.types.ts
|
│ ├── raw.types.ts
|
||||||
│ └── mapper.ts
|
│ └── mapper.ts
|
||||||
│
|
│
|
||||||
├── catalog/
|
├── services/
|
||||||
│ ├── contract.ts # CatalogProduct (UI view model)
|
│ ├── contract.ts # Service catalog products (UI view model)
|
||||||
│ ├── schema.ts
|
│ ├── schema.ts
|
||||||
│ ├── index.ts
|
│ ├── index.ts
|
||||||
│ └── providers/
|
│ └── providers/
|
||||||
|
|||||||
@ -223,7 +223,7 @@ function isAddonProduct(product: Product): boolean {
|
|||||||
const product = fromSalesforceProduct2(salesforceProduct, pricebookEntry);
|
const product = fromSalesforceProduct2(salesforceProduct, pricebookEntry);
|
||||||
|
|
||||||
if (isCatalogVisible(product)) {
|
if (isCatalogVisible(product)) {
|
||||||
displayInCatalog(product);
|
displayInServices(product);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type-safe access to specific fields
|
// Type-safe access to specific fields
|
||||||
|
|||||||
@ -21,7 +21,7 @@ apps/portal/src/
|
|||||||
│ ├── account/
|
│ ├── account/
|
||||||
│ ├── auth/
|
│ ├── auth/
|
||||||
│ ├── billing/
|
│ ├── billing/
|
||||||
│ ├── catalog/
|
│ ├── services/
|
||||||
│ ├── dashboard/
|
│ ├── dashboard/
|
||||||
│ ├── marketing/
|
│ ├── marketing/
|
||||||
│ ├── orders/
|
│ ├── orders/
|
||||||
@ -209,9 +209,9 @@ Only `layout.tsx`, `page.tsx`, and `loading.tsx` files live inside the route gro
|
|||||||
|
|
||||||
### Current Feature Hooks/Services
|
### Current Feature Hooks/Services
|
||||||
|
|
||||||
- Catalog
|
- Services
|
||||||
- Hooks: `useInternetCatalog`, `useSimCatalog`, `useVpnCatalog`, `useProducts*`, `useCalculateOrder`, `useSubmitOrder`
|
- Hooks: `useInternetCatalog`, `useSimCatalog`, `useVpnCatalog`, `useProducts*`, `useCalculateOrder`, `useSubmitOrder`
|
||||||
- Service: `catalogService` (internet/sim/vpn endpoints consolidated)
|
- Service: `servicesService` (internet/sim/vpn endpoints consolidated)
|
||||||
- Billing
|
- Billing
|
||||||
- Hooks: `useInvoices`, `usePaymentMethods`, `usePaymentGateways`, `usePaymentRefresh`
|
- Hooks: `useInvoices`, `usePaymentMethods`, `usePaymentGateways`, `usePaymentRefresh`
|
||||||
- Service: `BillingService`
|
- Service: `BillingService`
|
||||||
|
|||||||
@ -11,7 +11,7 @@ Start with `system-overview.md`, then jump into the feature you care about.
|
|||||||
| [Complete Guide](./COMPLETE-GUIDE.md) | Single, end-to-end explanation of how the portal works |
|
| [Complete Guide](./COMPLETE-GUIDE.md) | Single, end-to-end explanation of how the portal works |
|
||||||
| [System Overview](./system-overview.md) | High-level architecture, data ownership, and caching |
|
| [System Overview](./system-overview.md) | High-level architecture, data ownership, and caching |
|
||||||
| [Accounts & Identity](./accounts-and-identity.md) | Sign-up, WHMCS linking, and address/profile updates |
|
| [Accounts & Identity](./accounts-and-identity.md) | Sign-up, WHMCS linking, and address/profile updates |
|
||||||
| [Catalog & Checkout](./catalog-and-checkout.md) | Product source, pricing, and checkout flow |
|
| [Services & Checkout](./services-and-checkout.md) | Product source, pricing, and checkout flow |
|
||||||
| [Eligibility & Verification](./eligibility-and-verification.md) | Internet eligibility + SIM ID verification |
|
| [Eligibility & Verification](./eligibility-and-verification.md) | Internet eligibility + SIM ID verification |
|
||||||
| [Orders & Provisioning](./orders-and-provisioning.md) | Order lifecycle in Salesforce → WHMCS fulfillment |
|
| [Orders & Provisioning](./orders-and-provisioning.md) | Order lifecycle in Salesforce → WHMCS fulfillment |
|
||||||
| [Billing & Payments](./billing-and-payments.md) | Invoices, payment methods, billing links |
|
| [Billing & Payments](./billing-and-payments.md) | Invoices, payment methods, billing links |
|
||||||
|
|||||||
@ -20,12 +20,25 @@ This guide describes how eligibility and verification work in the customer porta
|
|||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
1. Customer navigates to `/account/services/internet`
|
1. Customer navigates to `/account/services/internet`
|
||||||
2. Customer enters service address and requests eligibility check
|
2. Customer clicks **Check Availability** (requires a service address on file)
|
||||||
3. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review
|
3. Portal calls `POST /api/services/internet/eligibility-request` and shows an immediate confirmation screen at `/account/services/internet/request-submitted`
|
||||||
4. Agent performs NTT serviceability check (manual process)
|
4. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review
|
||||||
5. Agent updates Account eligibility fields
|
5. Agent performs NTT serviceability check (manual process)
|
||||||
6. Salesforce Flow sends email notification to customer
|
6. Agent updates Account eligibility fields
|
||||||
7. Customer returns and sees eligible plans
|
7. Salesforce Flow sends email notification to customer
|
||||||
|
8. Customer returns and sees eligible plans
|
||||||
|
|
||||||
|
### Caching & Rate Limiting (Security + Load)
|
||||||
|
|
||||||
|
- **BFF cache (Redis)**:
|
||||||
|
- Internet catalog data is cached in Redis (CDC-driven invalidation, no TTL) so repeated portal hits **do not repeatedly query Salesforce**.
|
||||||
|
- Eligibility details are cached per Salesforce Account ID and are invalidated/updated when Salesforce emits Account change events.
|
||||||
|
- **Portal cache (React Query)**:
|
||||||
|
- The portal caches service catalog responses in-memory, scoped by auth state, and will refetch when stale.
|
||||||
|
- On logout, the portal clears cached queries to avoid cross-user leakage on shared devices.
|
||||||
|
- **Rate limiting**:
|
||||||
|
- Public catalog endpoints are rate-limited per IP + User-Agent to prevent abuse.
|
||||||
|
- `POST /api/services/internet/eligibility-request` is authenticated and rate-limited, and the BFF is idempotent when a request is already pending (no duplicate Cases created).
|
||||||
|
|
||||||
### Subscription Type Detection
|
### Subscription Type Detection
|
||||||
|
|
||||||
@ -50,12 +63,15 @@ const isInternetService =
|
|||||||
| `Internet_Eligibility__c` | Text | Agent | After check |
|
| `Internet_Eligibility__c` | Text | Agent | After check |
|
||||||
| `Internet_Eligibility_Request_Date_Time__c` | DateTime | Portal | On request |
|
| `Internet_Eligibility_Request_Date_Time__c` | DateTime | Portal | On request |
|
||||||
| `Internet_Eligibility_Checked_Date_Time__c` | DateTime | Agent | After check |
|
| `Internet_Eligibility_Checked_Date_Time__c` | DateTime | Agent | After check |
|
||||||
| `Internet_Eligibility_Notes__c` | Text | Agent | After check |
|
|
||||||
| `Internet_Eligibility_Case_Id__c` | Lookup | Portal | On request |
|
**Notes:**
|
||||||
|
|
||||||
|
- The portal returns an API-level **request id** (Salesforce Case ID) from `POST /api/services/internet/eligibility-request` for display/auditing.
|
||||||
|
- The portal UI reads eligibility status/value from the Account fields above; it does not rely on an Account-stored Case ID.
|
||||||
|
|
||||||
### Status Values
|
### Status Values
|
||||||
|
|
||||||
| Status | Shop Page UI | Checkout Gating |
|
| Status | Services Page UI | Checkout Gating |
|
||||||
| --------------- | --------------------------------------- | --------------- |
|
| --------------- | --------------------------------------- | --------------- |
|
||||||
| `Not Requested` | Show "Request eligibility check" button | Block submit |
|
| `Not Requested` | Show "Request eligibility check" button | Block submit |
|
||||||
| `Pending` | Show "Review in progress" | Block submit |
|
| `Pending` | Show "Review in progress" | Block submit |
|
||||||
@ -109,9 +125,10 @@ The Profile page is the primary location. The standalone page is used when redir
|
|||||||
## Portal UI Locations
|
## Portal UI Locations
|
||||||
|
|
||||||
| Location | What's Shown |
|
| Location | What's Shown |
|
||||||
| ---------------------------- | ----------------------------------------------- |
|
| ---------------------------------------------- | -------------------------------------------------------------- |
|
||||||
| `/account/settings` | Profile, Address, ID Verification (with upload) |
|
| `/account/settings` | Profile, Address, ID Verification (with upload) |
|
||||||
| `/account/services/internet` | Eligibility status and eligible plans |
|
| `/account/services/internet` | Eligibility status and eligible plans |
|
||||||
|
| `/account/services/internet/request-submitted` | Immediate confirmation after submitting an eligibility request |
|
||||||
| Subscription detail | Service-specific actions (cancel, etc.) |
|
| Subscription detail | Service-specific actions (cancel, etc.) |
|
||||||
|
|
||||||
## Cancellation Flow
|
## Cancellation Flow
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Catalog & Checkout
|
# Services & Checkout
|
||||||
|
|
||||||
Where product data comes from, what we validate, and how we keep it fresh.
|
Where product data comes from, what we validate, and how we keep it fresh.
|
||||||
|
|
||||||
@ -21,24 +21,24 @@ Where product data comes from, what we validate, and how we keep it fresh.
|
|||||||
- SKUs selected exist in the Salesforce pricebook.
|
- SKUs selected exist in the Salesforce pricebook.
|
||||||
- For Internet orders, we block duplicates when WHMCS already shows an active Internet service (in production).
|
- For Internet orders, we block duplicates when WHMCS already shows an active Internet service (in production).
|
||||||
|
|
||||||
For the intended Salesforce-driven workflow model (Cases + Account fields + portal UX), see `docs/portal-guides/eligibility-and-verification.md`.
|
For the intended Salesforce-driven workflow model (Cases + Account fields + portal UX), see `docs/how-it-works/eligibility-and-verification.md`.
|
||||||
|
|
||||||
## Checkout Data Captured
|
## Checkout Data Captured
|
||||||
|
|
||||||
- Address snapshot: we copy the 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).
|
- Activation preferences: stored on the Salesforce Order (activation type/schedule, SIM specifics, MNP details when applicable).
|
||||||
- No card data is stored in the portal; we only verify that WHMCS already has a payment method.
|
- No card data is stored in the portal; we only verify that WHMCS already has a payment method.
|
||||||
|
|
||||||
## Caching for Catalog Calls
|
## Caching for Product Catalog
|
||||||
|
|
||||||
- Catalog data uses Salesforce Change Data Capture (CDC) events; there is no time-based expiry. When Salesforce signals a product change, the cache is cleared.
|
- Product catalog data uses Salesforce Change Data Capture (CDC) events; there is no time-based expiry. When Salesforce signals a product change, the cache is cleared.
|
||||||
- Volatile catalog bits (e.g., fast-changing reference data) use a 60s TTL.
|
- Volatile catalog bits (e.g., fast-changing reference data) use a 60s TTL.
|
||||||
- Eligibility per account is cached with no TTL and cleared when Salesforce changes.
|
- Eligibility per account is cached with no TTL and cleared when Salesforce changes.
|
||||||
- Request coalescing is used so multiple users hitting the same catalog do not spam Salesforce.
|
- Request coalescing is used so multiple users hitting the same services endpoint do not spam Salesforce.
|
||||||
|
|
||||||
## If something goes wrong
|
## If something goes wrong
|
||||||
|
|
||||||
- Missing payment method: checkout is blocked with a clear “add a payment method” message.
|
- Missing payment method: checkout is blocked with a clear "add a payment method" message.
|
||||||
- Ineligible address or duplicate Internet: we stop the order and explain why (eligibility failed or active Internet already exists).
|
- Ineligible address or duplicate Internet: we stop the order and explain why (eligibility failed or active Internet already exists).
|
||||||
- Salesforce pricebook issues: we return a friendly “catalog unavailable, please try again later.”
|
- Salesforce pricebook issues: we return a friendly "services unavailable, please try again later."
|
||||||
- Cache failures: we fall back to live Salesforce reads to avoid empty screens.
|
- Cache failures: we fall back to live Salesforce reads to avoid empty screens.
|
||||||
@ -6,7 +6,7 @@ Purpose: explain what the portal does, which systems own which data, and how fre
|
|||||||
|
|
||||||
- Portal UI (Next.js) + BFF API (NestJS): handles all user traffic and calls external systems.
|
- Portal UI (Next.js) + BFF API (NestJS): handles all user traffic and calls external systems.
|
||||||
- Postgres: stores portal users and the cross-system mapping `user_id ↔ whmcs_client_id ↔ sf_account_id`.
|
- Postgres: stores portal users and the cross-system mapping `user_id ↔ whmcs_client_id ↔ sf_account_id`.
|
||||||
- Redis cache: keeps short-lived copies of data to reduce load; keys are always scoped per user to avoid mixing data.
|
- Redis cache: reduces load with a mix of **global** caches (e.g. product catalog) and **account-scoped** caches (e.g. eligibility) to avoid mixing customer data.
|
||||||
- WHMCS: system of record for billing (clients, addresses, invoices, payment methods, subscriptions).
|
- WHMCS: system of record for billing (clients, addresses, invoices, payment methods, subscriptions).
|
||||||
- Salesforce: system of record for CRM (accounts/contacts), product catalog/pricebook, orders, and support cases.
|
- Salesforce: system of record for CRM (accounts/contacts), product catalog/pricebook, orders, and support cases.
|
||||||
- Freebit: SIM provisioning only, used during mobile/SIM order fulfillment.
|
- Freebit: SIM provisioning only, used during mobile/SIM order fulfillment.
|
||||||
@ -15,7 +15,7 @@ Purpose: explain what the portal does, which systems own which data, and how fre
|
|||||||
|
|
||||||
- Sign-up: portal verifies the customer number in Salesforce → creates a WHMCS client (billing account) → stores the portal user + mapping → updates Salesforce with portal status + WHMCS ID.
|
- Sign-up: portal verifies the customer number in Salesforce → creates a WHMCS client (billing account) → stores the portal user + mapping → updates Salesforce with portal status + WHMCS ID.
|
||||||
- Login/Linking: existing WHMCS users validate their WHMCS credentials; we create the portal user, map IDs, and mark the Salesforce account as portal-active.
|
- Login/Linking: existing WHMCS users validate their WHMCS credentials; we create the portal user, map IDs, and mark the Salesforce account as portal-active.
|
||||||
- Catalog & Checkout: products/prices come from the Salesforce portal pricebook; eligibility is checked per account; we require a WHMCS payment method before allowing checkout.
|
- Services & Checkout: products/prices come from the Salesforce portal pricebook; eligibility is checked per account; we require a WHMCS payment method before allowing checkout.
|
||||||
- Orders: created in Salesforce with an address snapshot; Salesforce change events trigger fulfillment, which creates the matching WHMCS order and updates Salesforce statuses.
|
- Orders: created in Salesforce with an address snapshot; Salesforce change events trigger fulfillment, which creates the matching WHMCS order and updates Salesforce statuses.
|
||||||
- Billing: invoices, payment methods, and subscriptions are read from WHMCS; secure SSO links are generated for paying invoices inside WHMCS.
|
- Billing: invoices, payment methods, and subscriptions are read from WHMCS; secure SSO links are generated for paying invoices inside WHMCS.
|
||||||
- Support: cases are created/read directly in Salesforce with Origin = “Portal Website.”
|
- Support: cases are created/read directly in Salesforce with Origin = “Portal Website.”
|
||||||
@ -29,7 +29,7 @@ Purpose: explain what the portal does, which systems own which data, and how fre
|
|||||||
|
|
||||||
## Caching & Freshness (Redis)
|
## Caching & Freshness (Redis)
|
||||||
|
|
||||||
- Catalog: event-driven (Salesforce CDC), no TTL; “volatile” bits use 60s TTL; eligibility per account is cached without TTL and invalidated on change.
|
- Services catalog: event-driven (Salesforce CDC), no TTL; "volatile" bits use 60s TTL; eligibility per account is cached without TTL and invalidated on change.
|
||||||
- Orders: event-driven (Salesforce CDC), no TTL; invalidated when Salesforce emits order/order-item changes or when we create/provision an order.
|
- Orders: event-driven (Salesforce CDC), no TTL; invalidated when Salesforce emits order/order-item changes or when we create/provision an order.
|
||||||
- Invoices: list cached 90s; invoice detail cached 5m; invalidated by WHMCS webhooks and by write operations.
|
- Invoices: list cached 90s; invoice detail cached 5m; invalidated by WHMCS webhooks and by write operations.
|
||||||
- Subscriptions/services: list cached 5m; single subscription cached 10m; invalidated on WHMCS cache busts (webhooks or profile updates).
|
- Subscriptions/services: list cached 5m; single subscription cached 10m; invalidated on WHMCS cache busts (webhooks or profile updates).
|
||||||
@ -44,3 +44,43 @@ Purpose: explain what the portal does, which systems own which data, and how fre
|
|||||||
- If WHMCS or Salesforce is briefly unavailable, the portal surfaces a friendly “try again later” message rather than partial data.
|
- If WHMCS or Salesforce is briefly unavailable, the portal surfaces a friendly “try again later” message rather than partial data.
|
||||||
- Fulfillment writes error codes/messages back to Salesforce (e.g., missing payment method) so the team can see why a provision was paused.
|
- Fulfillment writes error codes/messages back to Salesforce (e.g., missing payment method) so the team can see why a provision was paused.
|
||||||
- Caches are cleared on writes and key webhooks so stale data is minimized; when cache access fails, we fall back to live reads.
|
- Caches are cleared on writes and key webhooks so stale data is minimized; when cache access fails, we fall back to live reads.
|
||||||
|
|
||||||
|
## Public vs Account API Boundary (Security + Caching)
|
||||||
|
|
||||||
|
The BFF exposes two “flavors” of service catalog endpoints:
|
||||||
|
|
||||||
|
- **Public catalog (never personalized)**: `GET /api/public/services/*`
|
||||||
|
- Ignores cookies/tokens (no optional session attach).
|
||||||
|
- Safe to cache publicly (subject to TTL) and heavily rate limit.
|
||||||
|
- **Account catalog (authenticated + personalized)**: `GET /api/account/services/*`
|
||||||
|
- Requires auth and can return account-specific catalog variants (e.g. SIM family discount availability).
|
||||||
|
- Uses `Cache-Control: private, no-store` at the HTTP layer; server-side caching is handled in Redis.
|
||||||
|
|
||||||
|
### How "public caching" works (and why high traffic usually won't hit Salesforce)
|
||||||
|
|
||||||
|
There are **two independent caching layers** involved:
|
||||||
|
|
||||||
|
- **Redis (server-side) catalog cache**:
|
||||||
|
- Catalog reads are cached in Redis via `ServicesCacheService`.
|
||||||
|
- For catalog data (plans/addons/etc) the TTL is intentionally **null** (no TTL): values persist until explicitly invalidated.
|
||||||
|
- Invalidation is driven by Salesforce **CDC** events (Product2 / PricebookEntry) and an account **Platform Event** for eligibility updates.
|
||||||
|
- Result: even if the public catalog is requested millions of times, the BFF typically serves from Redis and only re-queries Salesforce when a relevant Salesforce change event arrives (or on cold start / cache miss).
|
||||||
|
|
||||||
|
- **HTTP cache (browser/CDN)**:
|
||||||
|
- Public catalog responses include `Cache-Control: public, max-age=..., s-maxage=...`.
|
||||||
|
- This reduces load on the BFF by allowing browsers/shared caches/CDNs to reuse responses for the TTL window.
|
||||||
|
- This layer is TTL-based, so **staleness up to the TTL** is expected unless your CDN is configured for explicit purge.
|
||||||
|
|
||||||
|
### What to worry about at "million visits" scale
|
||||||
|
|
||||||
|
- **CDN cookie forwarding / cache key fragmentation**:
|
||||||
|
- Browsers will still send cookies to `/api/public/*` by default; the BFF ignores them, but a CDN might treat cookies as part of the cache key unless configured not to.
|
||||||
|
- Make sure your CDN/proxy config does **not** include cookies (and ideally not `Authorization`) in the cache key for `/api/public/services/*`.
|
||||||
|
|
||||||
|
- **BFF + Redis load (even if Salesforce is protected)**:
|
||||||
|
- Redis caching prevents Salesforce read amplification, but the BFF/Redis still need to handle request volume.
|
||||||
|
- Rate limiting on public endpoints is intentional to cap abuse and protect infrastructure.
|
||||||
|
|
||||||
|
- **CDC subscription health / fallback behavior**:
|
||||||
|
- If Salesforce CDC subscriptions are disabled or unhealthy, invalidations may not arrive and Redis caches can become stale until manually cleared.
|
||||||
|
- Monitor the CDC subscriber and cache health metrics (`GET /api/health/services/cache`).
|
||||||
|
|||||||
@ -70,8 +70,11 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
|||||||
| Status | `Internet_Eligibility_Status__c` | Pending, Checked |
|
| Status | `Internet_Eligibility_Status__c` | Pending, Checked |
|
||||||
| Requested At | `Internet_Eligibility_Request_Date_Time__c` | When request was made |
|
| Requested At | `Internet_Eligibility_Request_Date_Time__c` | When request was made |
|
||||||
| Checked At | `Internet_Eligibility_Checked_Date_Time__c` | When eligibility was checked |
|
| Checked At | `Internet_Eligibility_Checked_Date_Time__c` | When eligibility was checked |
|
||||||
| Notes | `Internet_Eligibility_Notes__c` | Agent notes |
|
|
||||||
| Case ID | `Internet_Eligibility_Case_Id__c` | Linked Case for request |
|
**Notes:**
|
||||||
|
|
||||||
|
- The portal does **not** store the Case ID or agent notes on the Account in our current Salesforce environment.
|
||||||
|
- Agent notes should live inside the **Case Description** / Case activity history.
|
||||||
|
|
||||||
**ID Verification Fields:**
|
**ID Verification Fields:**
|
||||||
|
|
||||||
@ -179,6 +182,7 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
|||||||
│ │
|
│ │
|
||||||
│ 1. CUSTOMER ENTERS ADDRESS │
|
│ 1. CUSTOMER ENTERS ADDRESS │
|
||||||
│ └─ Portal: POST /api/services/internet/eligibility-request │
|
│ └─ Portal: POST /api/services/internet/eligibility-request │
|
||||||
|
│ Portal UI: shows confirmation at /account/services/internet/request-submitted │
|
||||||
│ │
|
│ │
|
||||||
│ 2. CHECK IF ELIGIBILITY ALREADY KNOWN │
|
│ 2. CHECK IF ELIGIBILITY ALREADY KNOWN │
|
||||||
│ └─ Query: SELECT Internet_Eligibility__c FROM Account │
|
│ └─ Query: SELECT Internet_Eligibility__c FROM Account │
|
||||||
@ -215,7 +219,6 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
|||||||
│ 5. UPDATE ACCOUNT │
|
│ 5. UPDATE ACCOUNT │
|
||||||
│ └─ Internet_Eligibility_Status__c = "Pending" │
|
│ └─ Internet_Eligibility_Status__c = "Pending" │
|
||||||
│ └─ Internet_Eligibility_Request_Date_Time__c = now() │
|
│ └─ Internet_Eligibility_Request_Date_Time__c = now() │
|
||||||
│ └─ Internet_Eligibility_Case_Id__c = Case.Id │
|
|
||||||
│ │
|
│ │
|
||||||
│ 6. CS PROCESSES CASE │
|
│ 6. CS PROCESSES CASE │
|
||||||
│ └─ Checks with NTT / provider │
|
│ └─ Checks with NTT / provider │
|
||||||
@ -223,9 +226,9 @@ This guide documents the Salesforce Opportunity integration for service lifecycl
|
|||||||
│ - Internet_Eligibility__c = result │
|
│ - Internet_Eligibility__c = result │
|
||||||
│ - Internet_Eligibility_Status__c = "Checked" │
|
│ - Internet_Eligibility_Status__c = "Checked" │
|
||||||
│ - Internet_Eligibility_Checked_Date_Time__c = now() │
|
│ - Internet_Eligibility_Checked_Date_Time__c = now() │
|
||||||
│ └─ Updates Opportunity: │
|
│ └─ Opportunity stages are not automatically moved to "Ready" by │
|
||||||
│ - Eligible → Stage: Ready │
|
│ the eligibility check. CS updates stages later as part of the │
|
||||||
│ - Not Eligible → Stage: Void │
|
│ order review / lifecycle workflow. │
|
||||||
│ │
|
│ │
|
||||||
│ 7. PORTAL DETECTS CHANGE (via CDC or polling) │
|
│ 7. PORTAL DETECTS CHANGE (via CDC or polling) │
|
||||||
│ └─ Shows eligibility result to customer │
|
│ └─ Shows eligibility result to customer │
|
||||||
|
|||||||
@ -89,8 +89,11 @@ The Account stores customer information and status fields.
|
|||||||
| Eligibility Status | `Internet_Eligibility_Status__c` | Picklist | `Pending`, `Checked` |
|
| Eligibility Status | `Internet_Eligibility_Status__c` | Picklist | `Pending`, `Checked` |
|
||||||
| Request Date | `Internet_Eligibility_Request_Date_Time__c` | DateTime | When request was made |
|
| Request Date | `Internet_Eligibility_Request_Date_Time__c` | DateTime | When request was made |
|
||||||
| Checked Date | `Internet_Eligibility_Checked_Date_Time__c` | DateTime | When checked by CS |
|
| Checked Date | `Internet_Eligibility_Checked_Date_Time__c` | DateTime | When checked by CS |
|
||||||
| Notes | `Internet_Eligibility_Notes__c` | Text Area | Agent notes |
|
|
||||||
| Case ID | `Internet_Eligibility_Case_Id__c` | Text | Linked Case ID |
|
**Notes:**
|
||||||
|
|
||||||
|
- The portal does **not** require Account-level fields for a Case ID or agent notes.
|
||||||
|
- Agent notes should live in the **Case Description** / Case activity history.
|
||||||
|
|
||||||
**ID Verification Fields:**
|
**ID Verification Fields:**
|
||||||
|
|
||||||
|
|||||||
@ -191,7 +191,7 @@ pnpm update [package-name]@latest
|
|||||||
## 📚 Additional Resources
|
## 📚 Additional Resources
|
||||||
|
|
||||||
- **Security Policy**: See `SECURITY.md`
|
- **Security Policy**: See `SECURITY.md`
|
||||||
- **Complete Guide**: See `docs/portal-guides/COMPLETE-GUIDE.md`
|
- **Complete Guide**: See `docs/how-it-works/COMPLETE-GUIDE.md`
|
||||||
- **GitHub Security**: [https://docs.github.com/en/code-security](https://docs.github.com/en/code-security)
|
- **GitHub Security**: [https://docs.github.com/en/code-security](https://docs.github.com/en/code-security)
|
||||||
- **npm Security**: [https://docs.npmjs.com/security](https://docs.npmjs.com/security)
|
- **npm Security**: [https://docs.npmjs.com/security](https://docs.npmjs.com/security)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user