From 52adc290166e2b30f70029586d135bb748e87610 Mon Sep 17 00:00:00 2001 From: "T. Narantuya" Date: Thu, 18 Sep 2025 11:22:22 +0900 Subject: [PATCH] Refactor catalog services and update product types for improved clarity and functionality - Updated Internet and SIM catalog services to utilize new product types, enhancing type safety and consistency. - Refactored methods to return specific catalog item types, including InternetPlanCatalogItem, InternetInstallationCatalogItem, and SimActivationFeeCatalogItem. - Adjusted API responses and frontend components to align with updated product structures, ensuring accurate data handling. - Removed deprecated fields and streamlined type definitions across the catalog services and related components. --- apps/bff/src/catalog/catalog.controller.ts | 30 ++-- .../services/internet-catalog.service.ts | 111 +++++++++----- .../catalog/services/sim-catalog.service.ts | 32 ++--- .../catalog/internet/configure/page.tsx | 105 +++++++++----- .../app/(portal)/catalog/internet/page.tsx | 77 ++++++---- .../(portal)/catalog/sim/configure/page.tsx | 22 +-- .../src/app/(portal)/catalog/sim/page.tsx | 11 +- .../src/components/catalog/addon-group.tsx | 32 ++--- .../src/components/catalog/order-summary.tsx | 36 +++-- .../components/internet/InternetPlanCard.tsx | 32 +++-- .../features/catalog/types/catalog.types.ts | 136 ++++++------------ .../features/catalog/views/InternetPlans.tsx | 15 +- packages/domain/src/entities/product.ts | 26 ---- 13 files changed, 353 insertions(+), 312 deletions(-) diff --git a/apps/bff/src/catalog/catalog.controller.ts b/apps/bff/src/catalog/catalog.controller.ts index 5bebacab..1d823951 100644 --- a/apps/bff/src/catalog/catalog.controller.ts +++ b/apps/bff/src/catalog/catalog.controller.ts @@ -1,13 +1,17 @@ import { Controller, Get, Request } from "@nestjs/common"; import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger"; -import { InternetCatalogService } from "./services/internet-catalog.service"; -import { SimCatalogService } from "./services/sim-catalog.service"; -import { VpnCatalogService } from "./services/vpn-catalog.service"; import { - InternetProduct, - SimProduct, - VpnProduct, -} from "@customer-portal/domain"; + InternetAddonCatalogItem, + InternetCatalogService, + InternetInstallationCatalogItem, + InternetPlanCatalogItem, +} from "./services/internet-catalog.service"; +import { + SimActivationFeeCatalogItem, + SimCatalogService, +} from "./services/sim-catalog.service"; +import { VpnCatalogService } from "./services/vpn-catalog.service"; +import { SimProduct, VpnProduct } from "@customer-portal/domain"; @ApiTags("catalog") @Controller("catalog") @@ -21,7 +25,9 @@ export class CatalogController { @Get("internet/plans") @ApiOperation({ summary: "Get Internet plans filtered by customer eligibility" }) - async getInternetPlans(@Request() req: { user: { id: string } }): Promise { + async getInternetPlans( + @Request() req: { user: { id: string } } + ): Promise { const userId = req.user?.id; if (!userId) { // Fallback to all plans if no user context @@ -32,13 +38,13 @@ export class CatalogController { @Get("internet/addons") @ApiOperation({ summary: "Get Internet add-ons" }) - async getInternetAddons(): Promise { + async getInternetAddons(): Promise { return this.internetCatalog.getAddons(); } @Get("internet/installations") @ApiOperation({ summary: "Get Internet installations" }) - async getInternetInstallations(): Promise { + async getInternetInstallations(): Promise { return this.internetCatalog.getInstallations(); } @@ -49,14 +55,14 @@ export class CatalogController { if (!userId) { // Fallback to all regular plans if no user context const allPlans = await this.simCatalog.getPlans(); - return allPlans.filter(plan => !plan.hasFamilyDiscount); + return allPlans.filter(plan => !plan.simHasFamilyDiscount); } return this.simCatalog.getPlansForUser(userId); } @Get("sim/activation-fees") @ApiOperation({ summary: "Get SIM activation fees" }) - async getSimActivationFees(): Promise { + async getSimActivationFees(): Promise { return this.simCatalog.getActivationFees(); } diff --git a/apps/bff/src/catalog/services/internet-catalog.service.ts b/apps/bff/src/catalog/services/internet-catalog.service.ts index b678f235..35e2ad29 100644 --- a/apps/bff/src/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/catalog/services/internet-catalog.service.ts @@ -1,7 +1,6 @@ import { Injectable, Inject } from "@nestjs/common"; import { BaseCatalogService } from "./base-catalog.service"; import { InternetProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain"; -import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { MappingsService } from "../../id-mappings/mappings.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { Logger } from "nestjs-pino"; @@ -12,18 +11,39 @@ interface SalesforceAccount { Internet_Eligibility__c?: string; } +export type InternetPlanCatalogItem = InternetProduct & { + catalogMetadata: { + tierDescription: string; + features: string[]; + isRecommended: boolean; + }; +}; + +export type InternetInstallationCatalogItem = InternetProduct & { + catalogMetadata: { + installationTerm: "One-time" | "12-Month" | "24-Month"; + }; +}; + +export type InternetAddonCatalogItem = InternetProduct & { + catalogMetadata: { + addonCategory: "hikari-denwa-service" | "hikari-denwa-installation" | "other"; + autoAdd: boolean; + requiredWith: string[]; + }; +}; + @Injectable() export class InternetCatalogService extends BaseCatalogService { constructor( sf: SalesforceConnection, @Inject(Logger) logger: Logger, - private salesforceService: SalesforceService, private mappingsService: MappingsService ) { super(sf, logger); } - async getPlans(): Promise { + async getPlans(): Promise { const fields = this.getFields(); const soql = this.buildCatalogServiceQuery("Internet", [ fields.product.internetPlanTier, @@ -44,15 +64,17 @@ export class InternetCatalogService extends BaseCatalogService { return { ...product, - tierDescription: tierData.tierDescription, - features: tierData.features, - isRecommended: product.internetPlanTier === "Gold", + catalogMetadata: { + tierDescription: tierData.tierDescription, + features: tierData.features, + isRecommended: product.internetPlanTier === "Gold", + }, description: product.description ?? tierData.description, - } satisfies InternetProduct; + } satisfies InternetPlanCatalogItem; }); } - async getInstallations(): Promise { + async getInstallations(): Promise { const fields = this.getFields(); const soql = this.buildProductQuery("Internet", "Installation", [ fields.product.billingCycle, @@ -72,22 +94,19 @@ export class InternetCatalogService extends BaseCatalogService { ) as InternetProduct; const installationType = this.inferInstallationTypeFromSku(product.sku); - const priceValue = - product.billingCycle === "Monthly" - ? product.monthlyPrice ?? product.unitPrice ?? 0 - : product.oneTimePrice ?? product.unitPrice ?? 0; return { ...product, - type: installationType, - price: priceValue, + catalogMetadata: { + installationTerm: installationType, + }, displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0), - } satisfies InternetProduct; + } satisfies InternetInstallationCatalogItem; }) .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); } - async getAddons(): Promise { + async getAddons(): Promise { const fields = this.getFields(); const soql = this.buildProductQuery("Internet", "Add-on", [ fields.product.billingCycle, @@ -109,19 +128,16 @@ export class InternetCatalogService extends BaseCatalogService { ) as InternetProduct; const addonType = this.inferAddonTypeFromSku(product.sku); - const activationPrice = - product.billingCycle === "Onetime" - ? product.oneTimePrice ?? product.unitPrice ?? 0 - : undefined; return { ...product, - type: addonType, - activationPrice, - autoAdd: false, - requiredWith: [], + catalogMetadata: { + addonCategory: addonType, + autoAdd: false, + requiredWith: [], + }, displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0), - } satisfies InternetProduct; + } satisfies InternetAddonCatalogItem; }) .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); } @@ -135,7 +151,7 @@ export class InternetCatalogService extends BaseCatalogService { return { plans, installations, addons }; } - async getPlansForUser(userId: string): Promise { + async getPlansForUser(userId: string): Promise { try { // Get all plans first const allPlans = await this.getPlans(); @@ -162,7 +178,7 @@ export class InternetCatalogService extends BaseCatalogService { if (!eligibility) { this.logger.log(`No eligibility field for user ${userId}, filtering to Home 1G plans only`); - const homeGPlans = allPlans.filter(plan => plan.offeringType === "Home 1G"); + const homeGPlans = allPlans.filter(plan => plan.internetOfferingType === "Home 1G"); return homeGPlans; } @@ -171,7 +187,7 @@ export class InternetCatalogService extends BaseCatalogService { const isEligible = this.checkPlanEligibility(plan, eligibility); if (!isEligible) { this.logger.debug( - `Plan ${plan.name} (${plan.tier}) not eligible for user ${userId} with eligibility: ${eligibility}` + `Plan ${plan.name} (${plan.internetPlanTier ?? "Unknown"}) not eligible for user ${userId} with eligibility: ${eligibility}` ); } return isEligible; @@ -190,10 +206,10 @@ export class InternetCatalogService extends BaseCatalogService { } } - private checkPlanEligibility(plan: InternetProduct, eligibility: string): boolean { - // Simple match: user's eligibility field must equal plan's offering type - // e.g., eligibility "Home 1G" matches plan.offeringType "Home 1G" - return plan.offeringType === eligibility; + private checkPlanEligibility(plan: InternetPlanCatalogItem, eligibility: string): boolean { + // Simple match: user's eligibility field must equal plan's Salesforce offering type + // e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G" + return plan.internetOfferingType === eligibility; } private getTierData(tier: string) { @@ -239,4 +255,35 @@ export class InternetCatalogService extends BaseCatalogService { return tierData[tier] || tierData["Silver"]; } + private inferInstallationTypeFromSku(sku: string): "One-time" | "12-Month" | "24-Month" { + const upperSku = sku.toUpperCase(); + if (upperSku.includes("12M") || upperSku.includes("12-MONTH")) { + return "12-Month"; + } + if (upperSku.includes("24M") || upperSku.includes("24-MONTH")) { + return "24-Month"; + } + return "One-time"; + } + + private inferAddonTypeFromSku( + sku: string + ): "hikari-denwa-service" | "hikari-denwa-installation" | "other" { + const upperSku = sku.toUpperCase(); + if ( + upperSku.includes("DENWA") || + upperSku.includes("HOME-PHONE") || + upperSku.includes("PHONE") + ) { + if ( + upperSku.includes("INSTALL") || + upperSku.includes("SETUP") || + upperSku.includes("ACTIVATION") + ) { + return "hikari-denwa-installation"; + } + return "hikari-denwa-service"; + } + return "other"; + } } diff --git a/apps/bff/src/catalog/services/sim-catalog.service.ts b/apps/bff/src/catalog/services/sim-catalog.service.ts index ef0bf220..7288ec2b 100644 --- a/apps/bff/src/catalog/services/sim-catalog.service.ts +++ b/apps/bff/src/catalog/services/sim-catalog.service.ts @@ -1,18 +1,22 @@ import { Injectable, Inject } from "@nestjs/common"; import { BaseCatalogService } from "./base-catalog.service"; import { SimProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain"; -import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { MappingsService } from "../../id-mappings/mappings.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { Logger } from "nestjs-pino"; import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service"; +export type SimActivationFeeCatalogItem = SimProduct & { + catalogMetadata: { + isDefault: boolean; + }; +}; + @Injectable() export class SimCatalogService extends BaseCatalogService { constructor( sf: SalesforceConnection, @Inject(Logger) logger: Logger, - private salesforceService: SalesforceService, private mappingsService: MappingsService, private whmcs: WhmcsConnectionService ) { @@ -39,16 +43,13 @@ export class SimCatalogService extends BaseCatalogService { return { ...product, - dataSize: product.simDataSize || "Unknown", - planType: product.simPlanType, - hasFamilyDiscount: product.simHasFamilyDiscount || false, description: product.name, features: product.features ?? [], } satisfies SimProduct; }); } - async getActivationFees(): Promise { + async getActivationFees(): Promise { const fields = this.getFields(); const soql = this.buildProductQuery("SIM", "Activation", []); const records = await this.executeQuery(soql, "SIM Activation Fees"); @@ -61,14 +62,13 @@ export class SimCatalogService extends BaseCatalogService { fields.product ) as SimProduct; - const priceValue = product.oneTimePrice ?? product.unitPrice ?? 0; - return { ...product, - price: priceValue, description: product.name, - isDefault: true, - } satisfies SimProduct; + catalogMetadata: { + isDefault: true, + }, + } satisfies SimActivationFeeCatalogItem; }); } @@ -93,10 +93,6 @@ export class SimCatalogService extends BaseCatalogService { return { ...product, - price: - product.billingCycle === "Monthly" - ? product.monthlyPrice ?? product.unitPrice ?? 0 - : product.oneTimePrice ?? product.unitPrice ?? 0, description: product.name, displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0), } satisfies SimProduct; @@ -115,17 +111,17 @@ export class SimCatalogService extends BaseCatalogService { if (hasExistingSim) { this.logger.log(`User ${userId} has existing SIM, showing family discount plans`); // Show family discount plans + regular plans for comparison - return allPlans; // Family plans are included with hasFamilyDiscount: true + return allPlans; // Family plans are included with simHasFamilyDiscount: true } else { this.logger.log(`User ${userId} has no existing SIM, showing regular plans only`); // Show only regular plans (hide family discount plans) - return allPlans.filter(plan => !plan.hasFamilyDiscount); + return allPlans.filter(plan => !plan.simHasFamilyDiscount); } } catch (error) { this.logger.error(`Failed to get personalized SIM plans for user ${userId}`, error); // Fallback to all regular plans const allPlans = await this.getPlans(); - return allPlans.filter(plan => !plan.hasFamilyDiscount); + return allPlans.filter(plan => !plan.simHasFamilyDiscount); } } diff --git a/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx b/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx index 4b486b01..34b864f3 100644 --- a/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx +++ b/apps/portal/src/app/(portal)/catalog/internet/configure/page.tsx @@ -85,10 +85,10 @@ function InternetConfigureContent() { const installationSkuParam = searchParams.get("installationSku"); if (installationSkuParam) { - const installation = installationsData.find(i => i.sku === installationSkuParam); - if (installation) { - setInstallPlan(installation.type); - } + const installation = installationsData.find(i => i.sku === installationSkuParam); + if (installation) { + setInstallPlan(installation.catalogMetadata.installationTerm); + } } // Restore selected addons from URL parameters @@ -129,23 +129,34 @@ function InternetConfigureContent() { // Add selected addons selectedAddonSkus.forEach(addonSku => { const addon = addons.find(a => a.sku === addonSku); - if (addon) { - if (addon.monthlyPrice) { - monthlyTotal += addon.monthlyPrice; - } - if (addon.activationPrice) { - oneTimeTotal += addon.activationPrice; - } + if (!addon) return; + + const monthlyCharge = addon.monthlyPrice ?? (addon.billingCycle === "Monthly" ? addon.unitPrice : undefined); + const oneTimeCharge = addon.oneTimePrice ?? (addon.billingCycle !== "Monthly" ? addon.unitPrice : undefined); + + if (typeof monthlyCharge === "number") { + monthlyTotal += monthlyCharge; + } + if (typeof oneTimeCharge === "number") { + oneTimeTotal += oneTimeCharge; } }); // Add installation cost - const installation = installations.find(i => i.type === installPlan); - if (installation && installation.price) { - if (installation.billingCycle === "Monthly") { - monthlyTotal += installation.price; - } else { - oneTimeTotal += installation.price; + const installation = installations.find( + i => i.catalogMetadata.installationTerm === installPlan + ); + if (installation) { + const monthlyCharge = + installation.monthlyPrice ?? (installation.billingCycle === "Monthly" ? installation.unitPrice : undefined); + const oneTimeCharge = + installation.oneTimePrice ?? (installation.billingCycle !== "Monthly" ? installation.unitPrice : undefined); + + if (typeof monthlyCharge === "number") { + monthlyTotal += monthlyCharge; + } + if (typeof oneTimeCharge === "number") { + oneTimeTotal += oneTimeCharge; } } @@ -164,7 +175,9 @@ function InternetConfigureContent() { }); // Add installation SKU (not type) - const installation = installations.find(i => i.type === installPlan); + const installation = installations.find( + i => i.catalogMetadata.installationTerm === installPlan + ); if (installation) { params.append("installationSku", installation.sku); } @@ -229,14 +242,14 @@ function InternetConfigureContent() {
- {plan.tier} + {plan.internetPlanTier || "Plan"}
{plan.name} @@ -274,7 +287,7 @@ function InternetConfigureContent() {
{/* Important Message for Platinum */} - {plan?.tier === "Platinum" && ( + {plan?.internetPlanTier === "Platinum" && (
@@ -308,7 +321,7 @@ function InternetConfigureContent() { )} {/* Access Mode Selection - Only for Silver */} - {plan?.tier === "Silver" ? ( + {plan?.internetPlanTier === "Silver" ? (

Select Your Router & ISP Configuration: @@ -438,7 +451,7 @@ function InternetConfigureContent() { /> - Access Mode: IPoE-HGW (Pre-configured for {plan?.tier} plan) + Access Mode: IPoE-HGW (Pre-configured for {plan?.internetPlanTier} plan)

@@ -477,9 +490,13 @@ function InternetConfigureContent() { inst.type === installPlan) || null} + selectedInstallation={ + installations.find( + inst => inst.catalogMetadata.installationTerm === installPlan + ) || null + } onInstallationSelect={installation => { - setInstallPlan(installation.type); + setInstallPlan(installation.catalogMetadata.installationTerm); }} showSkus={false} /> @@ -634,19 +651,19 @@ function InternetConfigureContent() {
{selectedAddonSkus.map(addonSku => { const addon = addons.find(a => a.sku === addonSku); + const monthlyAmount = + addon?.monthlyPrice ?? (addon?.billingCycle === "Monthly" ? addon?.unitPrice : undefined); + const oneTimeAmount = + addon?.oneTimePrice ?? (addon?.billingCycle !== "Monthly" ? addon?.unitPrice : undefined); + const amount = monthlyAmount ?? oneTimeAmount ?? 0; + const cadence = monthlyAmount ? "mo" : "once"; + return (
{addon?.name || addonSku} - ¥ - {( - addon?.monthlyPrice || - addon?.activationPrice || - 0 - ).toLocaleString()} - - /{addon?.monthlyPrice ? "mo" : "once"} - + ¥{amount.toLocaleString()} + /{cadence}
); @@ -657,16 +674,26 @@ function InternetConfigureContent() { {/* Installation Fees */} {(() => { - const installation = installations.find(i => i.type === installPlan); - return installation && installation.price && installation.price > 0 ? ( + const installation = installations.find( + i => i.catalogMetadata.installationTerm === installPlan + ); + if (!installation) return null; + + const monthlyAmount = + installation.monthlyPrice ?? (installation.billingCycle === "Monthly" ? installation.unitPrice : undefined); + const oneTimeAmount = + installation.oneTimePrice ?? (installation.billingCycle !== "Monthly" ? installation.unitPrice : undefined); + const amount = monthlyAmount ?? oneTimeAmount; + + return amount && amount > 0 ? (

Installation

{installation.name} - ¥{installation.price.toLocaleString()} + ¥{amount.toLocaleString()} - /{installation.billingCycle === "Monthly" ? "mo" : "once"} + /{monthlyAmount ? "mo" : "once"}
diff --git a/apps/portal/src/app/(portal)/catalog/internet/page.tsx b/apps/portal/src/app/(portal)/catalog/internet/page.tsx index 33466842..bd7c9dce 100644 --- a/apps/portal/src/app/(portal)/catalog/internet/page.tsx +++ b/apps/portal/src/app/(portal)/catalog/internet/page.tsx @@ -42,7 +42,7 @@ export default function InternetPlansPage() { setPlans(plans); setInstallations(installations); if (plans.length > 0) { - setEligibility(plans[0].offeringType || "Home 1G"); + setEligibility(plans[0].internetOfferingType || "Home 1G"); } } } catch (e) { @@ -58,21 +58,23 @@ export default function InternetPlansPage() { }; }, []); - const getEligibilityIcon = (offeringType: string) => { - if (offeringType.toLowerCase().includes("home")) { + const getEligibilityIcon = (offeringType?: string) => { + const lower = (offeringType || "").toLowerCase(); + if (lower.includes("home")) { return ; } - if (offeringType.toLowerCase().includes("apartment")) { + if (lower.includes("apartment")) { return ; } return ; }; - const getEligibilityColor = (offeringType: string) => { - if (offeringType.toLowerCase().includes("home")) { + const getEligibilityColor = (offeringType?: string) => { + const lower = (offeringType || "").toLowerCase(); + if (lower.includes("home")) { return "text-blue-600 bg-blue-50 border-blue-200"; } - if (offeringType.toLowerCase().includes("apartment")) { + if (lower.includes("apartment")) { return "text-green-600 bg-green-50 border-green-200"; } return "text-gray-600 bg-gray-50 border-gray-200"; @@ -200,9 +202,10 @@ function InternetPlanCard({ plan: InternetPlan; installations: InternetInstallation[]; }) { - const isGold = plan.tier === "Gold"; - const isPlatinum = plan.tier === "Platinum"; - const isSilver = plan.tier === "Silver"; + const tier = plan.internetPlanTier; + const isGold = tier === "Gold"; + const isPlatinum = tier === "Platinum"; + const isSilver = tier === "Silver"; // Use default variant for all cards to avoid green background on gold const cardVariant = "default"; @@ -215,6 +218,16 @@ function InternetPlanCard({ return "border border-gray-200 shadow-lg hover:shadow-xl"; }; + const installationPrices = installations + .map(inst => + inst.billingCycle === "Monthly" + ? inst.monthlyPrice ?? inst.unitPrice + : inst.oneTimePrice ?? inst.unitPrice + ) + .filter((price): price is number => typeof price === "number" && Number.isFinite(price)); + + const minInstallationPrice = installationPrices.length > 0 ? Math.min(...installationPrices) : null; + return ( - {plan.tier} + {tier || "Plan"} {isGold && ( @@ -256,14 +269,16 @@ function InternetPlanCard({ {/* Plan Details */}

{plan.name}

-

{plan.tierDescription || plan.description}

+

+ {plan.catalogMetadata.tierDescription || plan.description} +

{/* Your Plan Includes */}

Your Plan Includes:

    - {plan.features && plan.features.length > 0 ? ( - plan.features.map((feature, index) => ( + {plan.catalogMetadata.features && plan.catalogMetadata.features.length > 0 ? ( + plan.catalogMetadata.features.map((feature, index) => (
  • {feature} @@ -273,26 +288,26 @@ function InternetPlanCard({ <>
  • 1 NTT Optical Fiber (Flet's - Hikari Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "} - {plan.offeringType?.includes("10G") + Hikari Next - {plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "} + {plan.internetOfferingType?.includes("10G") ? "10Gbps" - : plan.offeringType?.includes("100M") + : plan.internetOfferingType?.includes("100M") ? "100Mbps" : "1Gbps"} - ) Installation + Monthly -
  • -
  • - - Monthly: ¥{plan.monthlyPrice?.toLocaleString()} - {installations.length > 0 && ( - - (+ installation from ¥ - {Math.min(...installations.map(i => i.price || 0)).toLocaleString()}) - - )} -
  • - - )} + ) Installation + Monthly + +
  • + + Monthly: ¥{plan.monthlyPrice?.toLocaleString()} + {minInstallationPrice !== null && ( + + (+ installation from ¥ + {minInstallationPrice.toLocaleString()}) + + )} +
  • + + )}
diff --git a/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx b/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx index 686600c9..a94a907d 100644 --- a/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx +++ b/apps/portal/src/app/(portal)/catalog/sim/configure/page.tsx @@ -381,7 +381,7 @@ function SimConfigureContent() {

{plan.name}

- {plan.hasFamilyDiscount && ( + {plan.simHasFamilyDiscount && ( Family Discount @@ -390,13 +390,13 @@ function SimConfigureContent() {
- Data: {plan.dataSize} + Data: {plan.simDataSize} Type:{" "} - {plan.planType === "DataSmsVoice" + {(plan.simPlanType || "DataSmsVoice") === "DataSmsVoice" ? "Data + Voice" - : plan.planType === "DataOnly" + : (plan.simPlanType || "DataSmsVoice") === "DataOnly" ? "Data Only" : "Voice Only"} @@ -406,7 +406,7 @@ function SimConfigureContent() {
¥{plan.monthlyPrice?.toLocaleString()}/mo
- {plan.hasFamilyDiscount && ( + {plan.simHasFamilyDiscount && (
Discounted Price
)}
@@ -550,16 +550,16 @@ function SimConfigureContent() {
- {addons.length > 0 && plan.planType !== "DataOnly" ? ( + {addons.length > 0 && (plan.simPlanType || "DataSmsVoice") !== "DataOnly" ? ( ({ ...addon, @@ -579,7 +579,7 @@ function SimConfigureContent() { ) : (

- {plan.planType === "DataOnly" + {(plan.simPlanType || "DataSmsVoice") === "DataOnly" ? "No add-ons are available for data-only plans." : "No add-ons are available for this plan."}

@@ -685,7 +685,7 @@ function SimConfigureContent() {

{plan.name}

-

{plan.dataSize}

+

{plan.simDataSize}

@@ -788,7 +788,7 @@ function SimConfigureContent() { {/* Receipt Footer */}

- {plan.planType === "DataOnly" && "Data-only service (no voice features)"} + {(plan.simPlanType || "DataSmsVoice") === "DataOnly" && "Data-only service (no voice features)"}

diff --git a/apps/portal/src/app/(portal)/catalog/sim/page.tsx b/apps/portal/src/app/(portal)/catalog/sim/page.tsx index 7929d745..cf682ec5 100644 --- a/apps/portal/src/app/(portal)/catalog/sim/page.tsx +++ b/apps/portal/src/app/(portal)/catalog/sim/page.tsx @@ -41,8 +41,8 @@ function PlanTypeSection({ if (plans.length === 0) return null; // Separate regular and family plans - const regularPlans = plans.filter(p => !p.hasFamilyDiscount); - const familyPlans = plans.filter(p => p.hasFamilyDiscount); + const regularPlans = plans.filter(p => !p.simHasFamilyDiscount); + const familyPlans = plans.filter(p => p.simHasFamilyDiscount); return (
@@ -89,7 +89,7 @@ function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) {
- {plan.dataSize} + {plan.simDataSize}
{isFamily && (
@@ -147,7 +147,7 @@ export default function SimPlansPage() { if (mounted) { setPlans(plansData); // Check if any plans have family discount (indicates user has existing SIM) - setHasExistingSim(plansData.some(p => p.hasFamilyDiscount)); + setHasExistingSim(plansData.some(p => p.simHasFamilyDiscount)); } } catch (e) { if (mounted) { @@ -196,7 +196,8 @@ export default function SimPlansPage() { // Group plans by type const plansByType: PlansByType = plans.reduce( (acc, plan) => { - acc[plan.planType].push(plan); + const type = plan.simPlanType || "DataSmsVoice"; + acc[type].push(plan); return acc; }, { diff --git a/apps/portal/src/components/catalog/addon-group.tsx b/apps/portal/src/components/catalog/addon-group.tsx index 64df4f6b..bf8c6410 100644 --- a/apps/portal/src/components/catalog/addon-group.tsx +++ b/apps/portal/src/components/catalog/addon-group.tsx @@ -8,7 +8,7 @@ interface AddonItem { sku: string; description: string; monthlyPrice?: number; - activationPrice?: number; + oneTimePrice?: number; isBundledAddon?: boolean; bundledAddonId?: string; displayOrder?: number; @@ -19,7 +19,7 @@ interface AddonGroup { name: string; description: string; monthlyPrice?: number; - activationPrice?: number; + oneTimePrice?: number; skus: string[]; isBundled: boolean; } @@ -55,7 +55,7 @@ export function AddonGroup({ if (bundledPartner && !processedAddonIds.has(bundledPartner.id)) { // Create a combined group const monthlyAddon = addon.monthlyPrice ? addon : bundledPartner; - const activationAddon = addon.activationPrice ? addon : bundledPartner; + const activationAddon = addon.oneTimePrice ? addon : bundledPartner; // Generate clean name and description const cleanName = monthlyAddon.name @@ -68,7 +68,7 @@ export function AddonGroup({ name: bundleName, description: `${bundleName} (installation included)`, monthlyPrice: monthlyAddon.monthlyPrice, - activationPrice: activationAddon.activationPrice, + oneTimePrice: activationAddon.oneTimePrice, skus: [addon.sku, bundledPartner.sku], isBundled: true, }); @@ -82,7 +82,7 @@ export function AddonGroup({ name: addon.name, description: addon.description, monthlyPrice: addon.monthlyPrice, - activationPrice: addon.activationPrice, + oneTimePrice: addon.oneTimePrice, skus: [addon.sku], isBundled: false, }); @@ -95,7 +95,7 @@ export function AddonGroup({ name: addon.name, description: addon.description, monthlyPrice: addon.monthlyPrice, - activationPrice: addon.activationPrice, + oneTimePrice: addon.oneTimePrice, skus: [addon.sku], isBundled: false, }); @@ -155,16 +155,16 @@ export function AddonGroup({

{addonGroup.description}

- {addonGroup.monthlyPrice && ( - - ¥{addonGroup.monthlyPrice.toLocaleString()}/month - - )} - {addonGroup.activationPrice && ( - - Activation: ¥{addonGroup.activationPrice.toLocaleString()} - - )} + {addonGroup.monthlyPrice && ( + + ¥{addonGroup.monthlyPrice.toLocaleString()}/month + + )} + {addonGroup.oneTimePrice && ( + + Activation: ¥{addonGroup.oneTimePrice.toLocaleString()} + + )}
{addonGroup.isBundled && (
diff --git a/apps/portal/src/components/catalog/order-summary.tsx b/apps/portal/src/components/catalog/order-summary.tsx index 1c05b620..04f25512 100644 --- a/apps/portal/src/components/catalog/order-summary.tsx +++ b/apps/portal/src/components/catalog/order-summary.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; interface OrderItem { name: string; - price?: number; + monthlyPrice?: number; + oneTimePrice?: number; billingCycle?: string; sku?: string; } @@ -12,7 +13,7 @@ interface OrderSummaryProps { // Plan details plan: { name: string; - tier?: string; + internetPlanTier?: string; monthlyPrice?: number; }; @@ -78,7 +79,7 @@ export function OrderSummary({ Plan: {plan.name} - {plan.tier && ` (${plan.tier})`} + {plan.internetPlanTier && ` (${plan.internetPlanTier})`}
@@ -107,7 +108,9 @@ export function OrderSummary({
Monthly Costs:
{plan.monthlyPrice && (
- Base Plan {plan.tier && `(${plan.tier})`}: + + Base Plan {plan.internetPlanTier && `(${plan.internetPlanTier})`}: + ¥{plan.monthlyPrice.toLocaleString()}
)} @@ -115,10 +118,12 @@ export function OrderSummary({ {selectedAddons.map( (addon, index) => addon.billingCycle === "Monthly" && - addon.price && ( + typeof addon.monthlyPrice === "number" && (
{addon.name}: - ¥{addon.price.toLocaleString()}/month + + ¥{addon.monthlyPrice.toLocaleString()}/month +
) )} @@ -143,17 +148,21 @@ export function OrderSummary({ {activationFees.map((fee, index) => (
{fee.name}: - ¥{fee.price?.toLocaleString() || 0} + + ¥{(fee.oneTimePrice ?? fee.monthlyPrice ?? 0).toLocaleString()} +
))} {selectedAddons.map( (addon, index) => addon.billingCycle !== "Monthly" && - addon.price && ( + typeof addon.oneTimePrice === "number" && (
{addon.name}: - ¥{addon.price.toLocaleString()} + + ¥{addon.oneTimePrice.toLocaleString()} +
) )} @@ -185,7 +194,9 @@ export function OrderSummary({ {activationFees.map((fee, index) => (
{fee.name} - ¥{fee.price?.toLocaleString()} one-time + + ¥{(fee.oneTimePrice ?? fee.monthlyPrice ?? 0).toLocaleString()} one-time +
))} @@ -194,7 +205,10 @@ export function OrderSummary({
{addon.name} - ¥{addon.price?.toLocaleString()} + ¥{(addon.billingCycle === "Monthly" + ? addon.monthlyPrice ?? addon.oneTimePrice ?? 0 + : addon.oneTimePrice ?? addon.monthlyPrice ?? 0 + ).toLocaleString()} {addon.billingCycle === "Monthly" ? "/mo" : " one-time"}
diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index 5753290a..d8087a7b 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -3,12 +3,12 @@ import { AnimatedCard } from "@/components/ui"; import { Button } from "@/components/ui/button"; import { CurrencyYenIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; -import type { InternetProduct } from "@customer-portal/domain"; +import type { InternetPlan, InternetInstallation } from "@/shared/types/catalog.types"; import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; interface InternetPlanCardProps { - plan: InternetProduct; - installations: InternetProduct[]; + plan: InternetPlan; + installations: InternetInstallation[]; disabled?: boolean; disabledReason?: string; } @@ -19,14 +19,16 @@ export function InternetPlanCard({ plan, installations, disabled, disabledReason const isPlatinum = tier === "Platinum"; const isSilver = tier === "Silver"; - const minInstallationPrice = installations.length - ? Math.min( - ...installations.map(installation => - installation.billingCycle === "Monthly" - ? getMonthlyPrice(installation) - : getOneTimePrice(installation) - ) - ) + const installationPrices = installations + .map(installation => + installation.billingCycle === "Monthly" + ? getMonthlyPrice(installation) + : getOneTimePrice(installation) + ) + .filter(price => price > 0); + + const minInstallationPrice = installationPrices.length + ? Math.min(...installationPrices) : 0; const getBorderClass = () => { @@ -75,13 +77,15 @@ export function InternetPlanCard({ plan, installations, disabled, disabledReason

{plan.name}

-

{plan.tierDescription || plan.description}

+

+ {plan.catalogMetadata.tierDescription || plan.description} +

Your Plan Includes:

    - {plan.features && plan.features.length > 0 ? ( - plan.features.map((feature, index) => ( + {plan.catalogMetadata.features && plan.catalogMetadata.features.length > 0 ? ( + plan.catalogMetadata.features.map((feature, index) => (
  • {feature} diff --git a/apps/portal/src/features/catalog/types/catalog.types.ts b/apps/portal/src/features/catalog/types/catalog.types.ts index 792ea777..fcfe57bd 100644 --- a/apps/portal/src/features/catalog/types/catalog.types.ts +++ b/apps/portal/src/features/catalog/types/catalog.types.ts @@ -1,86 +1,52 @@ -/** - * Frontend catalog types - Clean, focused, production-ready - * NO NAME MATCHING - Direct product access only - * NO HARDCODED DATA - Only structured Salesforce data - */ +import type { + InternetProduct, + SimProduct, + VpnProduct, + ProductWithPricing, +} from "@customer-portal/domain"; -// Core product interface -export interface BaseProduct { - id: string; - name: string; - sku: string; - description: string; -} +// API shapes returned by the catalog endpoints. We model them as extensions of the +// unified domain types so field names stay aligned with Salesforce structures. +export type InternetPlan = InternetProduct & { + catalogMetadata: { + tierDescription: string; + features: string[]; + isRecommended: boolean; + }; +}; -// Internet product types -export interface InternetPlan extends BaseProduct { - tier: "Silver" | "Gold" | "Platinum"; - offeringType: string; - monthlyPrice?: number; - features: string[]; - tierDescription: string; - isRecommended: boolean; -} +export type InternetInstallation = InternetProduct & { + catalogMetadata: { + installationTerm: "One-time" | "12-Month" | "24-Month"; + }; +}; -export interface InternetInstallation extends BaseProduct { - type: "One-time" | "12-Month" | "24-Month"; - price?: number; - billingCycle: string; - displayOrder: number; -} +export type InternetAddon = InternetProduct & { + catalogMetadata: { + addonCategory: "hikari-denwa-service" | "hikari-denwa-installation" | "other"; + autoAdd: boolean; + requiredWith: string[]; + }; +}; -export interface InternetAddon extends BaseProduct { - type: "hikari-denwa-service" | "hikari-denwa-installation" | "other"; - monthlyPrice?: number; - activationPrice?: number; - autoAdd: boolean; - requiredWith: string[]; - bundledAddonId?: string; // ID of the bundled product - isBundledAddon?: boolean; // Whether this is part of a bundle - displayOrder?: number; -} +export type SimPlan = SimProduct; -// SIM product types -export interface SimPlan extends BaseProduct { - dataSize: string; - planType: "DataOnly" | "DataSmsVoice" | "VoiceOnly"; - hasFamilyDiscount: boolean; - monthlyPrice?: number; - features: string[]; -} +export type SimActivationFee = SimProduct & { + catalogMetadata: { + isDefault: boolean; + }; +}; -export interface SimActivationFee extends BaseProduct { - price: number; - isDefault: boolean; -} +export type SimAddon = SimProduct; -export interface SimAddon extends BaseProduct { - price: number; - billingCycle: string; - bundledAddonId?: string; // ID of the bundled product - isBundledAddon?: boolean; // Whether this is part of a bundle - displayOrder?: number; -} +export type VpnPlan = VpnProduct; +export type VpnActivationFee = VpnProduct; -// VPN product types -export interface VpnPlan extends BaseProduct { - region: string; // VPN region from VPN_Region__c (USA-SF, UK-London, Global) - monthlyPrice?: number; -} - -export interface VpnActivationFee extends BaseProduct { - price: number; - region?: string; - isDefault: boolean; -} - -// Helper types export type ProductType = "Internet" | "SIM" | "VPN"; export type ItemClass = "Service" | "Installation" | "Add-on" | "Activation"; export type BillingCycle = "Monthly" | "Onetime"; export type PlanTier = "Silver" | "Gold" | "Platinum"; -// Frontend-specific types export interface CheckoutState { loading: boolean; error: string | null; @@ -102,7 +68,6 @@ export interface OrderTotals { oneTimeTotal: number; } -// Production-ready utility functions - only structured Salesforce data export const buildInternetOrderItems = ( plan: InternetPlan, addons: InternetAddon[], @@ -114,29 +79,27 @@ export const buildInternetOrderItems = ( ): OrderItem[] => { const items: OrderItem[] = []; - // Add main plan items.push({ name: plan.name, sku: plan.sku, monthlyPrice: plan.monthlyPrice, + oneTimePrice: plan.oneTimePrice, type: "service", }); - // Add installation if selected (by SKU) if (selections.installationSku) { const installation = installations.find(inst => inst.sku === selections.installationSku); if (installation) { items.push({ name: installation.name, sku: installation.sku, - monthlyPrice: installation.billingCycle === "Monthly" ? installation.price : undefined, - oneTimePrice: installation.billingCycle !== "Monthly" ? installation.price : undefined, + monthlyPrice: installation.billingCycle === "Monthly" ? installation.monthlyPrice : undefined, + oneTimePrice: installation.billingCycle !== "Monthly" ? installation.oneTimePrice : undefined, type: "installation", }); } } - // Add selected addons (by SKU like SIM flow) if (selections.addonSkus && selections.addonSkus.length > 0) { selections.addonSkus.forEach(addonSku => { const selectedAddon = addons.find(addon => addon.sku === addonSku); @@ -145,7 +108,7 @@ export const buildInternetOrderItems = ( name: selectedAddon.name, sku: selectedAddon.sku, monthlyPrice: selectedAddon.monthlyPrice, - oneTimePrice: selectedAddon.activationPrice, + oneTimePrice: selectedAddon.oneTimePrice, type: "addon", }); } @@ -160,32 +123,29 @@ export const buildSimOrderItems = ( activationFees: SimActivationFee[], addons: SimAddon[], selections: { - addonSkus?: string[]; // Support multiple add-ons + addonSkus?: string[]; } ): OrderItem[] => { const items: OrderItem[] = []; - // Add main plan - items.push({ name: plan.name, - sku: plan.sku, // This should be the actual StockKeepingUnit from Salesforce + sku: plan.sku, monthlyPrice: plan.monthlyPrice, + oneTimePrice: plan.oneTimePrice, type: "service", }); - // Add activation fee - const activationFee = activationFees[0]; + const activationFee = activationFees.find(fee => fee.catalogMetadata.isDefault) || activationFees[0]; if (activationFee) { items.push({ name: activationFee.name, sku: activationFee.sku, - oneTimePrice: activationFee.price, + oneTimePrice: activationFee.oneTimePrice ?? activationFee.unitPrice, type: "addon", }); } - // Add selected addons from Salesforce (multiple allowed) if (selections.addonSkus && selections.addonSkus.length > 0) { selections.addonSkus.forEach(addonSku => { const selectedAddon = addons.find(addon => addon.sku === addonSku); @@ -193,12 +153,10 @@ export const buildSimOrderItems = ( items.push({ name: selectedAddon.name, sku: selectedAddon.sku, - monthlyPrice: selectedAddon.billingCycle === "Monthly" ? selectedAddon.price : undefined, - oneTimePrice: selectedAddon.billingCycle !== "Monthly" ? selectedAddon.price : undefined, + monthlyPrice: selectedAddon.billingCycle === "Monthly" ? selectedAddon.monthlyPrice ?? selectedAddon.unitPrice : undefined, + oneTimePrice: selectedAddon.billingCycle !== "Monthly" ? selectedAddon.oneTimePrice ?? selectedAddon.unitPrice : undefined, type: "addon", }); - } else { - // Addon SKU not found - skip silently in production } }); } diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index c013ffdd..c43aa552 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -13,7 +13,7 @@ import { } from "@heroicons/react/24/outline"; import { useInternetCatalog } from "@/features/catalog/hooks"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; -import type { InternetProduct } from "@customer-portal/domain"; +import type { InternetPlan, InternetInstallation } from "@/shared/types/catalog.types"; import { getMonthlyPrice } from "../utils/pricing"; import { LoadingCard, Skeleton, LoadingTable } from "@/components/ui/loading-skeleton"; import { AnimatedCard } from "@/components/ui"; @@ -24,8 +24,8 @@ import { AlertBanner } from "@/components/common/AlertBanner"; export function InternetPlansContainer() { const { data, isLoading, error } = useInternetCatalog(); - const plans: InternetProduct[] = data?.plans || []; - const installations: InternetProduct[] = data?.installations || []; + const plans: InternetPlan[] = data?.plans || []; + const installations: InternetInstallation[] = data?.installations || []; const [eligibility, setEligibility] = useState(""); const { data: activeSubs } = useActiveSubscriptions(); const hasActiveInternet = Array.isArray(activeSubs) @@ -44,16 +44,16 @@ export function InternetPlansContainer() { } }, [plans]); - const getEligibilityIcon = (offeringType: string) => { - const lower = offeringType.toLowerCase(); + const getEligibilityIcon = (offeringType?: string) => { + const lower = (offeringType || "").toLowerCase(); if (lower.includes("home")) return ; if (lower.includes("apartment")) return ; return ; }; - const getEligibilityColor = (offeringType: string) => { - const lower = offeringType.toLowerCase(); + const getEligibilityColor = (offeringType?: string) => { + const lower = (offeringType || "").toLowerCase(); if (lower.includes("home")) return "text-blue-600 bg-blue-50 border-blue-200"; if (lower.includes("apartment")) @@ -186,4 +186,3 @@ export function InternetPlansContainer() { } // InternetPlanCard extracted to components/internet/InternetPlanCard - diff --git a/packages/domain/src/entities/product.ts b/packages/domain/src/entities/product.ts index aaa0f302..7603b1e5 100644 --- a/packages/domain/src/entities/product.ts +++ b/packages/domain/src/entities/product.ts @@ -112,12 +112,6 @@ export interface ProductWithPricing extends BaseProduct { unitPrice?: number; // PricebookEntry.UnitPrice monthlyPrice?: number; // UnitPrice if billingCycle === "Monthly" oneTimePrice?: number; // UnitPrice if billingCycle === "Onetime" - /** @deprecated Legacy field kept temporarily for UI compatibility. Use monthlyPrice/oneTimePrice instead. */ - price?: number; - /** @deprecated Legacy field kept temporarily for UI compatibility. Use oneTimePrice instead. */ - activationPrice?: number; - /** @deprecated Legacy field kept temporarily for UI compatibility. Prefer deriving type from sku/itemClass. */ - type?: string; } // Internet-specific product fields @@ -133,10 +127,6 @@ export interface InternetProduct extends ProductWithPricing { features?: string[]; tierDescription?: string; isRecommended?: boolean; - /** @deprecated Use internetPlanTier. */ - tier?: InternetProduct["internetPlanTier"]; - /** @deprecated Use internetOfferingType. */ - offeringType?: string; } // SIM-specific product fields @@ -150,12 +140,6 @@ export interface SimProduct extends ProductWithPricing { // UI-specific fields features?: string[]; - /** @deprecated Use simDataSize. */ - dataSize?: string; - /** @deprecated Use simPlanType. */ - planType?: SimProduct["simPlanType"]; - /** @deprecated Use simHasFamilyDiscount. */ - hasFamilyDiscount?: boolean; } // VPN-specific product fields @@ -164,8 +148,6 @@ export interface VpnProduct extends ProductWithPricing { // VPN-specific fields vpnRegion?: string; // VPN_Region__c - /** @deprecated Use vpnRegion. */ - region?: string; } // Generic product for "Other" category @@ -339,10 +321,6 @@ export function fromSalesforceProduct2( | InternetProduct["internetPlanTier"] | undefined, internetOfferingType: coerceString(readField(sfProduct, fieldMap.internetOfferingType)) || undefined, - tier: coerceString(readField(sfProduct, fieldMap.internetPlanTier)) as - | InternetProduct["internetPlanTier"] - | undefined, - offeringType: coerceString(readField(sfProduct, fieldMap.internetOfferingType)) || undefined, } satisfies InternetProduct; case "SIM": @@ -351,16 +329,12 @@ export function fromSalesforceProduct2( simDataSize: coerceString(readField(sfProduct, fieldMap.simDataSize)) || undefined, simPlanType: normalizeSimPlanType(readField(sfProduct, fieldMap.simPlanType)) || undefined, simHasFamilyDiscount: normalizeBoolean(readField(sfProduct, fieldMap.simHasFamilyDiscount)), - dataSize: coerceString(readField(sfProduct, fieldMap.simDataSize)) || undefined, - planType: normalizeSimPlanType(readField(sfProduct, fieldMap.simPlanType)) || undefined, - hasFamilyDiscount: normalizeBoolean(readField(sfProduct, fieldMap.simHasFamilyDiscount)) ?? undefined, } satisfies SimProduct; case "VPN": return { ...baseProduct, vpnRegion: coerceString(readField(sfProduct, fieldMap.vpnRegion)) || undefined, - region: coerceString(readField(sfProduct, fieldMap.vpnRegion)) || undefined, } satisfies VpnProduct; default: