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:
parent
a95ec60859
commit
52adc29016
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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's
|
<span className="text-green-600 mr-2">✓</span>1 NTT Optical Fiber (Flet'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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user