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.
This commit is contained in:
T. Narantuya 2025-09-18 11:22:22 +09:00
parent a95ec60859
commit 52adc29016
13 changed files with 353 additions and 312 deletions

View File

@ -1,13 +1,17 @@
import { Controller, Get, Request } from "@nestjs/common"; import { Controller, Get, Request } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger"; 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 { import {
InternetProduct, InternetAddonCatalogItem,
SimProduct, InternetCatalogService,
VpnProduct, InternetInstallationCatalogItem,
} from "@customer-portal/domain"; 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") @ApiTags("catalog")
@Controller("catalog") @Controller("catalog")
@ -21,7 +25,9 @@ export class CatalogController {
@Get("internet/plans") @Get("internet/plans")
@ApiOperation({ summary: "Get Internet plans filtered by customer eligibility" }) @ApiOperation({ summary: "Get Internet plans filtered by customer eligibility" })
async getInternetPlans(@Request() req: { user: { id: string } }): Promise<InternetProduct[]> { async getInternetPlans(
@Request() req: { user: { id: string } }
): Promise<InternetPlanCatalogItem[]> {
const userId = req.user?.id; const userId = req.user?.id;
if (!userId) { if (!userId) {
// Fallback to all plans if no user context // Fallback to all plans if no user context
@ -32,13 +38,13 @@ export class CatalogController {
@Get("internet/addons") @Get("internet/addons")
@ApiOperation({ summary: "Get Internet add-ons" }) @ApiOperation({ summary: "Get Internet add-ons" })
async getInternetAddons(): Promise<InternetProduct[]> { async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
return this.internetCatalog.getAddons(); return this.internetCatalog.getAddons();
} }
@Get("internet/installations") @Get("internet/installations")
@ApiOperation({ summary: "Get Internet installations" }) @ApiOperation({ summary: "Get Internet installations" })
async getInternetInstallations(): Promise<InternetProduct[]> { async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
return this.internetCatalog.getInstallations(); return this.internetCatalog.getInstallations();
} }
@ -49,14 +55,14 @@ export class CatalogController {
if (!userId) { if (!userId) {
// Fallback to all regular plans if no user context // Fallback to all regular plans if no user context
const allPlans = await this.simCatalog.getPlans(); const allPlans = await this.simCatalog.getPlans();
return allPlans.filter(plan => !plan.hasFamilyDiscount); return allPlans.filter(plan => !plan.simHasFamilyDiscount);
} }
return this.simCatalog.getPlansForUser(userId); return this.simCatalog.getPlansForUser(userId);
} }
@Get("sim/activation-fees") @Get("sim/activation-fees")
@ApiOperation({ summary: "Get SIM activation fees" }) @ApiOperation({ summary: "Get SIM activation fees" })
async getSimActivationFees(): Promise<SimProduct[]> { async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
return this.simCatalog.getActivationFees(); return this.simCatalog.getActivationFees();
} }

View File

@ -1,7 +1,6 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { BaseCatalogService } from "./base-catalog.service"; import { BaseCatalogService } from "./base-catalog.service";
import { InternetProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain"; import { InternetProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { MappingsService } from "../../id-mappings/mappings.service"; import { MappingsService } from "../../id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
@ -12,18 +11,39 @@ interface SalesforceAccount {
Internet_Eligibility__c?: string; 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() @Injectable()
export class InternetCatalogService extends BaseCatalogService { export class InternetCatalogService extends BaseCatalogService {
constructor( constructor(
sf: SalesforceConnection, sf: SalesforceConnection,
@Inject(Logger) logger: Logger, @Inject(Logger) logger: Logger,
private salesforceService: SalesforceService,
private mappingsService: MappingsService private mappingsService: MappingsService
) { ) {
super(sf, logger); super(sf, logger);
} }
async getPlans(): Promise<InternetProduct[]> { async getPlans(): Promise<InternetPlanCatalogItem[]> {
const fields = this.getFields(); const fields = this.getFields();
const soql = this.buildCatalogServiceQuery("Internet", [ const soql = this.buildCatalogServiceQuery("Internet", [
fields.product.internetPlanTier, fields.product.internetPlanTier,
@ -44,15 +64,17 @@ export class InternetCatalogService extends BaseCatalogService {
return { return {
...product, ...product,
tierDescription: tierData.tierDescription, catalogMetadata: {
features: tierData.features, tierDescription: tierData.tierDescription,
isRecommended: product.internetPlanTier === "Gold", features: tierData.features,
isRecommended: product.internetPlanTier === "Gold",
},
description: product.description ?? tierData.description, description: product.description ?? tierData.description,
} satisfies InternetProduct; } satisfies InternetPlanCatalogItem;
}); });
} }
async getInstallations(): Promise<InternetProduct[]> { async getInstallations(): Promise<InternetInstallationCatalogItem[]> {
const fields = this.getFields(); const fields = this.getFields();
const soql = this.buildProductQuery("Internet", "Installation", [ const soql = this.buildProductQuery("Internet", "Installation", [
fields.product.billingCycle, fields.product.billingCycle,
@ -72,22 +94,19 @@ export class InternetCatalogService extends BaseCatalogService {
) as InternetProduct; ) as InternetProduct;
const installationType = this.inferInstallationTypeFromSku(product.sku); const installationType = this.inferInstallationTypeFromSku(product.sku);
const priceValue =
product.billingCycle === "Monthly"
? product.monthlyPrice ?? product.unitPrice ?? 0
: product.oneTimePrice ?? product.unitPrice ?? 0;
return { return {
...product, ...product,
type: installationType, catalogMetadata: {
price: priceValue, installationTerm: installationType,
},
displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0), displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0),
} satisfies InternetProduct; } satisfies InternetInstallationCatalogItem;
}) })
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
} }
async getAddons(): Promise<InternetProduct[]> { async getAddons(): Promise<InternetAddonCatalogItem[]> {
const fields = this.getFields(); const fields = this.getFields();
const soql = this.buildProductQuery("Internet", "Add-on", [ const soql = this.buildProductQuery("Internet", "Add-on", [
fields.product.billingCycle, fields.product.billingCycle,
@ -109,19 +128,16 @@ export class InternetCatalogService extends BaseCatalogService {
) as InternetProduct; ) as InternetProduct;
const addonType = this.inferAddonTypeFromSku(product.sku); const addonType = this.inferAddonTypeFromSku(product.sku);
const activationPrice =
product.billingCycle === "Onetime"
? product.oneTimePrice ?? product.unitPrice ?? 0
: undefined;
return { return {
...product, ...product,
type: addonType, catalogMetadata: {
activationPrice, addonCategory: addonType,
autoAdd: false, autoAdd: false,
requiredWith: [], requiredWith: [],
},
displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0), displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0),
} satisfies InternetProduct; } satisfies InternetAddonCatalogItem;
}) })
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
} }
@ -135,7 +151,7 @@ export class InternetCatalogService extends BaseCatalogService {
return { plans, installations, addons }; return { plans, installations, addons };
} }
async getPlansForUser(userId: string): Promise<InternetProduct[]> { async getPlansForUser(userId: string): Promise<InternetPlanCatalogItem[]> {
try { try {
// Get all plans first // Get all plans first
const allPlans = await this.getPlans(); const allPlans = await this.getPlans();
@ -162,7 +178,7 @@ export class InternetCatalogService extends BaseCatalogService {
if (!eligibility) { if (!eligibility) {
this.logger.log(`No eligibility field for user ${userId}, filtering to Home 1G plans only`); 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; return homeGPlans;
} }
@ -171,7 +187,7 @@ export class InternetCatalogService extends BaseCatalogService {
const isEligible = this.checkPlanEligibility(plan, eligibility); const isEligible = this.checkPlanEligibility(plan, eligibility);
if (!isEligible) { if (!isEligible) {
this.logger.debug( 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; return isEligible;
@ -190,10 +206,10 @@ export class InternetCatalogService extends BaseCatalogService {
} }
} }
private checkPlanEligibility(plan: InternetProduct, eligibility: string): boolean { private checkPlanEligibility(plan: InternetPlanCatalogItem, eligibility: string): boolean {
// Simple match: user's eligibility field must equal plan's offering type // Simple match: user's eligibility field must equal plan's Salesforce offering type
// e.g., eligibility "Home 1G" matches plan.offeringType "Home 1G" // e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G"
return plan.offeringType === eligibility; return plan.internetOfferingType === eligibility;
} }
private getTierData(tier: string) { private getTierData(tier: string) {
@ -239,4 +255,35 @@ export class InternetCatalogService extends BaseCatalogService {
return tierData[tier] || tierData["Silver"]; 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";
}
} }

View File

@ -1,18 +1,22 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { BaseCatalogService } from "./base-catalog.service"; import { BaseCatalogService } from "./base-catalog.service";
import { SimProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain"; import { SimProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { MappingsService } from "../../id-mappings/mappings.service"; import { MappingsService } from "../../id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service"; import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
export type SimActivationFeeCatalogItem = SimProduct & {
catalogMetadata: {
isDefault: boolean;
};
};
@Injectable() @Injectable()
export class SimCatalogService extends BaseCatalogService { export class SimCatalogService extends BaseCatalogService {
constructor( constructor(
sf: SalesforceConnection, sf: SalesforceConnection,
@Inject(Logger) logger: Logger, @Inject(Logger) logger: Logger,
private salesforceService: SalesforceService,
private mappingsService: MappingsService, private mappingsService: MappingsService,
private whmcs: WhmcsConnectionService private whmcs: WhmcsConnectionService
) { ) {
@ -39,16 +43,13 @@ export class SimCatalogService extends BaseCatalogService {
return { return {
...product, ...product,
dataSize: product.simDataSize || "Unknown",
planType: product.simPlanType,
hasFamilyDiscount: product.simHasFamilyDiscount || false,
description: product.name, description: product.name,
features: product.features ?? [], features: product.features ?? [],
} satisfies SimProduct; } satisfies SimProduct;
}); });
} }
async getActivationFees(): Promise<SimProduct[]> { async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
const fields = this.getFields(); const fields = this.getFields();
const soql = this.buildProductQuery("SIM", "Activation", []); const soql = this.buildProductQuery("SIM", "Activation", []);
const records = await this.executeQuery(soql, "SIM Activation Fees"); const records = await this.executeQuery(soql, "SIM Activation Fees");
@ -61,14 +62,13 @@ export class SimCatalogService extends BaseCatalogService {
fields.product fields.product
) as SimProduct; ) as SimProduct;
const priceValue = product.oneTimePrice ?? product.unitPrice ?? 0;
return { return {
...product, ...product,
price: priceValue,
description: product.name, description: product.name,
isDefault: true, catalogMetadata: {
} satisfies SimProduct; isDefault: true,
},
} satisfies SimActivationFeeCatalogItem;
}); });
} }
@ -93,10 +93,6 @@ export class SimCatalogService extends BaseCatalogService {
return { return {
...product, ...product,
price:
product.billingCycle === "Monthly"
? product.monthlyPrice ?? product.unitPrice ?? 0
: product.oneTimePrice ?? product.unitPrice ?? 0,
description: product.name, description: product.name,
displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0), displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0),
} satisfies SimProduct; } satisfies SimProduct;
@ -115,17 +111,17 @@ export class SimCatalogService extends BaseCatalogService {
if (hasExistingSim) { if (hasExistingSim) {
this.logger.log(`User ${userId} has existing SIM, showing family discount plans`); this.logger.log(`User ${userId} has existing SIM, showing family discount plans`);
// Show family discount plans + regular plans for comparison // 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 { } else {
this.logger.log(`User ${userId} has no existing SIM, showing regular plans only`); this.logger.log(`User ${userId} has no existing SIM, showing regular plans only`);
// Show only regular plans (hide family discount plans) // Show only regular plans (hide family discount plans)
return allPlans.filter(plan => !plan.hasFamilyDiscount); return allPlans.filter(plan => !plan.simHasFamilyDiscount);
} }
} catch (error) { } catch (error) {
this.logger.error(`Failed to get personalized SIM plans for user ${userId}`, error); this.logger.error(`Failed to get personalized SIM plans for user ${userId}`, error);
// Fallback to all regular plans // Fallback to all regular plans
const allPlans = await this.getPlans(); const allPlans = await this.getPlans();
return allPlans.filter(plan => !plan.hasFamilyDiscount); return allPlans.filter(plan => !plan.simHasFamilyDiscount);
} }
} }

View File

@ -85,10 +85,10 @@ function InternetConfigureContent() {
const installationSkuParam = searchParams.get("installationSku"); const installationSkuParam = searchParams.get("installationSku");
if (installationSkuParam) { if (installationSkuParam) {
const installation = installationsData.find(i => i.sku === installationSkuParam); const installation = installationsData.find(i => i.sku === installationSkuParam);
if (installation) { if (installation) {
setInstallPlan(installation.type); setInstallPlan(installation.catalogMetadata.installationTerm);
} }
} }
// Restore selected addons from URL parameters // Restore selected addons from URL parameters
@ -129,23 +129,34 @@ function InternetConfigureContent() {
// Add selected addons // Add selected addons
selectedAddonSkus.forEach(addonSku => { selectedAddonSkus.forEach(addonSku => {
const addon = addons.find(a => a.sku === addonSku); const addon = addons.find(a => a.sku === addonSku);
if (addon) { if (!addon) return;
if (addon.monthlyPrice) {
monthlyTotal += addon.monthlyPrice; const monthlyCharge = addon.monthlyPrice ?? (addon.billingCycle === "Monthly" ? addon.unitPrice : undefined);
} const oneTimeCharge = addon.oneTimePrice ?? (addon.billingCycle !== "Monthly" ? addon.unitPrice : undefined);
if (addon.activationPrice) {
oneTimeTotal += addon.activationPrice; if (typeof monthlyCharge === "number") {
} monthlyTotal += monthlyCharge;
}
if (typeof oneTimeCharge === "number") {
oneTimeTotal += oneTimeCharge;
} }
}); });
// Add installation cost // Add installation cost
const installation = installations.find(i => i.type === installPlan); const installation = installations.find(
if (installation && installation.price) { i => i.catalogMetadata.installationTerm === installPlan
if (installation.billingCycle === "Monthly") { );
monthlyTotal += installation.price; if (installation) {
} else { const monthlyCharge =
oneTimeTotal += installation.price; 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) // Add installation SKU (not type)
const installation = installations.find(i => i.type === installPlan); const installation = installations.find(
i => i.catalogMetadata.installationTerm === installPlan
);
if (installation) { if (installation) {
params.append("installationSku", installation.sku); params.append("installationSku", installation.sku);
} }
@ -229,14 +242,14 @@ function InternetConfigureContent() {
<div className="inline-flex items-center gap-3 bg-gray-50 px-6 py-3 rounded-2xl border"> <div className="inline-flex items-center gap-3 bg-gray-50 px-6 py-3 rounded-2xl border">
<div <div
className={`px-3 py-1 rounded-full text-sm font-medium ${ className={`px-3 py-1 rounded-full text-sm font-medium ${
plan.tier === "Platinum" plan.internetPlanTier === "Platinum"
? "bg-purple-100 text-purple-800" ? "bg-purple-100 text-purple-800"
: plan.tier === "Gold" : plan.internetPlanTier === "Gold"
? "bg-yellow-100 text-yellow-800" ? "bg-yellow-100 text-yellow-800"
: "bg-gray-100 text-gray-800" : "bg-gray-100 text-gray-800"
}`} }`}
> >
{plan.tier} {plan.internetPlanTier || "Plan"}
</div> </div>
<span className="text-gray-600"></span> <span className="text-gray-600"></span>
<span className="font-medium text-gray-900">{plan.name}</span> <span className="font-medium text-gray-900">{plan.name}</span>
@ -274,7 +287,7 @@ function InternetConfigureContent() {
</div> </div>
{/* Important Message for Platinum */} {/* Important Message for Platinum */}
{plan?.tier === "Platinum" && ( {plan?.internetPlanTier === "Platinum" && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div className="flex items-start"> <div className="flex items-start">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@ -308,7 +321,7 @@ function InternetConfigureContent() {
)} )}
{/* Access Mode Selection - Only for Silver */} {/* Access Mode Selection - Only for Silver */}
{plan?.tier === "Silver" ? ( {plan?.internetPlanTier === "Silver" ? (
<div className="mb-6"> <div className="mb-6">
<h4 className="font-medium text-gray-900 mb-4"> <h4 className="font-medium text-gray-900 mb-4">
Select Your Router & ISP Configuration: Select Your Router & ISP Configuration:
@ -438,7 +451,7 @@ function InternetConfigureContent() {
/> />
</svg> </svg>
<span className="font-medium text-green-900"> <span className="font-medium text-green-900">
Access Mode: IPoE-HGW (Pre-configured for {plan?.tier} plan) Access Mode: IPoE-HGW (Pre-configured for {plan?.internetPlanTier} plan)
</span> </span>
</div> </div>
</div> </div>
@ -477,9 +490,13 @@ function InternetConfigureContent() {
<InstallationOptions <InstallationOptions
installations={installations} installations={installations}
selectedInstallation={installations.find(inst => inst.type === installPlan) || null} selectedInstallation={
installations.find(
inst => inst.catalogMetadata.installationTerm === installPlan
) || null
}
onInstallationSelect={installation => { onInstallationSelect={installation => {
setInstallPlan(installation.type); setInstallPlan(installation.catalogMetadata.installationTerm);
}} }}
showSkus={false} showSkus={false}
/> />
@ -634,19 +651,19 @@ function InternetConfigureContent() {
<div className="space-y-2"> <div className="space-y-2">
{selectedAddonSkus.map(addonSku => { {selectedAddonSkus.map(addonSku => {
const addon = addons.find(a => a.sku === 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 ( return (
<div key={addonSku} className="flex justify-between text-sm"> <div key={addonSku} className="flex justify-between text-sm">
<span className="text-gray-600">{addon?.name || addonSku}</span> <span className="text-gray-600">{addon?.name || addonSku}</span>
<span className="text-gray-900"> <span className="text-gray-900">
¥ ¥{amount.toLocaleString()}
{( <span className="text-xs text-gray-500 ml-1">/{cadence}</span>
addon?.monthlyPrice ||
addon?.activationPrice ||
0
).toLocaleString()}
<span className="text-xs text-gray-500 ml-1">
/{addon?.monthlyPrice ? "mo" : "once"}
</span>
</span> </span>
</div> </div>
); );
@ -657,16 +674,26 @@ function InternetConfigureContent() {
{/* Installation Fees */} {/* Installation Fees */}
{(() => { {(() => {
const installation = installations.find(i => i.type === installPlan); const installation = installations.find(
return installation && installation.price && installation.price > 0 ? ( 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 ? (
<div className="border-t border-gray-200 pt-4 mb-6"> <div className="border-t border-gray-200 pt-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">Installation</h4> <h4 className="font-medium text-gray-900 mb-3">Installation</h4>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-gray-600">{installation.name}</span> <span className="text-gray-600">{installation.name}</span>
<span className="text-gray-900"> <span className="text-gray-900">
¥{installation.price.toLocaleString()} ¥{amount.toLocaleString()}
<span className="text-xs text-gray-500 ml-1"> <span className="text-xs text-gray-500 ml-1">
/{installation.billingCycle === "Monthly" ? "mo" : "once"} /{monthlyAmount ? "mo" : "once"}
</span> </span>
</span> </span>
</div> </div>

View File

@ -42,7 +42,7 @@ export default function InternetPlansPage() {
setPlans(plans); setPlans(plans);
setInstallations(installations); setInstallations(installations);
if (plans.length > 0) { if (plans.length > 0) {
setEligibility(plans[0].offeringType || "Home 1G"); setEligibility(plans[0].internetOfferingType || "Home 1G");
} }
} }
} catch (e) { } catch (e) {
@ -58,21 +58,23 @@ export default function InternetPlansPage() {
}; };
}, []); }, []);
const getEligibilityIcon = (offeringType: string) => { const getEligibilityIcon = (offeringType?: string) => {
if (offeringType.toLowerCase().includes("home")) { const lower = (offeringType || "").toLowerCase();
if (lower.includes("home")) {
return <HomeIcon className="h-5 w-5" />; return <HomeIcon className="h-5 w-5" />;
} }
if (offeringType.toLowerCase().includes("apartment")) { if (lower.includes("apartment")) {
return <BuildingOfficeIcon className="h-5 w-5" />; return <BuildingOfficeIcon className="h-5 w-5" />;
} }
return <HomeIcon className="h-5 w-5" />; return <HomeIcon className="h-5 w-5" />;
}; };
const getEligibilityColor = (offeringType: string) => { const getEligibilityColor = (offeringType?: string) => {
if (offeringType.toLowerCase().includes("home")) { const lower = (offeringType || "").toLowerCase();
if (lower.includes("home")) {
return "text-blue-600 bg-blue-50 border-blue-200"; 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-green-600 bg-green-50 border-green-200";
} }
return "text-gray-600 bg-gray-50 border-gray-200"; return "text-gray-600 bg-gray-50 border-gray-200";
@ -200,9 +202,10 @@ function InternetPlanCard({
plan: InternetPlan; plan: InternetPlan;
installations: InternetInstallation[]; installations: InternetInstallation[];
}) { }) {
const isGold = plan.tier === "Gold"; const tier = plan.internetPlanTier;
const isPlatinum = plan.tier === "Platinum"; const isGold = tier === "Gold";
const isSilver = plan.tier === "Silver"; const isPlatinum = tier === "Platinum";
const isSilver = tier === "Silver";
// Use default variant for all cards to avoid green background on gold // Use default variant for all cards to avoid green background on gold
const cardVariant = "default"; const cardVariant = "default";
@ -215,6 +218,16 @@ function InternetPlanCard({
return "border border-gray-200 shadow-lg hover:shadow-xl"; 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 ( return (
<AnimatedCard <AnimatedCard
variant={cardVariant} variant={cardVariant}
@ -233,7 +246,7 @@ function InternetPlanCard({
: "bg-gray-100 text-gray-800 border-gray-300" : "bg-gray-100 text-gray-800 border-gray-300"
}`} }`}
> >
{plan.tier} {tier || "Plan"}
</span> </span>
{isGold && ( {isGold && (
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full"> <span className="px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">
@ -256,14 +269,16 @@ function InternetPlanCard({
{/* Plan Details */} {/* Plan Details */}
<h3 className="text-xl font-semibold text-gray-900 mb-2">{plan.name}</h3> <h3 className="text-xl font-semibold text-gray-900 mb-2">{plan.name}</h3>
<p className="text-gray-600 text-sm mb-4">{plan.tierDescription || plan.description}</p> <p className="text-gray-600 text-sm mb-4">
{plan.catalogMetadata.tierDescription || plan.description}
</p>
{/* Your Plan Includes */} {/* Your Plan Includes */}
<div className="mb-6 flex-grow"> <div className="mb-6 flex-grow">
<h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4> <h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4>
<ul className="space-y-2 text-sm text-gray-700"> <ul className="space-y-2 text-sm text-gray-700">
{plan.features && plan.features.length > 0 ? ( {plan.catalogMetadata.features && plan.catalogMetadata.features.length > 0 ? (
plan.features.map((feature, index) => ( plan.catalogMetadata.features.map((feature, index) => (
<li key={index} className="flex items-start"> <li key={index} className="flex items-start">
<span className="text-green-600 mr-2"></span> <span className="text-green-600 mr-2"></span>
{feature} {feature}
@ -273,26 +288,26 @@ function InternetPlanCard({
<> <>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-600 mr-2"></span>1 NTT Optical Fiber (Flet&apos;s <span className="text-green-600 mr-2"></span>1 NTT Optical Fiber (Flet&apos;s
Hikari Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "} Hikari Next - {plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
{plan.offeringType?.includes("10G") {plan.internetOfferingType?.includes("10G")
? "10Gbps" ? "10Gbps"
: plan.offeringType?.includes("100M") : plan.internetOfferingType?.includes("100M")
? "100Mbps" ? "100Mbps"
: "1Gbps"} : "1Gbps"}
) Installation + Monthly ) Installation + Monthly
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-600 mr-2"></span> <span className="text-green-600 mr-2"></span>
Monthly: ¥{plan.monthlyPrice?.toLocaleString()} Monthly: ¥{plan.monthlyPrice?.toLocaleString()}
{installations.length > 0 && ( {minInstallationPrice !== null && (
<span className="text-gray-600 text-sm ml-2"> <span className="text-gray-600 text-sm ml-2">
(+ installation from ¥ (+ installation from ¥
{Math.min(...installations.map(i => i.price || 0)).toLocaleString()}) {minInstallationPrice.toLocaleString()})
</span> </span>
)} )}
</li> </li>
</> </>
)} )}
</ul> </ul>
</div> </div>

View File

@ -381,7 +381,7 @@ function SimConfigureContent() {
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" /> <DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" />
<h3 className="font-bold text-lg text-gray-900">{plan.name}</h3> <h3 className="font-bold text-lg text-gray-900">{plan.name}</h3>
{plan.hasFamilyDiscount && ( {plan.simHasFamilyDiscount && (
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1"> <span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1">
<UsersIcon className="h-3 w-3" /> <UsersIcon className="h-3 w-3" />
Family Discount Family Discount
@ -390,13 +390,13 @@ function SimConfigureContent() {
</div> </div>
<div className="flex items-center gap-4 text-sm text-gray-600 mb-2"> <div className="flex items-center gap-4 text-sm text-gray-600 mb-2">
<span> <span>
<strong>Data:</strong> {plan.dataSize} <strong>Data:</strong> {plan.simDataSize}
</span> </span>
<span> <span>
<strong>Type:</strong>{" "} <strong>Type:</strong>{" "}
{plan.planType === "DataSmsVoice" {(plan.simPlanType || "DataSmsVoice") === "DataSmsVoice"
? "Data + Voice" ? "Data + Voice"
: plan.planType === "DataOnly" : (plan.simPlanType || "DataSmsVoice") === "DataOnly"
? "Data Only" ? "Data Only"
: "Voice Only"} : "Voice Only"}
</span> </span>
@ -406,7 +406,7 @@ function SimConfigureContent() {
<div className="text-2xl font-bold text-blue-600"> <div className="text-2xl font-bold text-blue-600">
¥{plan.monthlyPrice?.toLocaleString()}/mo ¥{plan.monthlyPrice?.toLocaleString()}/mo
</div> </div>
{plan.hasFamilyDiscount && ( {plan.simHasFamilyDiscount && (
<div className="text-sm text-green-600 font-medium">Discounted Price</div> <div className="text-sm text-green-600 font-medium">Discounted Price</div>
)} )}
</div> </div>
@ -550,16 +550,16 @@ function SimConfigureContent() {
<div className="mb-6"> <div className="mb-6">
<StepHeader <StepHeader
stepNumber={3} stepNumber={3}
title={plan.planType === "DataOnly" ? "Add-ons" : "Voice Add-ons"} title={(plan.simPlanType || "DataSmsVoice") === "DataOnly" ? "Add-ons" : "Voice Add-ons"}
description={ description={
plan.planType === "DataOnly" (plan.simPlanType || "DataSmsVoice") === "DataOnly"
? "No add-ons available for data-only plans" ? "No add-ons available for data-only plans"
: "Enhance your voice services with these optional features" : "Enhance your voice services with these optional features"
} }
/> />
</div> </div>
{addons.length > 0 && plan.planType !== "DataOnly" ? ( {addons.length > 0 && (plan.simPlanType || "DataSmsVoice") !== "DataOnly" ? (
<AddonGroup <AddonGroup
addons={addons.map(addon => ({ addons={addons.map(addon => ({
...addon, ...addon,
@ -579,7 +579,7 @@ function SimConfigureContent() {
) : ( ) : (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-gray-600"> <p className="text-gray-600">
{plan.planType === "DataOnly" {(plan.simPlanType || "DataSmsVoice") === "DataOnly"
? "No add-ons are available for data-only plans." ? "No add-ons are available for data-only plans."
: "No add-ons are available for this plan."} : "No add-ons are available for this plan."}
</p> </p>
@ -685,7 +685,7 @@ function SimConfigureContent() {
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<h4 className="font-semibold text-gray-900">{plan.name}</h4> <h4 className="font-semibold text-gray-900">{plan.name}</h4>
<p className="text-sm text-gray-600">{plan.dataSize}</p> <p className="text-sm text-gray-600">{plan.simDataSize}</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="font-semibold text-gray-900"> <p className="font-semibold text-gray-900">
@ -788,7 +788,7 @@ function SimConfigureContent() {
{/* Receipt Footer */} {/* Receipt Footer */}
<div className="text-center mt-6 pt-4 border-t border-gray-200"> <div className="text-center mt-6 pt-4 border-t border-gray-200">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{plan.planType === "DataOnly" && "Data-only service (no voice features)"} {(plan.simPlanType || "DataSmsVoice") === "DataOnly" && "Data-only service (no voice features)"}
</p> </p>
</div> </div>
</div> </div>

View File

@ -41,8 +41,8 @@ function PlanTypeSection({
if (plans.length === 0) return null; if (plans.length === 0) return null;
// Separate regular and family plans // Separate regular and family plans
const regularPlans = plans.filter(p => !p.hasFamilyDiscount); const regularPlans = plans.filter(p => !p.simHasFamilyDiscount);
const familyPlans = plans.filter(p => p.hasFamilyDiscount); const familyPlans = plans.filter(p => p.simHasFamilyDiscount);
return ( return (
<div className="animate-in fade-in duration-500"> <div className="animate-in fade-in duration-500">
@ -89,7 +89,7 @@ function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) {
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<DevicePhoneMobileIcon className="h-4 w-4 text-blue-600" /> <DevicePhoneMobileIcon className="h-4 w-4 text-blue-600" />
<span className="font-bold text-sm text-gray-900">{plan.dataSize}</span> <span className="font-bold text-sm text-gray-900">{plan.simDataSize}</span>
</div> </div>
{isFamily && ( {isFamily && (
<div className="flex items-center gap-1 mb-1"> <div className="flex items-center gap-1 mb-1">
@ -147,7 +147,7 @@ export default function SimPlansPage() {
if (mounted) { if (mounted) {
setPlans(plansData); setPlans(plansData);
// Check if any plans have family discount (indicates user has existing SIM) // 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) { } catch (e) {
if (mounted) { if (mounted) {
@ -196,7 +196,8 @@ export default function SimPlansPage() {
// Group plans by type // Group plans by type
const plansByType: PlansByType = plans.reduce( const plansByType: PlansByType = plans.reduce(
(acc, plan) => { (acc, plan) => {
acc[plan.planType].push(plan); const type = plan.simPlanType || "DataSmsVoice";
acc[type].push(plan);
return acc; return acc;
}, },
{ {

View File

@ -8,7 +8,7 @@ interface AddonItem {
sku: string; sku: string;
description: string; description: string;
monthlyPrice?: number; monthlyPrice?: number;
activationPrice?: number; oneTimePrice?: number;
isBundledAddon?: boolean; isBundledAddon?: boolean;
bundledAddonId?: string; bundledAddonId?: string;
displayOrder?: number; displayOrder?: number;
@ -19,7 +19,7 @@ interface AddonGroup {
name: string; name: string;
description: string; description: string;
monthlyPrice?: number; monthlyPrice?: number;
activationPrice?: number; oneTimePrice?: number;
skus: string[]; skus: string[];
isBundled: boolean; isBundled: boolean;
} }
@ -55,7 +55,7 @@ export function AddonGroup({
if (bundledPartner && !processedAddonIds.has(bundledPartner.id)) { if (bundledPartner && !processedAddonIds.has(bundledPartner.id)) {
// Create a combined group // Create a combined group
const monthlyAddon = addon.monthlyPrice ? addon : bundledPartner; const monthlyAddon = addon.monthlyPrice ? addon : bundledPartner;
const activationAddon = addon.activationPrice ? addon : bundledPartner; const activationAddon = addon.oneTimePrice ? addon : bundledPartner;
// Generate clean name and description // Generate clean name and description
const cleanName = monthlyAddon.name const cleanName = monthlyAddon.name
@ -68,7 +68,7 @@ export function AddonGroup({
name: bundleName, name: bundleName,
description: `${bundleName} (installation included)`, description: `${bundleName} (installation included)`,
monthlyPrice: monthlyAddon.monthlyPrice, monthlyPrice: monthlyAddon.monthlyPrice,
activationPrice: activationAddon.activationPrice, oneTimePrice: activationAddon.oneTimePrice,
skus: [addon.sku, bundledPartner.sku], skus: [addon.sku, bundledPartner.sku],
isBundled: true, isBundled: true,
}); });
@ -82,7 +82,7 @@ export function AddonGroup({
name: addon.name, name: addon.name,
description: addon.description, description: addon.description,
monthlyPrice: addon.monthlyPrice, monthlyPrice: addon.monthlyPrice,
activationPrice: addon.activationPrice, oneTimePrice: addon.oneTimePrice,
skus: [addon.sku], skus: [addon.sku],
isBundled: false, isBundled: false,
}); });
@ -95,7 +95,7 @@ export function AddonGroup({
name: addon.name, name: addon.name,
description: addon.description, description: addon.description,
monthlyPrice: addon.monthlyPrice, monthlyPrice: addon.monthlyPrice,
activationPrice: addon.activationPrice, oneTimePrice: addon.oneTimePrice,
skus: [addon.sku], skus: [addon.sku],
isBundled: false, isBundled: false,
}); });
@ -155,16 +155,16 @@ export function AddonGroup({
</div> </div>
<p className="text-sm text-gray-600 mt-1">{addonGroup.description}</p> <p className="text-sm text-gray-600 mt-1">{addonGroup.description}</p>
<div className="flex flex-wrap gap-4 mt-2"> <div className="flex flex-wrap gap-4 mt-2">
{addonGroup.monthlyPrice && ( {addonGroup.monthlyPrice && (
<span className="text-sm font-semibold text-blue-600"> <span className="text-sm font-semibold text-blue-600">
¥{addonGroup.monthlyPrice.toLocaleString()}/month ¥{addonGroup.monthlyPrice.toLocaleString()}/month
</span> </span>
)} )}
{addonGroup.activationPrice && ( {addonGroup.oneTimePrice && (
<span className="text-sm font-semibold text-orange-600"> <span className="text-sm font-semibold text-orange-600">
Activation: ¥{addonGroup.activationPrice.toLocaleString()} Activation: ¥{addonGroup.oneTimePrice.toLocaleString()}
</span> </span>
)} )}
</div> </div>
{addonGroup.isBundled && ( {addonGroup.isBundled && (
<div className="text-xs text-green-600 mt-1 flex items-center gap-1"> <div className="text-xs text-green-600 mt-1 flex items-center gap-1">

View File

@ -3,7 +3,8 @@ import Link from "next/link";
interface OrderItem { interface OrderItem {
name: string; name: string;
price?: number; monthlyPrice?: number;
oneTimePrice?: number;
billingCycle?: string; billingCycle?: string;
sku?: string; sku?: string;
} }
@ -12,7 +13,7 @@ interface OrderSummaryProps {
// Plan details // Plan details
plan: { plan: {
name: string; name: string;
tier?: string; internetPlanTier?: string;
monthlyPrice?: number; monthlyPrice?: number;
}; };
@ -78,7 +79,7 @@ export function OrderSummary({
<span className="text-gray-600">Plan:</span> <span className="text-gray-600">Plan:</span>
<span className="font-medium"> <span className="font-medium">
{plan.name} {plan.name}
{plan.tier && ` (${plan.tier})`} {plan.internetPlanTier && ` (${plan.internetPlanTier})`}
</span> </span>
</div> </div>
@ -107,7 +108,9 @@ export function OrderSummary({
<div className="text-sm font-medium text-gray-700 mb-1">Monthly Costs:</div> <div className="text-sm font-medium text-gray-700 mb-1">Monthly Costs:</div>
{plan.monthlyPrice && ( {plan.monthlyPrice && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-gray-600">Base Plan {plan.tier && `(${plan.tier})`}:</span> <span className="text-gray-600">
Base Plan {plan.internetPlanTier && `(${plan.internetPlanTier})`}:
</span>
<span className="font-medium">¥{plan.monthlyPrice.toLocaleString()}</span> <span className="font-medium">¥{plan.monthlyPrice.toLocaleString()}</span>
</div> </div>
)} )}
@ -115,10 +118,12 @@ export function OrderSummary({
{selectedAddons.map( {selectedAddons.map(
(addon, index) => (addon, index) =>
addon.billingCycle === "Monthly" && addon.billingCycle === "Monthly" &&
addon.price && ( typeof addon.monthlyPrice === "number" && (
<div key={index} className="flex justify-between text-sm"> <div key={index} className="flex justify-between text-sm">
<span className="text-gray-600">{addon.name}:</span> <span className="text-gray-600">{addon.name}:</span>
<span className="font-medium">¥{addon.price.toLocaleString()}/month</span> <span className="font-medium">
¥{addon.monthlyPrice.toLocaleString()}/month
</span>
</div> </div>
) )
)} )}
@ -143,17 +148,21 @@ export function OrderSummary({
{activationFees.map((fee, index) => ( {activationFees.map((fee, index) => (
<div key={index} className="flex justify-between text-sm"> <div key={index} className="flex justify-between text-sm">
<span className="text-gray-600">{fee.name}:</span> <span className="text-gray-600">{fee.name}:</span>
<span className="font-medium">¥{fee.price?.toLocaleString() || 0}</span> <span className="font-medium">
¥{(fee.oneTimePrice ?? fee.monthlyPrice ?? 0).toLocaleString()}
</span>
</div> </div>
))} ))}
{selectedAddons.map( {selectedAddons.map(
(addon, index) => (addon, index) =>
addon.billingCycle !== "Monthly" && addon.billingCycle !== "Monthly" &&
addon.price && ( typeof addon.oneTimePrice === "number" && (
<div key={index} className="flex justify-between text-sm"> <div key={index} className="flex justify-between text-sm">
<span className="text-gray-600">{addon.name}:</span> <span className="text-gray-600">{addon.name}:</span>
<span className="font-medium">¥{addon.price.toLocaleString()}</span> <span className="font-medium">
¥{addon.oneTimePrice.toLocaleString()}
</span>
</div> </div>
) )
)} )}
@ -185,7 +194,9 @@ export function OrderSummary({
{activationFees.map((fee, index) => ( {activationFees.map((fee, index) => (
<div key={index} className="flex justify-between"> <div key={index} className="flex justify-between">
<span className="text-gray-600">{fee.name}</span> <span className="text-gray-600">{fee.name}</span>
<span className="font-medium">¥{fee.price?.toLocaleString()} one-time</span> <span className="font-medium">
¥{(fee.oneTimePrice ?? fee.monthlyPrice ?? 0).toLocaleString()} one-time
</span>
</div> </div>
))} ))}
@ -194,7 +205,10 @@ export function OrderSummary({
<div key={index} className="flex justify-between"> <div key={index} className="flex justify-between">
<span className="text-gray-600">{addon.name}</span> <span className="text-gray-600">{addon.name}</span>
<span className="font-medium"> <span className="font-medium">
¥{addon.price?.toLocaleString()} ¥{(addon.billingCycle === "Monthly"
? addon.monthlyPrice ?? addon.oneTimePrice ?? 0
: addon.oneTimePrice ?? addon.monthlyPrice ?? 0
).toLocaleString()}
{addon.billingCycle === "Monthly" ? "/mo" : " one-time"} {addon.billingCycle === "Monthly" ? "/mo" : " one-time"}
</span> </span>
</div> </div>

View File

@ -3,12 +3,12 @@
import { AnimatedCard } from "@/components/ui"; import { AnimatedCard } from "@/components/ui";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CurrencyYenIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; 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"; import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
interface InternetPlanCardProps { interface InternetPlanCardProps {
plan: InternetProduct; plan: InternetPlan;
installations: InternetProduct[]; installations: InternetInstallation[];
disabled?: boolean; disabled?: boolean;
disabledReason?: string; disabledReason?: string;
} }
@ -19,14 +19,16 @@ export function InternetPlanCard({ plan, installations, disabled, disabledReason
const isPlatinum = tier === "Platinum"; const isPlatinum = tier === "Platinum";
const isSilver = tier === "Silver"; const isSilver = tier === "Silver";
const minInstallationPrice = installations.length const installationPrices = installations
? Math.min( .map(installation =>
...installations.map(installation => installation.billingCycle === "Monthly"
installation.billingCycle === "Monthly" ? getMonthlyPrice(installation)
? getMonthlyPrice(installation) : getOneTimePrice(installation)
: getOneTimePrice(installation) )
) .filter(price => price > 0);
)
const minInstallationPrice = installationPrices.length
? Math.min(...installationPrices)
: 0; : 0;
const getBorderClass = () => { const getBorderClass = () => {
@ -75,13 +77,15 @@ export function InternetPlanCard({ plan, installations, disabled, disabledReason
</div> </div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{plan.name}</h3> <h3 className="text-xl font-semibold text-gray-900 mb-2">{plan.name}</h3>
<p className="text-gray-600 text-sm mb-4">{plan.tierDescription || plan.description}</p> <p className="text-gray-600 text-sm mb-4">
{plan.catalogMetadata.tierDescription || plan.description}
</p>
<div className="mb-6 flex-grow"> <div className="mb-6 flex-grow">
<h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4> <h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4>
<ul className="space-y-2 text-sm text-gray-700"> <ul className="space-y-2 text-sm text-gray-700">
{plan.features && plan.features.length > 0 ? ( {plan.catalogMetadata.features && plan.catalogMetadata.features.length > 0 ? (
plan.features.map((feature, index) => ( plan.catalogMetadata.features.map((feature, index) => (
<li key={index} className="flex items-start"> <li key={index} className="flex items-start">
<span className="text-green-600 mr-2"></span> <span className="text-green-600 mr-2"></span>
{feature} {feature}

View File

@ -1,86 +1,52 @@
/** import type {
* Frontend catalog types - Clean, focused, production-ready InternetProduct,
* NO NAME MATCHING - Direct product access only SimProduct,
* NO HARDCODED DATA - Only structured Salesforce data VpnProduct,
*/ ProductWithPricing,
} from "@customer-portal/domain";
// Core product interface // API shapes returned by the catalog endpoints. We model them as extensions of the
export interface BaseProduct { // unified domain types so field names stay aligned with Salesforce structures.
id: string; export type InternetPlan = InternetProduct & {
name: string; catalogMetadata: {
sku: string; tierDescription: string;
description: string; features: string[];
} isRecommended: boolean;
};
};
// Internet product types export type InternetInstallation = InternetProduct & {
export interface InternetPlan extends BaseProduct { catalogMetadata: {
tier: "Silver" | "Gold" | "Platinum"; installationTerm: "One-time" | "12-Month" | "24-Month";
offeringType: string; };
monthlyPrice?: number; };
features: string[];
tierDescription: string;
isRecommended: boolean;
}
export interface InternetInstallation extends BaseProduct { export type InternetAddon = InternetProduct & {
type: "One-time" | "12-Month" | "24-Month"; catalogMetadata: {
price?: number; addonCategory: "hikari-denwa-service" | "hikari-denwa-installation" | "other";
billingCycle: string; autoAdd: boolean;
displayOrder: number; requiredWith: string[];
} };
};
export interface InternetAddon extends BaseProduct { export type SimPlan = SimProduct;
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;
}
// SIM product types export type SimActivationFee = SimProduct & {
export interface SimPlan extends BaseProduct { catalogMetadata: {
dataSize: string; isDefault: boolean;
planType: "DataOnly" | "DataSmsVoice" | "VoiceOnly"; };
hasFamilyDiscount: boolean; };
monthlyPrice?: number;
features: string[];
}
export interface SimActivationFee extends BaseProduct { export type SimAddon = SimProduct;
price: number;
isDefault: boolean;
}
export interface SimAddon extends BaseProduct { export type VpnPlan = VpnProduct;
price: number; export type VpnActivationFee = VpnProduct;
billingCycle: string;
bundledAddonId?: string; // ID of the bundled product
isBundledAddon?: boolean; // Whether this is part of a bundle
displayOrder?: number;
}
// 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 ProductType = "Internet" | "SIM" | "VPN";
export type ItemClass = "Service" | "Installation" | "Add-on" | "Activation"; export type ItemClass = "Service" | "Installation" | "Add-on" | "Activation";
export type BillingCycle = "Monthly" | "Onetime"; export type BillingCycle = "Monthly" | "Onetime";
export type PlanTier = "Silver" | "Gold" | "Platinum"; export type PlanTier = "Silver" | "Gold" | "Platinum";
// Frontend-specific types
export interface CheckoutState { export interface CheckoutState {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@ -102,7 +68,6 @@ export interface OrderTotals {
oneTimeTotal: number; oneTimeTotal: number;
} }
// Production-ready utility functions - only structured Salesforce data
export const buildInternetOrderItems = ( export const buildInternetOrderItems = (
plan: InternetPlan, plan: InternetPlan,
addons: InternetAddon[], addons: InternetAddon[],
@ -114,29 +79,27 @@ export const buildInternetOrderItems = (
): OrderItem[] => { ): OrderItem[] => {
const items: OrderItem[] = []; const items: OrderItem[] = [];
// Add main plan
items.push({ items.push({
name: plan.name, name: plan.name,
sku: plan.sku, sku: plan.sku,
monthlyPrice: plan.monthlyPrice, monthlyPrice: plan.monthlyPrice,
oneTimePrice: plan.oneTimePrice,
type: "service", type: "service",
}); });
// Add installation if selected (by SKU)
if (selections.installationSku) { if (selections.installationSku) {
const installation = installations.find(inst => inst.sku === selections.installationSku); const installation = installations.find(inst => inst.sku === selections.installationSku);
if (installation) { if (installation) {
items.push({ items.push({
name: installation.name, name: installation.name,
sku: installation.sku, sku: installation.sku,
monthlyPrice: installation.billingCycle === "Monthly" ? installation.price : undefined, monthlyPrice: installation.billingCycle === "Monthly" ? installation.monthlyPrice : undefined,
oneTimePrice: installation.billingCycle !== "Monthly" ? installation.price : undefined, oneTimePrice: installation.billingCycle !== "Monthly" ? installation.oneTimePrice : undefined,
type: "installation", type: "installation",
}); });
} }
} }
// Add selected addons (by SKU like SIM flow)
if (selections.addonSkus && selections.addonSkus.length > 0) { if (selections.addonSkus && selections.addonSkus.length > 0) {
selections.addonSkus.forEach(addonSku => { selections.addonSkus.forEach(addonSku => {
const selectedAddon = addons.find(addon => addon.sku === addonSku); const selectedAddon = addons.find(addon => addon.sku === addonSku);
@ -145,7 +108,7 @@ export const buildInternetOrderItems = (
name: selectedAddon.name, name: selectedAddon.name,
sku: selectedAddon.sku, sku: selectedAddon.sku,
monthlyPrice: selectedAddon.monthlyPrice, monthlyPrice: selectedAddon.monthlyPrice,
oneTimePrice: selectedAddon.activationPrice, oneTimePrice: selectedAddon.oneTimePrice,
type: "addon", type: "addon",
}); });
} }
@ -160,32 +123,29 @@ export const buildSimOrderItems = (
activationFees: SimActivationFee[], activationFees: SimActivationFee[],
addons: SimAddon[], addons: SimAddon[],
selections: { selections: {
addonSkus?: string[]; // Support multiple add-ons addonSkus?: string[];
} }
): OrderItem[] => { ): OrderItem[] => {
const items: OrderItem[] = []; const items: OrderItem[] = [];
// Add main plan
items.push({ items.push({
name: plan.name, name: plan.name,
sku: plan.sku, // This should be the actual StockKeepingUnit from Salesforce sku: plan.sku,
monthlyPrice: plan.monthlyPrice, monthlyPrice: plan.monthlyPrice,
oneTimePrice: plan.oneTimePrice,
type: "service", type: "service",
}); });
// Add activation fee const activationFee = activationFees.find(fee => fee.catalogMetadata.isDefault) || activationFees[0];
const activationFee = activationFees[0];
if (activationFee) { if (activationFee) {
items.push({ items.push({
name: activationFee.name, name: activationFee.name,
sku: activationFee.sku, sku: activationFee.sku,
oneTimePrice: activationFee.price, oneTimePrice: activationFee.oneTimePrice ?? activationFee.unitPrice,
type: "addon", type: "addon",
}); });
} }
// Add selected addons from Salesforce (multiple allowed)
if (selections.addonSkus && selections.addonSkus.length > 0) { if (selections.addonSkus && selections.addonSkus.length > 0) {
selections.addonSkus.forEach(addonSku => { selections.addonSkus.forEach(addonSku => {
const selectedAddon = addons.find(addon => addon.sku === addonSku); const selectedAddon = addons.find(addon => addon.sku === addonSku);
@ -193,12 +153,10 @@ export const buildSimOrderItems = (
items.push({ items.push({
name: selectedAddon.name, name: selectedAddon.name,
sku: selectedAddon.sku, sku: selectedAddon.sku,
monthlyPrice: selectedAddon.billingCycle === "Monthly" ? selectedAddon.price : undefined, monthlyPrice: selectedAddon.billingCycle === "Monthly" ? selectedAddon.monthlyPrice ?? selectedAddon.unitPrice : undefined,
oneTimePrice: selectedAddon.billingCycle !== "Monthly" ? selectedAddon.price : undefined, oneTimePrice: selectedAddon.billingCycle !== "Monthly" ? selectedAddon.oneTimePrice ?? selectedAddon.unitPrice : undefined,
type: "addon", type: "addon",
}); });
} else {
// Addon SKU not found - skip silently in production
} }
}); });
} }

View File

@ -13,7 +13,7 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { useInternetCatalog } from "@/features/catalog/hooks"; import { useInternetCatalog } from "@/features/catalog/hooks";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; 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 { getMonthlyPrice } from "../utils/pricing";
import { LoadingCard, Skeleton, LoadingTable } from "@/components/ui/loading-skeleton"; import { LoadingCard, Skeleton, LoadingTable } from "@/components/ui/loading-skeleton";
import { AnimatedCard } from "@/components/ui"; import { AnimatedCard } from "@/components/ui";
@ -24,8 +24,8 @@ import { AlertBanner } from "@/components/common/AlertBanner";
export function InternetPlansContainer() { export function InternetPlansContainer() {
const { data, isLoading, error } = useInternetCatalog(); const { data, isLoading, error } = useInternetCatalog();
const plans: InternetProduct[] = data?.plans || []; const plans: InternetPlan[] = data?.plans || [];
const installations: InternetProduct[] = data?.installations || []; const installations: InternetInstallation[] = data?.installations || [];
const [eligibility, setEligibility] = useState<string>(""); const [eligibility, setEligibility] = useState<string>("");
const { data: activeSubs } = useActiveSubscriptions(); const { data: activeSubs } = useActiveSubscriptions();
const hasActiveInternet = Array.isArray(activeSubs) const hasActiveInternet = Array.isArray(activeSubs)
@ -44,16 +44,16 @@ export function InternetPlansContainer() {
} }
}, [plans]); }, [plans]);
const getEligibilityIcon = (offeringType: string) => { const getEligibilityIcon = (offeringType?: string) => {
const lower = offeringType.toLowerCase(); const lower = (offeringType || "").toLowerCase();
if (lower.includes("home")) return <HomeIcon className="h-5 w-5" />; if (lower.includes("home")) return <HomeIcon className="h-5 w-5" />;
if (lower.includes("apartment")) if (lower.includes("apartment"))
return <BuildingOfficeIcon className="h-5 w-5" />; return <BuildingOfficeIcon className="h-5 w-5" />;
return <HomeIcon className="h-5 w-5" />; return <HomeIcon className="h-5 w-5" />;
}; };
const getEligibilityColor = (offeringType: string) => { const getEligibilityColor = (offeringType?: string) => {
const lower = offeringType.toLowerCase(); const lower = (offeringType || "").toLowerCase();
if (lower.includes("home")) if (lower.includes("home"))
return "text-blue-600 bg-blue-50 border-blue-200"; return "text-blue-600 bg-blue-50 border-blue-200";
if (lower.includes("apartment")) if (lower.includes("apartment"))
@ -186,4 +186,3 @@ export function InternetPlansContainer() {
} }
// InternetPlanCard extracted to components/internet/InternetPlanCard // InternetPlanCard extracted to components/internet/InternetPlanCard

View File

@ -112,12 +112,6 @@ export interface ProductWithPricing extends BaseProduct {
unitPrice?: number; // PricebookEntry.UnitPrice unitPrice?: number; // PricebookEntry.UnitPrice
monthlyPrice?: number; // UnitPrice if billingCycle === "Monthly" monthlyPrice?: number; // UnitPrice if billingCycle === "Monthly"
oneTimePrice?: number; // UnitPrice if billingCycle === "Onetime" 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 // Internet-specific product fields
@ -133,10 +127,6 @@ export interface InternetProduct extends ProductWithPricing {
features?: string[]; features?: string[];
tierDescription?: string; tierDescription?: string;
isRecommended?: boolean; isRecommended?: boolean;
/** @deprecated Use internetPlanTier. */
tier?: InternetProduct["internetPlanTier"];
/** @deprecated Use internetOfferingType. */
offeringType?: string;
} }
// SIM-specific product fields // SIM-specific product fields
@ -150,12 +140,6 @@ export interface SimProduct extends ProductWithPricing {
// UI-specific fields // UI-specific fields
features?: string[]; features?: string[];
/** @deprecated Use simDataSize. */
dataSize?: string;
/** @deprecated Use simPlanType. */
planType?: SimProduct["simPlanType"];
/** @deprecated Use simHasFamilyDiscount. */
hasFamilyDiscount?: boolean;
} }
// VPN-specific product fields // VPN-specific product fields
@ -164,8 +148,6 @@ export interface VpnProduct extends ProductWithPricing {
// VPN-specific fields // VPN-specific fields
vpnRegion?: string; // VPN_Region__c vpnRegion?: string; // VPN_Region__c
/** @deprecated Use vpnRegion. */
region?: string;
} }
// Generic product for "Other" category // Generic product for "Other" category
@ -339,10 +321,6 @@ export function fromSalesforceProduct2(
| InternetProduct["internetPlanTier"] | InternetProduct["internetPlanTier"]
| undefined, | undefined,
internetOfferingType: coerceString(readField(sfProduct, fieldMap.internetOfferingType)) || 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; } satisfies InternetProduct;
case "SIM": case "SIM":
@ -351,16 +329,12 @@ export function fromSalesforceProduct2(
simDataSize: coerceString(readField(sfProduct, fieldMap.simDataSize)) || undefined, simDataSize: coerceString(readField(sfProduct, fieldMap.simDataSize)) || undefined,
simPlanType: normalizeSimPlanType(readField(sfProduct, fieldMap.simPlanType)) || undefined, simPlanType: normalizeSimPlanType(readField(sfProduct, fieldMap.simPlanType)) || undefined,
simHasFamilyDiscount: normalizeBoolean(readField(sfProduct, fieldMap.simHasFamilyDiscount)), 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; } satisfies SimProduct;
case "VPN": case "VPN":
return { return {
...baseProduct, ...baseProduct,
vpnRegion: coerceString(readField(sfProduct, fieldMap.vpnRegion)) || undefined, vpnRegion: coerceString(readField(sfProduct, fieldMap.vpnRegion)) || undefined,
region: coerceString(readField(sfProduct, fieldMap.vpnRegion)) || undefined,
} satisfies VpnProduct; } satisfies VpnProduct;
default: default: