diff --git a/README.md b/README.md index 97b60e32..1f399ae0 100644 --- a/README.md +++ b/README.md @@ -481,7 +481,7 @@ rm -rf node_modules && pnpm install - **[Deployment Guide](docs/DEPLOY.md)** - Production deployment instructions - **[Architecture](docs/STRUCTURE.md)** - Code organization and conventions - **[Logging](docs/LOGGING.md)** - Logging configuration and best practices -- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/portal-guides/README.md`) +- **Portal Guides** - High-level flow, data ownership, and error handling (`docs/how-it-works/README.md`) ## Contributing diff --git a/SECURITY.md b/SECURITY.md index 17ee66e5..68f89263 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -119,8 +119,8 @@ Security audits are automatically run on: ### Internal Documentation -- [Environment Configuration](./docs/portal-guides/COMPLETE-GUIDE.md) -- [Deployment Guide](./docs/portal-guides/) +- [Environment Configuration](./docs/how-it-works/COMPLETE-GUIDE.md) +- [Deployment Guide](./docs/getting-started/) ### External Resources diff --git a/apps/bff/src/core/config/env.validation.ts b/apps/bff/src/core/config/env.validation.ts index 51a329c3..f2f6bc68 100644 --- a/apps/bff/src/core/config/env.validation.ts +++ b/apps/bff/src/core/config/env.validation.ts @@ -54,6 +54,20 @@ export const envSchema = z.object({ "Authentication service is temporarily unavailable for maintenance. Please try again later." ), + /** + * Services catalog/eligibility cache safety TTL. + * + * Primary invalidation is event-driven (Salesforce CDC / Platform Events). + * This TTL is a safety net to self-heal if events are missed. + * + * Set to 0 to disable safety TTL (pure event-driven). + */ + SERVICES_CACHE_SAFETY_TTL_SECONDS: z.coerce + .number() + .int() + .min(0) + .default(60 * 60 * 12), + DATABASE_URL: z.string().url(), WHMCS_BASE_URL: z.string().url().optional(), @@ -139,9 +153,6 @@ export const envSchema = z.object({ ACCOUNT_INTERNET_ELIGIBILITY_CHECKED_AT_FIELD: z .string() .default("Internet_Eligibility_Checked_Date_Time__c"), - // Note: These fields are not used in the current Salesforce environment but kept in config schema for future compatibility - ACCOUNT_INTERNET_ELIGIBILITY_NOTES_FIELD: z.string().default("Internet_Eligibility_Notes__c"), - ACCOUNT_INTERNET_ELIGIBILITY_CASE_ID_FIELD: z.string().default("Internet_Eligibility_Case_Id__c"), ACCOUNT_ID_VERIFICATION_STATUS_FIELD: z.string().default("Id_Verification_Status__c"), ACCOUNT_ID_VERIFICATION_SUBMITTED_AT_FIELD: z diff --git a/apps/bff/src/modules/auth/decorators/public.decorator.ts b/apps/bff/src/modules/auth/decorators/public.decorator.ts index 7beff6f4..834188ee 100644 --- a/apps/bff/src/modules/auth/decorators/public.decorator.ts +++ b/apps/bff/src/modules/auth/decorators/public.decorator.ts @@ -2,3 +2,12 @@ import { SetMetadata } from "@nestjs/common"; export const IS_PUBLIC_KEY = "isPublic"; export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +/** + * Marks a route/controller as public *and* disables optional session attachment. + * + * Why: some endpoints must be strictly non-personalized for caching/security correctness + * (e.g. public service catalogs). These endpoints should ignore cookies/tokens entirely. + */ +export const IS_PUBLIC_NO_SESSION_KEY = "isPublicNoSession"; +export const PublicNoSession = () => SetMetadata(IS_PUBLIC_NO_SESSION_KEY, true); diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index 33359a06..15737857 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -5,7 +5,7 @@ import { Reflector } from "@nestjs/core"; import type { Request } from "express"; import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js"; -import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator.js"; +import { IS_PUBLIC_KEY, IS_PUBLIC_NO_SESSION_KEY } from "../../../decorators/public.decorator.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; @@ -45,8 +45,17 @@ export class GlobalAuthGuard implements CanActivate { context.getHandler(), context.getClass(), ]); + const isPublicNoSession = this.reflector.getAllAndOverride(IS_PUBLIC_NO_SESSION_KEY, [ + context.getHandler(), + context.getClass(), + ]); if (isPublic) { + if (isPublicNoSession) { + this.logger.debug(`Strict public route accessed (no session attach): ${route}`); + return true; + } + const token = extractAccessTokenFromRequest(request); if (token) { try { diff --git a/apps/bff/src/modules/services/account-services.controller.ts b/apps/bff/src/modules/services/account-services.controller.ts new file mode 100644 index 00000000..5101e8be --- /dev/null +++ b/apps/bff/src/modules/services/account-services.controller.ts @@ -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 { + 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 { + 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 { + const catalog = await this.vpnCatalog.getCatalogData(); + return parseVpnCatalog(catalog); + } +} diff --git a/apps/bff/src/modules/services/internet-eligibility.controller.ts b/apps/bff/src/modules/services/internet-eligibility.controller.ts index e945c8f8..7b45dfb7 100644 --- a/apps/bff/src/modules/services/internet-eligibility.controller.ts +++ b/apps/bff/src/modules/services/internet-eligibility.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req, UseGuards, UsePipes } from "@nestjs/common"; +import { Body, Controller, Get, Header, Post, Req, UseGuards, UsePipes } from "@nestjs/common"; import { ZodValidationPipe } from "nestjs-zod"; import { z } from "zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; @@ -31,6 +31,7 @@ export class InternetEligibilityController { @Get("eligibility") @RateLimit({ limit: 60, ttl: 60 }) // 60/min per IP (cheap) + @Header("Cache-Control", "private, no-store") async getEligibility(@Req() req: RequestWithUser): Promise { return this.internetCatalog.getEligibilityDetailsForUser(req.user.id); } @@ -38,6 +39,7 @@ export class InternetEligibilityController { @Post("eligibility-request") @RateLimit({ limit: 5, ttl: 300 }) // 5 per 5 minutes per IP @UsePipes(new ZodValidationPipe(eligibilityRequestSchema)) + @Header("Cache-Control", "private, no-store") async requestEligibility( @Req() req: RequestWithUser, @Body() body: EligibilityRequest diff --git a/apps/bff/src/modules/services/public-services.controller.ts b/apps/bff/src/modules/services/public-services.controller.ts new file mode 100644 index 00000000..57397b15 --- /dev/null +++ b/apps/bff/src/modules/services/public-services.controller.ts @@ -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 { + 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 { + 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 { + const catalog = await this.vpnCatalog.getCatalogData(); + return parseVpnCatalog(catalog); + } +} diff --git a/apps/bff/src/modules/services/services.controller.ts b/apps/bff/src/modules/services/services.controller.ts index 675fab43..c49ed4a7 100644 --- a/apps/bff/src/modules/services/services.controller.ts +++ b/apps/bff/src/modules/services/services.controller.ts @@ -5,6 +5,7 @@ import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; import { parseInternetCatalog, parseSimCatalog, + parseVpnCatalog, type InternetAddonCatalogItem, type InternetInstallationCatalogItem, type InternetPlanCatalogItem, @@ -12,6 +13,7 @@ import { type SimCatalogCollection, type SimCatalogProduct, type VpnCatalogProduct, + type VpnCatalogCollection, } from "@customer-portal/domain/services"; import { InternetServicesService } from "./services/internet-services.service.js"; import { SimServicesService } from "./services/sim-services.service.js"; @@ -100,8 +102,10 @@ export class ServicesController { @Get("vpn/plans") @RateLimit({ limit: 20, ttl: 60 }) // 20 requests per minute @Header("Cache-Control", "public, max-age=300, s-maxage=300") // 5 minutes - async getVpnPlans(): Promise { - return this.vpnCatalog.getPlans(); + async getVpnPlans(): Promise { + // Backwards-compatible: return the full VPN catalog (plans + activation fees) + const catalog = await this.vpnCatalog.getCatalogData(); + return parseVpnCatalog(catalog); } @Get("vpn/activation-fees") diff --git a/apps/bff/src/modules/services/services.module.ts b/apps/bff/src/modules/services/services.module.ts index 9a53aa8d..a9db1775 100644 --- a/apps/bff/src/modules/services/services.module.ts +++ b/apps/bff/src/modules/services/services.module.ts @@ -2,6 +2,8 @@ import { Module, forwardRef } from "@nestjs/common"; import { ServicesController } from "./services.controller.js"; import { ServicesHealthController } from "./services-health.controller.js"; import { InternetEligibilityController } from "./internet-eligibility.controller.js"; +import { PublicServicesController } from "./public-services.controller.js"; +import { AccountServicesController } from "./account-services.controller.js"; import { IntegrationsModule } from "@bff/integrations/integrations.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { CoreConfigModule } from "@bff/core/config/config.module.js"; @@ -22,7 +24,13 @@ import { ServicesCacheService } from "./services/services-cache.service.js"; CacheModule, QueueModule, ], - controllers: [ServicesController, ServicesHealthController, InternetEligibilityController], + controllers: [ + ServicesController, + PublicServicesController, + AccountServicesController, + ServicesHealthController, + InternetEligibilityController, + ], providers: [ BaseServicesService, InternetServicesService, diff --git a/apps/bff/src/modules/services/services/services-cache.service.ts b/apps/bff/src/modules/services/services/services-cache.service.ts index 1b70f649..4998560d 100644 --- a/apps/bff/src/modules/services/services/services-cache.service.ts +++ b/apps/bff/src/modules/services/services/services-cache.service.ts @@ -1,4 +1,5 @@ import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { CacheService } from "@bff/infra/cache/cache.service.js"; import type { CacheBucketMetrics, CacheDependencies } from "@bff/infra/cache/cache.types.js"; @@ -43,10 +44,10 @@ interface LegacyCatalogCachePayload { */ @Injectable() export class ServicesCacheService { - // CDC-driven invalidation: null TTL means cache persists until explicit invalidation - private readonly SERVICES_TTL: number | null = null; - private readonly STATIC_TTL: number | null = null; - private readonly ELIGIBILITY_TTL: number | null = null; + // CDC-driven invalidation + safety TTL (self-heal if events are missed) + private readonly SERVICES_TTL: number | null; + private readonly STATIC_TTL: number | null; + private readonly ELIGIBILITY_TTL: number | null; private readonly VOLATILE_TTL = 60; // Volatile data still uses TTL private readonly metrics: ServicesCacheSnapshot = { @@ -61,10 +62,21 @@ export class ServicesCacheService { // request the same data after CDC invalidation private readonly inflightRequests = new Map>(); - constructor(private readonly cache: CacheService) {} + constructor( + private readonly cache: CacheService, + private readonly config: ConfigService + ) { + const raw = this.config.get("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( 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(key: string, fetchFn: () => Promise): Promise { 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(key: string, fetchFn: () => Promise): Promise { return this.getOrSet("eligibility", key, this.ELIGIBILITY_TTL, fetchFn, { diff --git a/apps/bff/src/modules/services/services/sim-services.service.ts b/apps/bff/src/modules/services/services/sim-services.service.ts index bea4fe75..1d3cd450 100644 --- a/apps/bff/src/modules/services/services/sim-services.service.ts +++ b/apps/bff/src/modules/services/services/sim-services.service.ts @@ -184,22 +184,28 @@ export class SimServicesService extends BaseServicesService { return false; } - // Check WHMCS for existing SIM services - const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId }); - const services = (products?.products?.product || []) as Array<{ - groupname?: string; - status?: string; - }>; - - // Look for active SIM services - const hasActiveSim = services.some( - service => - String(service.groupname || "") - .toLowerCase() - .includes("sim") && String(service.status || "").toLowerCase() === "active" + const cacheKey = this.catalogCache.buildServicesKey( + "sim", + "has-existing-sim", + String(mapping.whmcsClientId) ); - return hasActiveSim; + // This is per-account and can be somewhat expensive (WHMCS call). + // Cache briefly to reduce repeat reads during account page refreshes. + return await this.catalogCache.getCachedVolatile(cacheKey, async () => { + const products = await this.whmcs.getClientsProducts({ clientid: mapping.whmcsClientId }); + const services = (products?.products?.product || []) as Array<{ + groupname?: string; + status?: string; + }>; + + // Look for active SIM services + return services.some(service => { + const group = String(service.groupname || "").toLowerCase(); + const status = String(service.status || "").toLowerCase(); + return group.includes("sim") && status === "active"; + }); + }); } catch (error) { this.logger.warn(`Failed to check existing SIM for user ${userId}`, error); return false; // Default to no existing SIM diff --git a/apps/bff/src/modules/services/services/vpn-services.service.ts b/apps/bff/src/modules/services/services/vpn-services.service.ts index 73bf577b..8b795ff1 100644 --- a/apps/bff/src/modules/services/services/vpn-services.service.ts +++ b/apps/bff/src/modules/services/services/vpn-services.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js"; import { BaseServicesService } from "./base-services.service.js"; +import { ServicesCacheService } from "./services-cache.service.js"; import type { SalesforceProduct2WithPricebookEntries, VpnCatalogProduct, @@ -14,43 +15,68 @@ export class VpnServicesService extends BaseServicesService { constructor( sf: SalesforceConnection, configService: ConfigService, - @Inject(Logger) logger: Logger + @Inject(Logger) logger: Logger, + private readonly catalogCache: ServicesCacheService ) { super(sf, configService, logger); } async getPlans(): Promise { - const soql = this.buildServicesQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]); - const records = await this.executeQuery( - soql, - "VPN Plans" - ); + const cacheKey = this.catalogCache.buildServicesKey("vpn", "plans"); - return records.map(record => { - const entry = this.extractPricebookEntry(record); - const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry); - return { - ...product, - description: product.description || product.name, - } satisfies VpnCatalogProduct; - }); + return this.catalogCache.getCachedServices( + cacheKey, + async () => { + const soql = this.buildServicesQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]); + const records = await this.executeQuery( + soql, + "VPN Plans" + ); + + return records.map(record => { + const entry = this.extractPricebookEntry(record); + const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry); + return { + ...product, + description: product.description || product.name, + } satisfies VpnCatalogProduct; + }); + }, + { + resolveDependencies: plans => ({ + productIds: plans.map(plan => plan.id).filter((id): id is string => Boolean(id)), + }), + } + ); } async getActivationFees(): Promise { - const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]); - const records = await this.executeQuery( - soql, - "VPN Activation Fees" + const cacheKey = this.catalogCache.buildServicesKey("vpn", "activation-fees"); + + return this.catalogCache.getCachedServices( + cacheKey, + async () => { + const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]); + const records = await this.executeQuery( + soql, + "VPN Activation Fees" + ); + + return records.map(record => { + const pricebookEntry = this.extractPricebookEntry(record); + const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry); + + return { + ...product, + description: product.description ?? product.name, + } satisfies VpnCatalogProduct; + }); + }, + { + resolveDependencies: fees => ({ + productIds: fees.map(fee => fee.id).filter((id): id is string => Boolean(id)), + }), + } ); - - return records.map(record => { - const pricebookEntry = this.extractPricebookEntry(record); - const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry); - - return { - ...product, - description: product.description ?? product.name, - } satisfies VpnCatalogProduct; - }); } async getCatalogData() { diff --git a/apps/bff/tsconfig.build.json b/apps/bff/tsconfig.build.json index 352367ef..d1129028 100644 --- a/apps/bff/tsconfig.build.json +++ b/apps/bff/tsconfig.build.json @@ -6,6 +6,6 @@ "rootDir": "./src", "sourceMap": true }, - "include": ["src/**/*"], + "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts"] } diff --git a/apps/bff/tsconfig.json b/apps/bff/tsconfig.json index 0d09c85c..a3db0387 100644 --- a/apps/bff/tsconfig.json +++ b/apps/bff/tsconfig.json @@ -15,6 +15,6 @@ "noEmit": true, "types": ["node"] }, - "include": ["src/**/*"], + "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "prisma", "test", "**/*.spec.ts", "**/*.test.ts"] } diff --git a/apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx b/apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx index 8da5fac7..badebabc 100644 --- a/apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx +++ b/apps/portal/src/app/(public)/(site)/services/internet/configure/page.tsx @@ -10,7 +10,7 @@ import { RedirectAuthenticatedToAccountServices } from "@/features/services/comp export default function PublicInternetConfigurePage() { return ( <> - + ); diff --git a/apps/portal/src/app/account/services/internet/request-submitted/page.tsx b/apps/portal/src/app/account/services/internet/request-submitted/page.tsx new file mode 100644 index 00000000..f9e2d880 --- /dev/null +++ b/apps/portal/src/app/account/services/internet/request-submitted/page.tsx @@ -0,0 +1,5 @@ +import InternetEligibilityRequestSubmittedView from "@/features/services/views/InternetEligibilityRequestSubmitted"; + +export default function AccountInternetEligibilityRequestSubmittedPage() { + return ; +} diff --git a/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx b/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx index b0b38169..841639ce 100644 --- a/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx +++ b/apps/portal/src/features/services/components/internet/InternetOfferingCard.tsx @@ -7,6 +7,7 @@ import { cn } from "@/lib/utils"; interface TierInfo { tier: "Silver" | "Gold" | "Platinum"; + planSku: string; monthlyPrice: number; description: string; features: string[]; @@ -62,6 +63,10 @@ export function InternetOfferingCard({ previewMode = false, }: InternetOfferingCardProps) { const Icon = iconType === "home" ? Home : Building2; + const resolveTierHref = (basePath: string, planSku: string): string => { + const joiner = basePath.includes("?") ? "&" : "?"; + return `${basePath}${joiner}planSku=${encodeURIComponent(planSku)}`; + }; return (
+ +
+
{children}
+
+
+ ); +} + +function SimPlanCardCompact({ + plan, + isFamily, + onSelect, +}: { + plan: SimCatalogProduct; + isFamily?: boolean; + onSelect: (sku: string) => void; +}) { + const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0; + + return ( +
+ {isFamily && ( +
+ + Family Discount +
+ )} + +
+
+
+ +
+ {plan.simDataSize} +
+
+ +
+ + {isFamily && ( +
Discounted price applied
+ )} +
+ +

{plan.name}

+ + +
+ ); +} + +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: , + title: "NTT Docomo Network", + description: "Best area coverage among the main three carriers in Japan", + highlight: "Nationwide coverage", + }, + { + icon: , + title: "First Month Free", + description: "Basic fee waived on signup to get you started risk-free", + highlight: "Great value", + }, + { + icon: , + title: "Foreign Cards Accepted", + description: "We accept both foreign and Japanese credit cards", + highlight: "No hassle", + }, + { + icon: , + title: "No Binding Contract", + description: "Minimum 4 months service (1st month free + 3 billing months)", + highlight: "Flexible contract", + }, + { + icon: , + title: "Number Portability", + description: "Easily switch to us keeping your current Japanese number", + highlight: "Keep your number", + }, + { + icon: , + title: "Free Plan Changes", + description: "Switch data plans anytime for the next billing cycle", + highlight: "Flexibility", + }, + ]; + + if (isLoading) { + return ( +
+ +
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+ + +
+
+
+ +
+
+ + +
+ +
+ ))} +
+
+ ); + } + + if (error) { + const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; + return ( +
+ +
+
Failed to load SIM plans
+
{errorMessage}
+ +
+
+ ); + } + + const plansByType = simPlans.reduce( + (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 ( +
+ + +
+
+ + Powered by NTT DOCOMO +
+

+ Choose Your SIM Plan +

+

+ Get connected with Japan's best network coverage. Choose eSIM for quick digital + delivery or physical SIM shipped to your door. +

+
+ + {variant === "account" && hasExistingSim && ( +
+ +

+ You already have a SIM subscription. Discounted pricing is automatically shown for + additional lines. +

+
+
+ )} + + + +
+
+ + + +
+
+ +
+ {regularPlans.length > 0 || familyPlans.length > 0 ? ( +
+ {regularPlans.length > 0 && ( +
+ {regularPlans.map(plan => ( + + ))} +
+ )} + + {variant === "account" && hasExistingSim && familyPlans.length > 0 && ( +
+
+ +

Family Discount Plans

+
+
+ {familyPlans.map(plan => ( + + ))} +
+
+ )} +
+ ) : ( +
+ No plans available in this category. +
+ )} +
+ +
+ +
+
+

+ + + + Domestic (Japan) +

+
+
+
Voice Calls
+
+ ¥10/30 sec +
+
+
+
SMS
+
+ ¥3/message +
+
+
+

+ Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage. +

+
+ +
+
+ +
+

Unlimited Domestic Calling

+

+ Add unlimited domestic calls for{" "} + ¥3,000/month (available at + checkout) +

+
+
+
+ +
+

+ International calling rates vary by country (¥31-148/30 sec). See{" "} + + NTT Docomo's website + {" "} + for full details. +

+
+
+
+ + +
+
+

One-time Fees

+
+
+ Activation Fee + ¥1,500 +
+
+ SIM Replacement (lost/damaged) + ¥1,500 +
+
+ eSIM Re-download + ¥1,500 +
+
+
+ +
+

Family Discount

+

+ ¥300/month off per additional + Voice SIM on your account +

+
+ +

All prices exclude 10% consumption tax.

+
+
+ + +
+
+

+ + Important Notices +

+
    +
  • + + + ID verification with official documents (name, date of birth, address, photo) is + required during checkout. + +
  • +
  • + + + A compatible unlocked device is required. Check compatibility on our website. + +
  • +
  • + + + Service may not be available in areas with weak signal. See{" "} + + NTT Docomo coverage map + + . + +
  • +
  • + + + SIM is activated as 4G by default. 5G can be requested via your account portal. + +
  • +
  • + + + International data roaming is not available. Voice/SMS roaming can be enabled + upon request (¥50,000/month limit). + +
  • +
+
+ +
+

Contract Terms

+
    +
  • + + + Minimum contract: 3 full billing + months. First month (sign-up to end of month) is free and doesn't count. + +
  • +
  • + + + Billing cycle: 1st to end of month. + Regular billing starts the 1st of the following month after sign-up. + +
  • +
  • + + + Cancellation: Can be requested + after 3rd month via cancellation form. Monthly fee is incurred in full for + cancellation month. + +
  • +
  • + + + SIM return: SIM card must be + returned after service termination. + +
  • +
+
+ +
+

Additional Options

+
    +
  • + + Call waiting and voice mail available as separate paid options. +
  • +
  • + + Data plan changes are free and take effect next billing month. +
  • +
  • + + + Voice plan changes require new SIM issuance and standard policies apply. + +
  • +
+
+ +
+

+ 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. +

+
+
+
+
+ +
+

+ All prices exclude 10% consumption tax.{" "} + + View full Terms of Service + +

+
+
+ ); +} + +export default SimPlansContent; diff --git a/apps/portal/src/features/services/hooks/useInternetConfigure.ts b/apps/portal/src/features/services/hooks/useInternetConfigure.ts index 5ee09adf..89d45b45 100644 --- a/apps/portal/src/features/services/hooks/useInternetConfigure.ts +++ b/apps/portal/src/features/services/hooks/useInternetConfigure.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useInternetCatalog, useInternetPlan } from "."; +import { useAccountInternetCatalog } from "."; import { useCatalogStore } from "../services/services.store"; import { useServicesBasePath } from "./useServicesBasePath"; import type { AccessModeValue } from "@customer-portal/domain/orders"; @@ -55,8 +55,12 @@ export function useInternetConfigure(): UseInternetConfigureResult { const lastRestoredSignatureRef = useRef(null); // Fetch services data from BFF - const { data: internetData, isLoading: internetLoading } = useInternetCatalog(); - const { plan: selectedPlan } = useInternetPlan(configState.planSku || urlPlanSku || undefined); + const { data: internetData, isLoading: internetLoading } = useAccountInternetCatalog(); + const selectedPlanSku = configState.planSku || urlPlanSku || undefined; + const selectedPlan = useMemo(() => { + if (!selectedPlanSku) return null; + return (internetData?.plans ?? []).find(p => p.sku === selectedPlanSku) ?? null; + }, [internetData?.plans, selectedPlanSku]); // Initialize/restore state on mount useEffect(() => { diff --git a/apps/portal/src/features/services/hooks/useServices.ts b/apps/portal/src/features/services/hooks/useServices.ts index ef6068ca..40cb9997 100644 --- a/apps/portal/src/features/services/hooks/useServices.ts +++ b/apps/portal/src/features/services/hooks/useServices.ts @@ -6,57 +6,106 @@ import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "@/lib/api"; import { servicesService } from "../services"; +import { useAuthSession } from "@/features/auth/services/auth.store"; + +type ServicesCatalogScope = "public" | "account"; + +function withScope(key: T, scope: ServicesCatalogScope) { + return [...key, scope] as const; +} /** - * Internet services composite hook - * Fetches plans and installations together + * Internet catalog (public vs account) */ -export function useInternetCatalog() { +export function usePublicInternetCatalog() { return useQuery({ - queryKey: queryKeys.services.internet.combined(), - queryFn: () => servicesService.getInternetCatalog(), + queryKey: withScope(queryKeys.services.internet.combined(), "public"), + queryFn: () => servicesService.getPublicInternetCatalog(), + }); +} + +export function useAccountInternetCatalog() { + const { isAuthenticated } = useAuthSession(); + return useQuery({ + queryKey: withScope(queryKeys.services.internet.combined(), "account"), + enabled: isAuthenticated, + queryFn: () => servicesService.getAccountInternetCatalog(), }); } /** - * SIM services composite hook - * Fetches plans, activation fees, and addons together + * SIM catalog (public vs account) */ -export function useSimCatalog() { +export function usePublicSimCatalog() { return useQuery({ - queryKey: queryKeys.services.sim.combined(), - queryFn: () => servicesService.getSimCatalog(), + queryKey: withScope(queryKeys.services.sim.combined(), "public"), + queryFn: () => servicesService.getPublicSimCatalog(), + }); +} + +export function useAccountSimCatalog() { + const { isAuthenticated } = useAuthSession(); + return useQuery({ + queryKey: withScope(queryKeys.services.sim.combined(), "account"), + enabled: isAuthenticated, + queryFn: () => servicesService.getAccountSimCatalog(), }); } /** - * VPN services hook - * Fetches VPN plans and activation fees + * VPN catalog (public vs account) */ -export function useVpnCatalog() { +export function usePublicVpnCatalog() { return useQuery({ - queryKey: queryKeys.services.vpn.combined(), - queryFn: () => servicesService.getVpnCatalog(), + queryKey: withScope(queryKeys.services.vpn.combined(), "public"), + queryFn: () => servicesService.getPublicVpnCatalog(), + }); +} + +export function useAccountVpnCatalog() { + const { isAuthenticated } = useAuthSession(); + return useQuery({ + queryKey: withScope(queryKeys.services.vpn.combined(), "account"), + enabled: isAuthenticated, + queryFn: () => servicesService.getAccountVpnCatalog(), }); } /** - * Lookup helpers by SKU + * Lookup helpers by SKU (explicit scope) */ -export function useInternetPlan(sku?: string) { - const { data, ...rest } = useInternetCatalog(); +export function usePublicInternetPlan(sku?: string) { + const { data, ...rest } = usePublicInternetCatalog(); const plan = (data?.plans || []).find(p => p.sku === sku); return { plan, ...rest } as const; } -export function useSimPlan(sku?: string) { - const { data, ...rest } = useSimCatalog(); +export function useAccountInternetPlan(sku?: string) { + const { data, ...rest } = useAccountInternetCatalog(); const plan = (data?.plans || []).find(p => p.sku === sku); return { plan, ...rest } as const; } -export function useVpnPlan(sku?: string) { - const { data, ...rest } = useVpnCatalog(); +export function usePublicSimPlan(sku?: string) { + const { data, ...rest } = usePublicSimCatalog(); + const plan = (data?.plans || []).find(p => p.sku === sku); + return { plan, ...rest } as const; +} + +export function useAccountSimPlan(sku?: string) { + const { data, ...rest } = useAccountSimCatalog(); + const plan = (data?.plans || []).find(p => p.sku === sku); + return { plan, ...rest } as const; +} + +export function usePublicVpnPlan(sku?: string) { + const { data, ...rest } = usePublicVpnCatalog(); + const plan = (data?.plans || []).find(p => p.sku === sku); + return { plan, ...rest } as const; +} + +export function useAccountVpnPlan(sku?: string) { + const { data, ...rest } = useAccountVpnCatalog(); const plan = (data?.plans || []).find(p => p.sku === sku); return { plan, ...rest } as const; } @@ -64,20 +113,20 @@ export function useVpnPlan(sku?: string) { /** * Addon/installation lookup helpers by SKU */ -export function useInternetInstallation(sku?: string) { - const { data, ...rest } = useInternetCatalog(); +export function useAccountInternetInstallation(sku?: string) { + const { data, ...rest } = useAccountInternetCatalog(); const installation = (data?.installations || []).find(i => i.sku === sku); return { installation, ...rest } as const; } -export function useInternetAddon(sku?: string) { - const { data, ...rest } = useInternetCatalog(); +export function useAccountInternetAddon(sku?: string) { + const { data, ...rest } = useAccountInternetCatalog(); const addon = (data?.addons || []).find(a => a.sku === sku); return { addon, ...rest } as const; } -export function useSimAddon(sku?: string) { - const { data, ...rest } = useSimCatalog(); +export function useAccountSimAddon(sku?: string) { + const { data, ...rest } = useAccountSimCatalog(); const addon = (data?.addons || []).find(a => a.sku === sku); return { addon, ...rest } as const; } diff --git a/apps/portal/src/features/services/hooks/useSimConfigure.ts b/apps/portal/src/features/services/hooks/useSimConfigure.ts index dfdbb072..8f30f5d7 100644 --- a/apps/portal/src/features/services/hooks/useSimConfigure.ts +++ b/apps/portal/src/features/services/hooks/useSimConfigure.ts @@ -2,7 +2,7 @@ import { useEffect, useCallback, useMemo, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useSimCatalog, useSimPlan } from "."; +import { useAccountSimCatalog } from "."; import { useCatalogStore } from "../services/services.store"; import { useServicesBasePath } from "./useServicesBasePath"; import { @@ -68,8 +68,12 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { const lastRestoredSignatureRef = useRef(null); // Fetch services data from BFF - const { data: simData, isLoading: simLoading } = useSimCatalog(); - const { plan: selectedPlan } = useSimPlan(configState.planSku || urlPlanSku || planId); + const { data: simData, isLoading: simLoading } = useAccountSimCatalog(); + const selectedPlanSku = configState.planSku || urlPlanSku || planId; + const selectedPlan = useMemo(() => { + if (!selectedPlanSku) return null; + return (simData?.plans ?? []).find(p => p.sku === selectedPlanSku) ?? null; + }, [simData?.plans, selectedPlanSku]); // Initialize/restore state on mount useEffect(() => { diff --git a/apps/portal/src/features/services/services/services.service.ts b/apps/portal/src/features/services/services/services.service.ts index e20bac90..e76ada16 100644 --- a/apps/portal/src/features/services/services/services.service.ts +++ b/apps/portal/src/features/services/services/services.service.ts @@ -21,8 +21,14 @@ import { import type { Address } from "@customer-portal/domain/customer"; export const servicesService = { - async getInternetCatalog(): Promise { - const response = await apiClient.GET("/api/services/internet/plans"); + // ============================================================================ + // Public (non-personalized) catalog endpoints + // ============================================================================ + + async getPublicInternetCatalog(): Promise { + const response = await apiClient.GET( + "/api/public/services/internet/plans" + ); const data = getDataOrThrow( response, "Failed to load internet services" @@ -30,6 +36,52 @@ export const servicesService = { return data; // BFF already validated }, + /** + * @deprecated Use getPublicInternetCatalog() or getAccountInternetCatalog() for clear separation. + */ + async getInternetCatalog(): Promise { + return this.getPublicInternetCatalog(); + }, + + async getPublicSimCatalog(): Promise { + const response = await apiClient.GET("/api/public/services/sim/plans"); + const data = getDataOrDefault(response, EMPTY_SIM_CATALOG); + return data; // BFF already validated + }, + + async getPublicVpnCatalog(): Promise { + const response = await apiClient.GET("/api/public/services/vpn/plans"); + const data = getDataOrDefault(response, EMPTY_VPN_CATALOG); + return data; // BFF already validated + }, + + // ============================================================================ + // Account (authenticated + personalized) catalog endpoints + // ============================================================================ + + async getAccountInternetCatalog(): Promise { + const response = await apiClient.GET( + "/api/account/services/internet/plans" + ); + const data = getDataOrThrow( + response, + "Failed to load internet services" + ); + return data; // BFF already validated + }, + + async getAccountSimCatalog(): Promise { + const response = await apiClient.GET("/api/account/services/sim/plans"); + const data = getDataOrDefault(response, EMPTY_SIM_CATALOG); + return data; // BFF already validated + }, + + async getAccountVpnCatalog(): Promise { + const response = await apiClient.GET("/api/account/services/vpn/plans"); + const data = getDataOrDefault(response, EMPTY_VPN_CATALOG); + return data; // BFF already validated + }, + async getInternetInstallations(): Promise { const response = await apiClient.GET( "/api/services/internet/installations" @@ -46,10 +98,11 @@ export const servicesService = { return internetAddonCatalogItemSchema.array().parse(data); }, + /** + * @deprecated Use getPublicSimCatalog() or getAccountSimCatalog() for clear separation. + */ async getSimCatalog(): Promise { - const response = await apiClient.GET("/api/services/sim/plans"); - const data = getDataOrDefault(response, EMPTY_SIM_CATALOG); - return data; // BFF already validated + return this.getPublicSimCatalog(); }, async getSimActivationFees(): Promise { @@ -66,10 +119,11 @@ export const servicesService = { return simCatalogProductSchema.array().parse(data); }, + /** + * @deprecated Use getPublicVpnCatalog() or getAccountVpnCatalog() for clear separation. + */ async getVpnCatalog(): Promise { - const response = await apiClient.GET("/api/services/vpn/plans"); - const data = getDataOrDefault(response, EMPTY_VPN_CATALOG); - return data; // BFF already validated + return this.getPublicVpnCatalog(); }, async getVpnActivationFees(): Promise { diff --git a/apps/portal/src/features/services/views/InternetConfigure.tsx b/apps/portal/src/features/services/views/InternetConfigure.tsx index f41396c7..b65c9576 100644 --- a/apps/portal/src/features/services/views/InternetConfigure.tsx +++ b/apps/portal/src/features/services/views/InternetConfigure.tsx @@ -1,15 +1,59 @@ "use client"; +import { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; import { logger } from "@/lib/logger"; import { useInternetConfigure } from "@/features/services/hooks/useInternetConfigure"; +import { useInternetEligibility } from "@/features/services/hooks/useInternetEligibility"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { InternetConfigureView as InternetConfigureInnerView } from "@/features/services/components/internet/InternetConfigureView"; +import { Spinner } from "@/components/atoms/Spinner"; export function InternetConfigureContainer() { const router = useRouter(); const pathname = usePathname(); + const servicesBasePath = useServicesBasePath(); + const eligibilityQuery = useInternetEligibility(); const vm = useInternetConfigure(); + // Keep /internet/configure strictly in the post-eligibility path. + useEffect(() => { + if (!pathname.startsWith("/account")) return; + if (!eligibilityQuery.isSuccess) return; + if (eligibilityQuery.data.status === "eligible") return; + router.replace(`${servicesBasePath}/internet`); + }, [ + eligibilityQuery.data?.status, + eligibilityQuery.isSuccess, + pathname, + router, + servicesBasePath, + ]); + + if (pathname.startsWith("/account")) { + if (eligibilityQuery.isLoading) { + return ( +
+
+ +

Checking availability…

+
+
+ ); + } + + if (eligibilityQuery.isSuccess && eligibilityQuery.data.status !== "eligible") { + return ( +
+
+ +

Redirecting…

+
+
+ ); + } + } + // Debug: log current state logger.debug("InternetConfigure state", { plan: vm.plan?.sku, diff --git a/apps/portal/src/features/services/views/InternetEligibilityRequestSubmitted.tsx b/apps/portal/src/features/services/views/InternetEligibilityRequestSubmitted.tsx new file mode 100644 index 00000000..1444a74a --- /dev/null +++ b/apps/portal/src/features/services/views/InternetEligibilityRequestSubmitted.tsx @@ -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 ( +
+ + +
+
+
+ +
+
+

Availability request submitted

+

+ We'll verify NTT service availability for your address. This typically takes 1-2 + business days. +

+ + {addressLabel && ( +
+
+ Address on file +
+
{addressLabel}
+
+ )} + + {requestId && ( +
+
+ Request ID +
+
{requestId}
+
+ )} + +
+ + +
+
+
+ + {(isPending || isEligible || isIneligible) && ( +
+ {isPending && ( + +
+ + + We'll email you once our team completes the manual serviceability check. + +
+
+ )} + {isEligible && ( + + Your address is eligible. You can now choose a plan and complete your order. + + )} + {isIneligible && ( + + It looks like service isn't available at your address. Please contact support + if you think this is incorrect. + + )} +
+ )} +
+
+ ); +} + +export default InternetEligibilityRequestSubmittedView; diff --git a/apps/portal/src/features/services/views/InternetPlans.tsx b/apps/portal/src/features/services/views/InternetPlans.tsx index 86436812..1a729b31 100644 --- a/apps/portal/src/features/services/views/InternetPlans.tsx +++ b/apps/portal/src/features/services/views/InternetPlans.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Server, CheckCircle, Clock, TriangleAlert, MapPin } from "lucide-react"; -import { useInternetCatalog } from "@/features/services/hooks"; +import { useAccountInternetCatalog } from "@/features/services/hooks"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; import type { InternetPlanCatalogItem, @@ -119,6 +119,7 @@ function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): Ti const config = tierDescriptions[tier]; result.push({ tier, + planSku: plan.sku, monthlyPrice: plan.monthlyPrice ?? 0, description: config.description, features: config.features, @@ -294,7 +295,7 @@ export function InternetPlansContainer() { const servicesBasePath = useServicesBasePath(); const searchParams = useSearchParams(); const { user } = useAuthSession(); - const { data, isLoading, error } = useInternetCatalog(); + const { data, isLoading, error } = useAccountInternetCatalog(); const eligibilityQuery = useInternetEligibility(); const eligibilityLoading = eligibilityQuery.isLoading; const refetchEligibility = eligibilityQuery.refetch; @@ -401,7 +402,18 @@ export function InternetPlansContainer() { window.confirm(`Request availability check for:\n\n${addressLabel}`); if (!confirmed) return; - eligibilityRequest.mutate({ address: user?.address ?? undefined }); + setAutoRequestId(null); + setAutoRequestStatus("submitting"); + try { + const result = await submitEligibilityRequest({ address: user?.address ?? undefined }); + setAutoRequestId(result.requestId ?? null); + setAutoRequestStatus("submitted"); + await refetchEligibility(); + const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : ""; + router.push(`${servicesBasePath}/internet/request-submitted${query}`); + } catch { + setAutoRequestStatus("failed"); + } }; // Auto eligibility request effect @@ -432,11 +444,13 @@ export function InternetPlansContainer() { setAutoRequestId(result.requestId ?? null); setAutoRequestStatus("submitted"); await refetchEligibility(); + const query = result.requestId ? `?requestId=${encodeURIComponent(result.requestId)}` : ""; + router.replace(`${servicesBasePath}/internet/request-submitted${query}`); + return; } catch { setAutoRequestStatus("failed"); - } finally { - router.replace(`${servicesBasePath}/internet`); } + router.replace(`${servicesBasePath}/internet`); }; void submit(); diff --git a/apps/portal/src/features/services/views/PublicInternetConfigure.tsx b/apps/portal/src/features/services/views/PublicInternetConfigure.tsx index 89762b56..47c70209 100644 --- a/apps/portal/src/features/services/views/PublicInternetConfigure.tsx +++ b/apps/portal/src/features/services/views/PublicInternetConfigure.tsx @@ -5,7 +5,7 @@ import { WifiIcon, ClockIcon, EnvelopeIcon, CheckCircleIcon } from "@heroicons/r import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; -import { useInternetPlan } from "@/features/services/hooks"; +import { usePublicInternetPlan } from "@/features/services/hooks"; import { CardPricing } from "@/features/services/components/base/CardPricing"; import { Skeleton } from "@/components/atoms/loading-skeleton"; @@ -18,7 +18,7 @@ export function PublicInternetConfigureView() { const servicesBasePath = useServicesBasePath(); const searchParams = useSearchParams(); const planSku = searchParams?.get("planSku"); - const { plan, isLoading } = useInternetPlan(planSku || undefined); + const { plan, isLoading } = usePublicInternetPlan(planSku || undefined); const redirectTo = planSku ? `/account/services/internet?autoEligibilityRequest=1&planSku=${encodeURIComponent(planSku)}` diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index 052724e2..9e8423d8 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -13,7 +13,7 @@ import { Wrench, Globe, } from "lucide-react"; -import { useInternetCatalog } from "@/features/services/hooks"; +import { usePublicInternetCatalog } from "@/features/services/hooks"; import type { InternetPlanCatalogItem, InternetInstallationCatalogItem, @@ -133,7 +133,7 @@ export function PublicInternetPlansContent({ heroTitle = "Internet Service Plans", heroDescription = "NTT Optical Fiber with full English support", }: PublicInternetPlansContentProps) { - const { data: servicesCatalog, isLoading, error } = useInternetCatalog(); + const { data: servicesCatalog, isLoading, error } = usePublicInternetCatalog(); const servicesBasePath = useServicesBasePath(); const defaultCtaPath = `${servicesBasePath}/internet/configure`; const ctaPath = propCtaPath ?? defaultCtaPath; diff --git a/apps/portal/src/features/services/views/PublicSimConfigure.tsx b/apps/portal/src/features/services/views/PublicSimConfigure.tsx index 871bc4a3..681f6090 100644 --- a/apps/portal/src/features/services/views/PublicSimConfigure.tsx +++ b/apps/portal/src/features/services/views/PublicSimConfigure.tsx @@ -5,7 +5,7 @@ import { DevicePhoneMobileIcon, CheckIcon, BoltIcon } from "@heroicons/react/24/ import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; -import { useSimPlan } from "@/features/services/hooks"; +import { usePublicSimPlan } from "@/features/services/hooks"; import { InlineAuthSection } from "@/features/auth/components/InlineAuthSection/InlineAuthSection"; import { CardPricing } from "@/features/services/components/base/CardPricing"; import { Skeleton } from "@/components/atoms/loading-skeleton"; @@ -20,7 +20,7 @@ export function PublicSimConfigureView() { const servicesBasePath = useServicesBasePath(); const searchParams = useSearchParams(); const planSku = searchParams?.get("planSku"); - const { plan, isLoading } = useSimPlan(planSku || undefined); + const { plan, isLoading } = usePublicSimPlan(planSku || undefined); const redirectTarget = planSku ? `/account/services/sim/configure?planSku=${encodeURIComponent(planSku)}` diff --git a/apps/portal/src/features/services/views/PublicSimPlans.tsx b/apps/portal/src/features/services/views/PublicSimPlans.tsx index 373a59c9..2b30f103 100644 --- a/apps/portal/src/features/services/views/PublicSimPlans.tsx +++ b/apps/portal/src/features/services/views/PublicSimPlans.tsx @@ -1,554 +1,42 @@ "use client"; import { useMemo, useState } from "react"; -import { - Smartphone, - Check, - Phone, - Globe, - ArrowLeft, - Signal, - Sparkles, - ChevronDown, - Info, - CircleDollarSign, - TriangleAlert, - CreditCard, - Calendar, - ArrowRightLeft, - ArrowRight, -} from "lucide-react"; -import { Skeleton } from "@/components/atoms/loading-skeleton"; -import { Button } from "@/components/atoms/button"; -import { useSimCatalog } from "@/features/services/hooks"; +import { useRouter } from "next/navigation"; import type { SimCatalogProduct } from "@customer-portal/domain/services"; -import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; +import { usePublicSimCatalog } from "@/features/services/hooks"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; -import { CardPricing } from "@/features/services/components/base/CardPricing"; import { - ServiceHighlights, - HighlightFeature, -} from "@/features/services/components/base/ServiceHighlights"; - -interface PlansByType { - DataOnly: SimCatalogProduct[]; - DataSmsVoice: SimCatalogProduct[]; - VoiceOnly: SimCatalogProduct[]; -} - -// Collapsible section component -function CollapsibleSection({ - title, - icon: Icon, - defaultOpen = false, - children, -}: { - title: string; - icon: React.ElementType; - defaultOpen?: boolean; - children: React.ReactNode; -}) { - const [isOpen, setIsOpen] = useState(defaultOpen); - - return ( -
- -
-
{children}
-
-
- ); -} - -// 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 ( -
- {/* Data Size Badge */} -
-
-
- -
- {plan.simDataSize} -
-
- - {/* Price */} -
- -
- - {/* Plan name */} -

{plan.name}

- - {/* CTA */} - -
- ); -} + SimPlansContent, + type SimPlansTab, +} from "@/features/services/components/sim/SimPlansContent"; /** * Public SIM Plans View * * Displays SIM plans for unauthenticated users. - * Clean, focused design with plan selection. + * Uses the shared plans UI, with a public navigation handler. */ export function PublicSimPlansView() { + const router = useRouter(); const servicesBasePath = useServicesBasePath(); - const { data, isLoading, error } = useSimCatalog(); - const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); - const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( - "data-voice" - ); + const { data, isLoading, error } = usePublicSimCatalog(); + const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); + const [activeTab, setActiveTab] = useState("data-voice"); const handleSelectPlan = (planSku: string) => { - window.location.href = `${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`; + router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`); }; - const simFeatures: HighlightFeature[] = [ - { - icon: , - title: "NTT Docomo Network", - description: "Best area coverage among the main three carriers in Japan", - highlight: "Nationwide coverage", - }, - { - icon: , - title: "First Month Free", - description: "Basic fee waived on signup to get you started risk-free", - highlight: "Great value", - }, - { - icon: , - title: "Foreign Cards Accepted", - description: "We accept both foreign and Japanese credit cards", - highlight: "No hassle", - }, - { - icon: , - title: "No Binding Contract", - description: "Minimum 4 months service (1st month free + 3 billing months)", - highlight: "Flexible contract", - }, - { - icon: , - title: "Number Portability", - description: "Easily switch to us keeping your current Japanese number", - highlight: "Keep your number", - }, - { - icon: , - title: "Free Plan Changes", - description: "Switch data plans anytime for the next billing cycle", - highlight: "Flexibility", - }, - ]; - - if (isLoading) { - return ( -
- -
- - -
-
- {Array.from({ length: 4 }).map((_, i) => ( -
- - - - -
- ))} -
-
- ); - } - - if (error) { - const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; - return ( -
-
-
Failed to load SIM plans
-
{errorMessage}
- -
-
- ); - } - - const plansByType = simPlans.reduce( - (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 ( -
- - - {/* Hero Section - Clean & Minimal */} -
-
- - Powered by NTT DOCOMO -
-

Mobile SIM Plans

-

- Get connected with Japan's best network coverage. Choose eSIM for quick digital delivery - or physical SIM shipped to your door. -

-
- - {/* Service Highlights */} - - - {/* Plan Type Tabs */} -
-
- - - -
-
- - {/* Plan Cards Grid */} -
- {currentPlans.length > 0 ? ( -
- {currentPlans.map(plan => ( - - ))} -
- ) : ( -
- No plans available in this category. -
- )} -
- - {/* Collapsible Information Sections */} -
- {/* Calling & SMS Rates */} - -
- {/* Domestic Rates */} -
-

- - - - Domestic (Japan) -

-
-
-
Voice Calls
-
- ¥10/30 sec -
-
-
-
SMS
-
- ¥3/message -
-
-
-

- Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage. -

-
- - {/* Unlimited Option */} -
-
- -
-

Unlimited Domestic Calling

-

- Add unlimited domestic calls for{" "} - ¥3,000/month (available at - checkout) -

-
-
-
- - {/* International Note */} -
-

- International calling rates vary by country (¥31-148/30 sec). See{" "} - - NTT Docomo's website - {" "} - for full details. -

-
-
-
- - {/* Fees & Discounts */} - -
- {/* Fees */} -
-

One-time Fees

-
-
- Activation Fee - ¥1,500 -
-
- SIM Replacement (lost/damaged) - ¥1,500 -
-
- eSIM Re-download - ¥1,500 -
-
-
- - {/* Discounts */} -
-

Family Discount

-

- ¥300/month off per additional - Voice SIM on your account -

-
- -

All prices exclude 10% consumption tax.

-
-
- - {/* Important Information & Terms */} - -
- {/* Key Notices */} -
-

- - Important Notices -

-
    -
  • - - - ID verification with official documents (name, date of birth, address, photo) is - required during checkout. - -
  • -
  • - - - A compatible unlocked device is required. Check compatibility on our website. - -
  • -
  • - - - Service may not be available in areas with weak signal. See{" "} - - NTT Docomo coverage map - - . - -
  • -
  • - - - SIM is activated as 4G by default. 5G can be requested via your account portal. - -
  • -
  • - - - International data roaming is not available. Voice/SMS roaming can be enabled - upon request (¥50,000/month limit). - -
  • -
-
- - {/* Contract Terms */} -
-

Contract Terms

-
    -
  • - - - Minimum contract: 3 full billing - months. First month (sign-up to end of month) is free and doesn't count. - -
  • -
  • - - - Billing cycle: 1st to end of month. - Regular billing starts the 1st of the following month after sign-up. - -
  • -
  • - - - Cancellation: Can be requested - after 3rd month via cancellation form. Monthly fee is incurred in full for - cancellation month. - -
  • -
  • - - - SIM return: SIM card must be - returned after service termination. - -
  • -
-
- - {/* Additional Options */} -
-

Additional Options

-
    -
  • - - Call waiting and voice mail available as separate paid options. -
  • -
  • - - Data plan changes are free and take effect next billing month. -
  • -
  • - - - Voice plan changes require new SIM issuance and standard policies apply. - -
  • -
-
- - {/* Disclaimer */} -
-

- 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. -

-
-
-
-
- - {/* Terms Footer */} -
-

- All prices exclude 10% consumption tax.{" "} - - View full Terms of Service - -

-
-
+ ); } diff --git a/apps/portal/src/features/services/views/PublicVpnPlans.tsx b/apps/portal/src/features/services/views/PublicVpnPlans.tsx index 0ba97255..ba86dc7c 100644 --- a/apps/portal/src/features/services/views/PublicVpnPlans.tsx +++ b/apps/portal/src/features/services/views/PublicVpnPlans.tsx @@ -1,7 +1,7 @@ "use client"; import { ShieldCheck, Zap } from "lucide-react"; -import { useVpnCatalog } from "@/features/services/hooks"; +import { usePublicVpnCatalog } from "@/features/services/hooks"; import { LoadingCard } from "@/components/atoms"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; @@ -17,7 +17,7 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa */ export function PublicVpnPlansView() { const servicesBasePath = useServicesBasePath(); - const { data, isLoading, error } = useVpnCatalog(); + const { data, isLoading, error } = usePublicVpnCatalog(); const vpnPlans = data?.plans || []; const activationFees = data?.activationFees || []; diff --git a/apps/portal/src/features/services/views/SimPlans.tsx b/apps/portal/src/features/services/views/SimPlans.tsx index a4109f50..0881df75 100644 --- a/apps/portal/src/features/services/views/SimPlans.tsx +++ b/apps/portal/src/features/services/views/SimPlans.tsx @@ -1,616 +1,42 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { - Smartphone, - Check, - Phone, - Globe, - ArrowLeft, - Signal, - Sparkles, - CreditCard, - ChevronDown, - Info, - CircleDollarSign, - TriangleAlert, - Calendar, - ArrowRightLeft, - ArrowRight, - Users, -} from "lucide-react"; -import { Skeleton } from "@/components/atoms/loading-skeleton"; -import { Button } from "@/components/atoms/button"; -import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; -import { useSimCatalog } from "@/features/services/hooks"; -import type { SimCatalogProduct } from "@customer-portal/domain/services"; -import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; -import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; -import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; -import { CardPricing } from "@/features/services/components/base/CardPricing"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; +import type { SimCatalogProduct } from "@customer-portal/domain/services"; +import { useAccountSimCatalog } from "@/features/services/hooks"; +import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { - ServiceHighlights, - HighlightFeature, -} from "@/features/services/components/base/ServiceHighlights"; - -interface PlansByType { - DataOnly: SimCatalogProduct[]; - DataSmsVoice: SimCatalogProduct[]; - VoiceOnly: SimCatalogProduct[]; -} - -// Collapsible section component -function CollapsibleSection({ - title, - icon: Icon, - defaultOpen = false, - children, -}: { - title: string; - icon: React.ElementType; - defaultOpen?: boolean; - children: React.ReactNode; -}) { - const [isOpen, setIsOpen] = useState(defaultOpen); - - return ( -
- -
-
{children}
-
-
- ); -} - -// 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 ( -
- {/* Family Discount Badge */} - {isFamily && ( -
- - Family Discount -
- )} - - {/* Data Size Badge */} -
-
-
- -
- {plan.simDataSize} -
-
- - {/* Price */} -
- - {isFamily && ( -
Discounted price applied
- )} -
- - {/* Plan name */} -

{plan.name}

- - {/* CTA */} - -
- ); -} + SimPlansContent, + type SimPlansTab, +} from "@/features/services/components/sim/SimPlansContent"; +/** + * Account SIM Plans Container + * + * Fetches account context (payment methods + personalised catalog) and + * renders the shared SIM plans UI. + */ export function SimPlansContainer() { - const servicesBasePath = useServicesBasePath(); const router = useRouter(); - const { data, isLoading, error } = useSimCatalog(); - const simPlans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); - const [hasExistingSim, setHasExistingSim] = useState(false); - const [activeTab, setActiveTab] = useState<"data-voice" | "data-only" | "voice-only">( - "data-voice" - ); - const { data: paymentMethods, isLoading: paymentMethodsLoading } = usePaymentMethods(); - - useEffect(() => { - setHasExistingSim(simPlans.some(p => p.simHasFamilyDiscount)); - }, [simPlans]); + const servicesBasePath = useServicesBasePath(); + const { data, isLoading, error } = useAccountSimCatalog(); + const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]); + const [activeTab, setActiveTab] = useState("data-voice"); const handleSelectPlan = (planSku: string) => { router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`); }; - const simFeatures: HighlightFeature[] = [ - { - icon: , - title: "NTT Docomo Network", - description: "Best area coverage among the main three carriers in Japan", - highlight: "Nationwide coverage", - }, - { - icon: , - title: "First Month Free", - description: "Basic fee waived on signup to get you started risk-free", - highlight: "Great value", - }, - { - icon: , - title: "Foreign Cards Accepted", - description: "We accept both foreign and Japanese credit cards", - highlight: "No hassle", - }, - { - icon: , - title: "No Binding Contract", - description: "Minimum 4 months service (1st month free + 3 billing months)", - highlight: "Flexible contract", - }, - { - icon: , - title: "Number Portability", - description: "Easily switch to us keeping your current Japanese number", - highlight: "Keep your number", - }, - { - icon: , - title: "Free Plan Changes", - description: "Switch data plans anytime for the next billing cycle", - highlight: "Flexibility", - }, - ]; - - if (isLoading) { - return ( -
- -
- - -
-
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
- - -
-
-
- -
-
- - -
- -
- ))} -
-
- ); - } - - if (error) { - const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred"; - return ( -
- -
-
Failed to load SIM plans
-
{errorMessage}
- -
-
- ); - } - - const plansByType = simPlans.reduce( - (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 ( -
- - - {/* Hero Section */} -
-
- - Powered by NTT DOCOMO -
-

- Choose Your SIM Plan -

-

- Get connected with Japan's best network coverage. Choose eSIM for quick digital delivery - or physical SIM shipped to your door. -

-
- - {/* Requirements Banners */} -
- {paymentMethodsLoading ? ( - -

Loading your payment method status.

-
- ) : ( - <> - {paymentMethods && paymentMethods.totalCount === 0 && ( - -
-

- SIM orders require a saved payment method on your account. -

- -
-
- )} - - )} - - {hasExistingSim && ( - -

- You already have a SIM subscription. Discounted pricing is automatically shown for - additional lines. -

-
- )} -
- - {/* Service Highlights (Shared with Public View) */} - - - {/* Plan Type Tabs */} -
-
- - - -
-
- - {/* Plan Cards Grid */} -
- {regularPlans.length > 0 || familyPlans.length > 0 ? ( -
- {/* Regular Plans */} - {regularPlans.length > 0 && ( -
- {regularPlans.map(plan => ( - - ))} -
- )} - - {/* Family Discount Plans */} - {hasExistingSim && familyPlans.length > 0 && ( -
-
- -

Family Discount Plans

-
-
- {familyPlans.map(plan => ( - - ))} -
-
- )} -
- ) : ( -
- No plans available in this category. -
- )} -
- - {/* Collapsible Information Sections */} -
- {/* Calling & SMS Rates */} - -
- {/* Domestic Rates */} -
-

- - - - Domestic (Japan) -

-
-
-
Voice Calls
-
- ¥10/30 sec -
-
-
-
SMS
-
- ¥3/message -
-
-
-

- Incoming calls and SMS are free. Pay-per-use charges billed 5-6 weeks after usage. -

-
- - {/* Unlimited Option */} -
-
- -
-

Unlimited Domestic Calling

-

- Add unlimited domestic calls for{" "} - ¥3,000/month (available at - checkout) -

-
-
-
- - {/* International Note */} -
-

- International calling rates vary by country (¥31-148/30 sec). See{" "} - - NTT Docomo's website - {" "} - for full details. -

-
-
-
- - {/* Fees & Discounts */} - -
- {/* Fees */} -
-

One-time Fees

-
-
- Activation Fee - ¥1,500 -
-
- SIM Replacement (lost/damaged) - ¥1,500 -
-
- eSIM Re-download - ¥1,500 -
-
-
- - {/* Discounts */} -
-

Family Discount

-

- ¥300/month off per additional - Voice SIM on your account -

-
- -

All prices exclude 10% consumption tax.

-
-
- - {/* Important Information & Terms */} - -
- {/* Key Notices */} -
-

- - Important Notices -

-
    -
  • - - ID verification with official documents is required during checkout. -
  • -
  • - - - A compatible unlocked device is required. Check compatibility on our website. - -
  • -
  • - - - SIM is activated as 4G by default. 5G can be requested via your account portal. - -
  • -
  • - - - International data roaming is not available. Voice/SMS roaming can be enabled - upon request. - -
  • -
-
- - {/* Contract Terms */} -
-

Contract Terms

-
    -
  • - - - Minimum contract: 3 full billing - months. - -
  • -
  • - - - Cancellation: Can be requested - after 3rd month via cancellation form. - -
  • -
  • - - - SIM return: SIM card must be - returned after service termination. - -
  • -
-
- - {/* Disclaimer */} -
-

- 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. -

-
-
-
-
- - {/* Terms Footer */} -
-

- All prices exclude 10% consumption tax.{" "} - - View full Terms of Service - -

-
-
+ ); } diff --git a/apps/portal/src/features/services/views/VpnPlans.tsx b/apps/portal/src/features/services/views/VpnPlans.tsx index 64d9dbd3..7cebfcdd 100644 --- a/apps/portal/src/features/services/views/VpnPlans.tsx +++ b/apps/portal/src/features/services/views/VpnPlans.tsx @@ -2,7 +2,7 @@ import { PageLayout } from "@/components/templates/PageLayout"; import { ShieldCheckIcon } from "@heroicons/react/24/outline"; -import { useVpnCatalog } from "@/features/services/hooks"; +import { useAccountVpnCatalog } from "@/features/services/hooks"; import { LoadingCard } from "@/components/atoms"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; @@ -13,7 +13,7 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa export function VpnPlansView() { const servicesBasePath = useServicesBasePath(); - const { data, isLoading, error } = useVpnCatalog(); + const { data, isLoading, error } = useAccountVpnCatalog(); const vpnPlans = data?.plans || []; const activationFees = data?.activationFees || []; diff --git a/apps/portal/src/lib/providers.tsx b/apps/portal/src/lib/providers.tsx index 0cb11bf4..34497773 100644 --- a/apps/portal/src/lib/providers.tsx +++ b/apps/portal/src/lib/providers.tsx @@ -7,8 +7,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { isApiError } from "@/lib/api/runtime/client"; +import { useAuthStore } from "@/features/auth/services/auth.store"; interface QueryProviderProps { children: React.ReactNode; @@ -48,6 +49,18 @@ export function QueryProvider({ children }: QueryProviderProps) { }) ); + // Security + correctness: clear cached queries on logout so a previous user's + // account-scoped data cannot remain in memory. + useEffect(() => { + const unsubscribe = useAuthStore.subscribe((state, prevState) => { + if (prevState.isAuthenticated && !state.isAuthenticated) { + queryClient.clear(); + } + }); + + return unsubscribe; + }, [queryClient]); + return ( {children} diff --git a/docs/README.md b/docs/README.md index c1ce2fcc..b3e841f7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ Core system design documents: | -------------------------------------------------------------- | ------------------------- | | [System Overview](./architecture/system-overview.md) | High-level architecture | | [Monorepo Structure](./architecture/monorepo.md) | Monorepo organization | -| [Product Catalog](./architecture/product-catalog.md) | Catalog design | +| [Product Catalog](./architecture/product-catalog.md) | Product catalog design | | [Modular Provisioning](./architecture/modular-provisioning.md) | Provisioning architecture | | [Domain Layer](./architecture/domain-layer.md) | Domain-driven design | | [Orders Architecture](./architecture/orders.md) | Order system design | @@ -56,7 +56,7 @@ Feature guides explaining how the portal functions: | ------------------------------------------------------------------ | --------------------------- | | [System Overview](./how-it-works/system-overview.md) | Systems and data ownership | | [Accounts & Identity](./how-it-works/accounts-and-identity.md) | Sign-up and WHMCS linking | -| [Catalog & Checkout](./how-it-works/catalog-and-checkout.md) | Products and checkout rules | +| [Services & Checkout](./how-it-works/services-and-checkout.md) | Products and checkout rules | | [Orders & Provisioning](./how-it-works/orders-and-provisioning.md) | Order fulfillment flow | | [Billing & Payments](./how-it-works/billing-and-payments.md) | Invoices and payments | | [Subscriptions](./how-it-works/subscriptions.md) | Active services | diff --git a/docs/architecture/domain-layer.md b/docs/architecture/domain-layer.md index c36046c8..00b06302 100644 --- a/docs/architecture/domain-layer.md +++ b/docs/architecture/domain-layer.md @@ -59,7 +59,7 @@ packages/domain/ │ │ ├── raw.types.ts # Salesforce Order/OrderItem │ │ └── mapper.ts # Transform Salesforce → OrderDetails │ └── index.ts -├── catalog/ +├── services/ │ ├── contract.ts # CatalogProduct, InternetPlan, SimProduct, VpnProduct │ ├── schema.ts │ ├── providers/ @@ -90,7 +90,7 @@ import type { Invoice, InvoiceList } from "@customer-portal/domain/billing"; import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { SimDetails } from "@customer-portal/domain/sim"; import type { OrderSummary } from "@customer-portal/domain/orders"; -import type { CatalogProduct } from "@customer-portal/domain/services"; +import type { InternetPlanCatalogItem } from "@customer-portal/domain/services"; ``` ### Import Schemas @@ -233,11 +233,11 @@ External API Request - **Providers**: WHMCS (provisioning), Salesforce (order management) - **Use Cases**: Order fulfillment, order history, order details -### Catalog +### Services - **Contracts**: `InternetPlanCatalogItem`, `SimCatalogProduct`, `VpnCatalogProduct` - **Providers**: Salesforce (Product2) -- **Use Cases**: Product catalog display, product selection +- **Use Cases**: Product catalog display, product selection, service browsing ### Common diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md index 918b383a..88d09f1d 100644 --- a/docs/architecture/system-overview.md +++ b/docs/architecture/system-overview.md @@ -69,7 +69,7 @@ src/ users/ # User management me-status/ # Aggregated customer status (dashboard + gating signals) id-mappings/ # Portal-WHMCS-Salesforce ID mappings - catalog/ # Product catalog + services/ # Services catalog (browsing/purchasing) orders/ # Order creation and fulfillment invoices/ # Invoice management subscriptions/ # Service and subscription management @@ -96,6 +96,11 @@ src/ - Reuse `packages/domain` for domain types - External integrations in dedicated modules +### **API Boundary: Public vs Account** + +- **Public APIs** (`/api/public/*`): strictly non-personalized endpoints intended for marketing pages and unauthenticated browsing. +- **Account APIs** (`/api/account/*`): authenticated endpoints that may return personalized responses (e.g. eligibility-gated catalogs, SIM family discount availability). + ## 📦 **Shared Packages** ### **Domain Package (`packages/domain/`)** @@ -106,7 +111,7 @@ The domain package is the single source of truth for shared types, validation sc packages/domain/ ├── auth/ # Authentication types and validation ├── billing/ # Invoice and payment types -├── catalog/ # Product catalog types +├── services/ # Services catalog types ├── checkout/ # Checkout flow types ├── common/ # Shared utilities and base types ├── customer/ # Customer profile types diff --git a/docs/development/domain/structure.md b/docs/development/domain/structure.md index 0d58cf9f..a0be9441 100644 --- a/docs/development/domain/structure.md +++ b/docs/development/domain/structure.md @@ -74,8 +74,8 @@ packages/domain/ │ ├── raw.types.ts │ └── mapper.ts │ -├── catalog/ -│ ├── contract.ts # CatalogProduct (UI view model) +├── services/ +│ ├── contract.ts # Service catalog products (UI view model) │ ├── schema.ts │ ├── index.ts │ └── providers/ diff --git a/docs/development/domain/types.md b/docs/development/domain/types.md index 860f6f71..cfffdbc0 100644 --- a/docs/development/domain/types.md +++ b/docs/development/domain/types.md @@ -223,7 +223,7 @@ function isAddonProduct(product: Product): boolean { const product = fromSalesforceProduct2(salesforceProduct, pricebookEntry); if (isCatalogVisible(product)) { - displayInCatalog(product); + displayInServices(product); } // Type-safe access to specific fields diff --git a/docs/development/portal/architecture.md b/docs/development/portal/architecture.md index cf1f6b8c..f392d3ed 100644 --- a/docs/development/portal/architecture.md +++ b/docs/development/portal/architecture.md @@ -21,7 +21,7 @@ apps/portal/src/ │ ├── account/ │ ├── auth/ │ ├── billing/ -│ ├── catalog/ +│ ├── services/ │ ├── dashboard/ │ ├── marketing/ │ ├── orders/ @@ -209,9 +209,9 @@ Only `layout.tsx`, `page.tsx`, and `loading.tsx` files live inside the route gro ### Current Feature Hooks/Services -- Catalog +- Services - Hooks: `useInternetCatalog`, `useSimCatalog`, `useVpnCatalog`, `useProducts*`, `useCalculateOrder`, `useSubmitOrder` - - Service: `catalogService` (internet/sim/vpn endpoints consolidated) + - Service: `servicesService` (internet/sim/vpn endpoints consolidated) - Billing - Hooks: `useInvoices`, `usePaymentMethods`, `usePaymentGateways`, `usePaymentRefresh` - Service: `BillingService` diff --git a/docs/how-it-works/README.md b/docs/how-it-works/README.md index ba616492..e07502df 100644 --- a/docs/how-it-works/README.md +++ b/docs/how-it-works/README.md @@ -11,7 +11,7 @@ Start with `system-overview.md`, then jump into the feature you care about. | [Complete Guide](./COMPLETE-GUIDE.md) | Single, end-to-end explanation of how the portal works | | [System Overview](./system-overview.md) | High-level architecture, data ownership, and caching | | [Accounts & Identity](./accounts-and-identity.md) | Sign-up, WHMCS linking, and address/profile updates | -| [Catalog & Checkout](./catalog-and-checkout.md) | Product source, pricing, and checkout flow | +| [Services & Checkout](./services-and-checkout.md) | Product source, pricing, and checkout flow | | [Eligibility & Verification](./eligibility-and-verification.md) | Internet eligibility + SIM ID verification | | [Orders & Provisioning](./orders-and-provisioning.md) | Order lifecycle in Salesforce → WHMCS fulfillment | | [Billing & Payments](./billing-and-payments.md) | Invoices, payment methods, billing links | diff --git a/docs/how-it-works/eligibility-and-verification.md b/docs/how-it-works/eligibility-and-verification.md index 881f066b..3577b19f 100644 --- a/docs/how-it-works/eligibility-and-verification.md +++ b/docs/how-it-works/eligibility-and-verification.md @@ -20,12 +20,25 @@ This guide describes how eligibility and verification work in the customer porta ### How It Works 1. Customer navigates to `/account/services/internet` -2. Customer enters service address and requests eligibility check -3. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review -4. Agent performs NTT serviceability check (manual process) -5. Agent updates Account eligibility fields -6. Salesforce Flow sends email notification to customer -7. Customer returns and sees eligible plans +2. Customer clicks **Check Availability** (requires a service address on file) +3. Portal calls `POST /api/services/internet/eligibility-request` and shows an immediate confirmation screen at `/account/services/internet/request-submitted` +4. Portal **finds/creates a Salesforce Opportunity** (Stage = `Introduction`) and creates a Salesforce Case **linked to that Opportunity** for agent review +5. Agent performs NTT serviceability check (manual process) +6. Agent updates Account eligibility fields +7. Salesforce Flow sends email notification to customer +8. Customer returns and sees eligible plans + +### Caching & Rate Limiting (Security + Load) + +- **BFF cache (Redis)**: + - Internet catalog data is cached in Redis (CDC-driven invalidation, no TTL) so repeated portal hits **do not repeatedly query Salesforce**. + - Eligibility details are cached per Salesforce Account ID and are invalidated/updated when Salesforce emits Account change events. +- **Portal cache (React Query)**: + - The portal caches service catalog responses in-memory, scoped by auth state, and will refetch when stale. + - On logout, the portal clears cached queries to avoid cross-user leakage on shared devices. +- **Rate limiting**: + - Public catalog endpoints are rate-limited per IP + User-Agent to prevent abuse. + - `POST /api/services/internet/eligibility-request` is authenticated and rate-limited, and the BFF is idempotent when a request is already pending (no duplicate Cases created). ### Subscription Type Detection @@ -50,12 +63,15 @@ const isInternetService = | `Internet_Eligibility__c` | Text | Agent | After check | | `Internet_Eligibility_Request_Date_Time__c` | DateTime | Portal | On request | | `Internet_Eligibility_Checked_Date_Time__c` | DateTime | Agent | After check | -| `Internet_Eligibility_Notes__c` | Text | Agent | After check | -| `Internet_Eligibility_Case_Id__c` | Lookup | Portal | On request | + +**Notes:** + +- The portal returns an API-level **request id** (Salesforce Case ID) from `POST /api/services/internet/eligibility-request` for display/auditing. +- The portal UI reads eligibility status/value from the Account fields above; it does not rely on an Account-stored Case ID. ### Status Values -| Status | Shop Page UI | Checkout Gating | +| Status | Services Page UI | Checkout Gating | | --------------- | --------------------------------------- | --------------- | | `Not Requested` | Show "Request eligibility check" button | Block submit | | `Pending` | Show "Review in progress" | Block submit | @@ -108,11 +124,12 @@ The Profile page is the primary location. The standalone page is used when redir ## Portal UI Locations -| Location | What's Shown | -| ---------------------------- | ----------------------------------------------- | -| `/account/settings` | Profile, Address, ID Verification (with upload) | -| `/account/services/internet` | Eligibility status and eligible plans | -| Subscription detail | Service-specific actions (cancel, etc.) | +| Location | What's Shown | +| ---------------------------------------------- | -------------------------------------------------------------- | +| `/account/settings` | Profile, Address, ID Verification (with upload) | +| `/account/services/internet` | Eligibility status and eligible plans | +| `/account/services/internet/request-submitted` | Immediate confirmation after submitting an eligibility request | +| Subscription detail | Service-specific actions (cancel, etc.) | ## Cancellation Flow diff --git a/docs/how-it-works/catalog-and-checkout.md b/docs/how-it-works/services-and-checkout.md similarity index 72% rename from docs/how-it-works/catalog-and-checkout.md rename to docs/how-it-works/services-and-checkout.md index 569f784b..ab28034b 100644 --- a/docs/how-it-works/catalog-and-checkout.md +++ b/docs/how-it-works/services-and-checkout.md @@ -1,4 +1,4 @@ -# Catalog & Checkout +# Services & Checkout Where product data comes from, what we validate, and how we keep it fresh. @@ -21,24 +21,24 @@ Where product data comes from, what we validate, and how we keep it fresh. - SKUs selected exist in the Salesforce pricebook. - For Internet orders, we block duplicates when WHMCS already shows an active Internet service (in production). -For the intended Salesforce-driven workflow model (Cases + Account fields + portal UX), see `docs/portal-guides/eligibility-and-verification.md`. +For the intended Salesforce-driven workflow model (Cases + Account fields + portal UX), see `docs/how-it-works/eligibility-and-verification.md`. ## Checkout Data Captured -- Address snapshot: we copy the customer’s address (or the one they update during checkout) into the Salesforce Order billing fields so the order shows the exact data used. +- Address snapshot: we copy the customer's address (or the one they update during checkout) into the Salesforce Order billing fields so the order shows the exact data used. - Activation preferences: stored on the Salesforce Order (activation type/schedule, SIM specifics, MNP details when applicable). - No card data is stored in the portal; we only verify that WHMCS already has a payment method. -## Caching for Catalog Calls +## Caching for Product Catalog -- Catalog data uses Salesforce Change Data Capture (CDC) events; there is no time-based expiry. When Salesforce signals a product change, the cache is cleared. +- Product catalog data uses Salesforce Change Data Capture (CDC) events; there is no time-based expiry. When Salesforce signals a product change, the cache is cleared. - Volatile catalog bits (e.g., fast-changing reference data) use a 60s TTL. - Eligibility per account is cached with no TTL and cleared when Salesforce changes. -- Request coalescing is used so multiple users hitting the same catalog do not spam Salesforce. +- Request coalescing is used so multiple users hitting the same services endpoint do not spam Salesforce. ## If something goes wrong -- Missing payment method: checkout is blocked with a clear “add a payment method” message. +- Missing payment method: checkout is blocked with a clear "add a payment method" message. - Ineligible address or duplicate Internet: we stop the order and explain why (eligibility failed or active Internet already exists). -- Salesforce pricebook issues: we return a friendly “catalog unavailable, please try again later.” +- Salesforce pricebook issues: we return a friendly "services unavailable, please try again later." - Cache failures: we fall back to live Salesforce reads to avoid empty screens. diff --git a/docs/how-it-works/system-overview.md b/docs/how-it-works/system-overview.md index dba7f187..5df1b15e 100644 --- a/docs/how-it-works/system-overview.md +++ b/docs/how-it-works/system-overview.md @@ -6,7 +6,7 @@ Purpose: explain what the portal does, which systems own which data, and how fre - Portal UI (Next.js) + BFF API (NestJS): handles all user traffic and calls external systems. - Postgres: stores portal users and the cross-system mapping `user_id ↔ whmcs_client_id ↔ sf_account_id`. -- Redis cache: keeps short-lived copies of data to reduce load; keys are always scoped per user to avoid mixing data. +- Redis cache: reduces load with a mix of **global** caches (e.g. product catalog) and **account-scoped** caches (e.g. eligibility) to avoid mixing customer data. - WHMCS: system of record for billing (clients, addresses, invoices, payment methods, subscriptions). - Salesforce: system of record for CRM (accounts/contacts), product catalog/pricebook, orders, and support cases. - Freebit: SIM provisioning only, used during mobile/SIM order fulfillment. @@ -15,7 +15,7 @@ Purpose: explain what the portal does, which systems own which data, and how fre - Sign-up: portal verifies the customer number in Salesforce → creates a WHMCS client (billing account) → stores the portal user + mapping → updates Salesforce with portal status + WHMCS ID. - Login/Linking: existing WHMCS users validate their WHMCS credentials; we create the portal user, map IDs, and mark the Salesforce account as portal-active. -- Catalog & Checkout: products/prices come from the Salesforce portal pricebook; eligibility is checked per account; we require a WHMCS payment method before allowing checkout. +- Services & Checkout: products/prices come from the Salesforce portal pricebook; eligibility is checked per account; we require a WHMCS payment method before allowing checkout. - Orders: created in Salesforce with an address snapshot; Salesforce change events trigger fulfillment, which creates the matching WHMCS order and updates Salesforce statuses. - Billing: invoices, payment methods, and subscriptions are read from WHMCS; secure SSO links are generated for paying invoices inside WHMCS. - Support: cases are created/read directly in Salesforce with Origin = “Portal Website.” @@ -29,7 +29,7 @@ Purpose: explain what the portal does, which systems own which data, and how fre ## Caching & Freshness (Redis) -- Catalog: event-driven (Salesforce CDC), no TTL; “volatile” bits use 60s TTL; eligibility per account is cached without TTL and invalidated on change. +- Services catalog: event-driven (Salesforce CDC), no TTL; "volatile" bits use 60s TTL; eligibility per account is cached without TTL and invalidated on change. - Orders: event-driven (Salesforce CDC), no TTL; invalidated when Salesforce emits order/order-item changes or when we create/provision an order. - Invoices: list cached 90s; invoice detail cached 5m; invalidated by WHMCS webhooks and by write operations. - Subscriptions/services: list cached 5m; single subscription cached 10m; invalidated on WHMCS cache busts (webhooks or profile updates). @@ -44,3 +44,43 @@ Purpose: explain what the portal does, which systems own which data, and how fre - If WHMCS or Salesforce is briefly unavailable, the portal surfaces a friendly “try again later” message rather than partial data. - Fulfillment writes error codes/messages back to Salesforce (e.g., missing payment method) so the team can see why a provision was paused. - Caches are cleared on writes and key webhooks so stale data is minimized; when cache access fails, we fall back to live reads. + +## Public vs Account API Boundary (Security + Caching) + +The BFF exposes two “flavors” of service catalog endpoints: + +- **Public catalog (never personalized)**: `GET /api/public/services/*` + - Ignores cookies/tokens (no optional session attach). + - Safe to cache publicly (subject to TTL) and heavily rate limit. +- **Account catalog (authenticated + personalized)**: `GET /api/account/services/*` + - Requires auth and can return account-specific catalog variants (e.g. SIM family discount availability). + - Uses `Cache-Control: private, no-store` at the HTTP layer; server-side caching is handled in Redis. + +### How "public caching" works (and why high traffic usually won't hit Salesforce) + +There are **two independent caching layers** involved: + +- **Redis (server-side) catalog cache**: + - Catalog reads are cached in Redis via `ServicesCacheService`. + - For catalog data (plans/addons/etc) the TTL is intentionally **null** (no TTL): values persist until explicitly invalidated. + - Invalidation is driven by Salesforce **CDC** events (Product2 / PricebookEntry) and an account **Platform Event** for eligibility updates. + - Result: even if the public catalog is requested millions of times, the BFF typically serves from Redis and only re-queries Salesforce when a relevant Salesforce change event arrives (or on cold start / cache miss). + +- **HTTP cache (browser/CDN)**: + - Public catalog responses include `Cache-Control: public, max-age=..., s-maxage=...`. + - This reduces load on the BFF by allowing browsers/shared caches/CDNs to reuse responses for the TTL window. + - This layer is TTL-based, so **staleness up to the TTL** is expected unless your CDN is configured for explicit purge. + +### What to worry about at "million visits" scale + +- **CDN cookie forwarding / cache key fragmentation**: + - Browsers will still send cookies to `/api/public/*` by default; the BFF ignores them, but a CDN might treat cookies as part of the cache key unless configured not to. + - Make sure your CDN/proxy config does **not** include cookies (and ideally not `Authorization`) in the cache key for `/api/public/services/*`. + +- **BFF + Redis load (even if Salesforce is protected)**: + - Redis caching prevents Salesforce read amplification, but the BFF/Redis still need to handle request volume. + - Rate limiting on public endpoints is intentional to cap abuse and protect infrastructure. + +- **CDC subscription health / fallback behavior**: + - If Salesforce CDC subscriptions are disabled or unhealthy, invalidations may not arrive and Redis caches can become stale until manually cleared. + - Monitor the CDC subscriber and cache health metrics (`GET /api/health/services/cache`). diff --git a/docs/integrations/salesforce/opportunity-lifecycle.md b/docs/integrations/salesforce/opportunity-lifecycle.md index cd5a605b..da3604b6 100644 --- a/docs/integrations/salesforce/opportunity-lifecycle.md +++ b/docs/integrations/salesforce/opportunity-lifecycle.md @@ -70,8 +70,11 @@ This guide documents the Salesforce Opportunity integration for service lifecycl | Status | `Internet_Eligibility_Status__c` | Pending, Checked | | Requested At | `Internet_Eligibility_Request_Date_Time__c` | When request was made | | Checked At | `Internet_Eligibility_Checked_Date_Time__c` | When eligibility was checked | -| Notes | `Internet_Eligibility_Notes__c` | Agent notes | -| Case ID | `Internet_Eligibility_Case_Id__c` | Linked Case for request | + +**Notes:** + +- The portal does **not** store the Case ID or agent notes on the Account in our current Salesforce environment. +- Agent notes should live inside the **Case Description** / Case activity history. **ID Verification Fields:** @@ -179,6 +182,7 @@ This guide documents the Salesforce Opportunity integration for service lifecycl │ │ │ 1. CUSTOMER ENTERS ADDRESS │ │ └─ Portal: POST /api/services/internet/eligibility-request │ +│ Portal UI: shows confirmation at /account/services/internet/request-submitted │ │ │ │ 2. CHECK IF ELIGIBILITY ALREADY KNOWN │ │ └─ Query: SELECT Internet_Eligibility__c FROM Account │ @@ -215,7 +219,6 @@ This guide documents the Salesforce Opportunity integration for service lifecycl │ 5. UPDATE ACCOUNT │ │ └─ Internet_Eligibility_Status__c = "Pending" │ │ └─ Internet_Eligibility_Request_Date_Time__c = now() │ -│ └─ Internet_Eligibility_Case_Id__c = Case.Id │ │ │ │ 6. CS PROCESSES CASE │ │ └─ Checks with NTT / provider │ @@ -223,9 +226,9 @@ This guide documents the Salesforce Opportunity integration for service lifecycl │ - Internet_Eligibility__c = result │ │ - Internet_Eligibility_Status__c = "Checked" │ │ - Internet_Eligibility_Checked_Date_Time__c = now() │ -│ └─ Updates Opportunity: │ -│ - Eligible → Stage: Ready │ -│ - Not Eligible → Stage: Void │ +│ └─ Opportunity stages are not automatically moved to "Ready" by │ +│ the eligibility check. CS updates stages later as part of the │ +│ order review / lifecycle workflow. │ │ │ │ 7. PORTAL DETECTS CHANGE (via CDC or polling) │ │ └─ Shows eligibility result to customer │ diff --git a/docs/integrations/salesforce/requirements.md b/docs/integrations/salesforce/requirements.md index f4321b23..9024a694 100644 --- a/docs/integrations/salesforce/requirements.md +++ b/docs/integrations/salesforce/requirements.md @@ -89,8 +89,11 @@ The Account stores customer information and status fields. | Eligibility Status | `Internet_Eligibility_Status__c` | Picklist | `Pending`, `Checked` | | Request Date | `Internet_Eligibility_Request_Date_Time__c` | DateTime | When request was made | | Checked Date | `Internet_Eligibility_Checked_Date_Time__c` | DateTime | When checked by CS | -| Notes | `Internet_Eligibility_Notes__c` | Text Area | Agent notes | -| Case ID | `Internet_Eligibility_Case_Id__c` | Text | Linked Case ID | + +**Notes:** + +- The portal does **not** require Account-level fields for a Case ID or agent notes. +- Agent notes should live in the **Case Description** / Case activity history. **ID Verification Fields:** diff --git a/docs/operations/security-monitoring.md b/docs/operations/security-monitoring.md index c87e76f3..3d11a85e 100644 --- a/docs/operations/security-monitoring.md +++ b/docs/operations/security-monitoring.md @@ -191,7 +191,7 @@ pnpm update [package-name]@latest ## 📚 Additional Resources - **Security Policy**: See `SECURITY.md` -- **Complete Guide**: See `docs/portal-guides/COMPLETE-GUIDE.md` +- **Complete Guide**: See `docs/how-it-works/COMPLETE-GUIDE.md` - **GitHub Security**: [https://docs.github.com/en/code-security](https://docs.github.com/en/code-security) - **npm Security**: [https://docs.npmjs.com/security](https://docs.npmjs.com/security)