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 { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { InternetCatalogService } from "./services/internet-catalog.service";
|
||||
import { SimCatalogService } from "./services/sim-catalog.service";
|
||||
import { VpnCatalogService } from "./services/vpn-catalog.service";
|
||||
import {
|
||||
InternetProduct,
|
||||
SimProduct,
|
||||
VpnProduct,
|
||||
} from "@customer-portal/domain";
|
||||
InternetAddonCatalogItem,
|
||||
InternetCatalogService,
|
||||
InternetInstallationCatalogItem,
|
||||
InternetPlanCatalogItem,
|
||||
} from "./services/internet-catalog.service";
|
||||
import {
|
||||
SimActivationFeeCatalogItem,
|
||||
SimCatalogService,
|
||||
} from "./services/sim-catalog.service";
|
||||
import { VpnCatalogService } from "./services/vpn-catalog.service";
|
||||
import { SimProduct, VpnProduct } from "@customer-portal/domain";
|
||||
|
||||
@ApiTags("catalog")
|
||||
@Controller("catalog")
|
||||
@ -21,7 +25,9 @@ export class CatalogController {
|
||||
|
||||
@Get("internet/plans")
|
||||
@ApiOperation({ summary: "Get Internet plans filtered by customer eligibility" })
|
||||
async getInternetPlans(@Request() req: { user: { id: string } }): Promise<InternetProduct[]> {
|
||||
async getInternetPlans(
|
||||
@Request() req: { user: { id: string } }
|
||||
): Promise<InternetPlanCatalogItem[]> {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
// Fallback to all plans if no user context
|
||||
@ -32,13 +38,13 @@ export class CatalogController {
|
||||
|
||||
@Get("internet/addons")
|
||||
@ApiOperation({ summary: "Get Internet add-ons" })
|
||||
async getInternetAddons(): Promise<InternetProduct[]> {
|
||||
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
|
||||
return this.internetCatalog.getAddons();
|
||||
}
|
||||
|
||||
@Get("internet/installations")
|
||||
@ApiOperation({ summary: "Get Internet installations" })
|
||||
async getInternetInstallations(): Promise<InternetProduct[]> {
|
||||
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
||||
return this.internetCatalog.getInstallations();
|
||||
}
|
||||
|
||||
@ -49,14 +55,14 @@ export class CatalogController {
|
||||
if (!userId) {
|
||||
// Fallback to all regular plans if no user context
|
||||
const allPlans = await this.simCatalog.getPlans();
|
||||
return allPlans.filter(plan => !plan.hasFamilyDiscount);
|
||||
return allPlans.filter(plan => !plan.simHasFamilyDiscount);
|
||||
}
|
||||
return this.simCatalog.getPlansForUser(userId);
|
||||
}
|
||||
|
||||
@Get("sim/activation-fees")
|
||||
@ApiOperation({ summary: "Get SIM activation fees" })
|
||||
async getSimActivationFees(): Promise<SimProduct[]> {
|
||||
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||
return this.simCatalog.getActivationFees();
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { BaseCatalogService } from "./base-catalog.service";
|
||||
import { InternetProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||
import { MappingsService } from "../../id-mappings/mappings.service";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
@ -12,18 +11,39 @@ interface SalesforceAccount {
|
||||
Internet_Eligibility__c?: string;
|
||||
}
|
||||
|
||||
export type InternetPlanCatalogItem = InternetProduct & {
|
||||
catalogMetadata: {
|
||||
tierDescription: string;
|
||||
features: string[];
|
||||
isRecommended: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type InternetInstallationCatalogItem = InternetProduct & {
|
||||
catalogMetadata: {
|
||||
installationTerm: "One-time" | "12-Month" | "24-Month";
|
||||
};
|
||||
};
|
||||
|
||||
export type InternetAddonCatalogItem = InternetProduct & {
|
||||
catalogMetadata: {
|
||||
addonCategory: "hikari-denwa-service" | "hikari-denwa-installation" | "other";
|
||||
autoAdd: boolean;
|
||||
requiredWith: string[];
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class InternetCatalogService extends BaseCatalogService {
|
||||
constructor(
|
||||
sf: SalesforceConnection,
|
||||
@Inject(Logger) logger: Logger,
|
||||
private salesforceService: SalesforceService,
|
||||
private mappingsService: MappingsService
|
||||
) {
|
||||
super(sf, logger);
|
||||
}
|
||||
|
||||
async getPlans(): Promise<InternetProduct[]> {
|
||||
async getPlans(): Promise<InternetPlanCatalogItem[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildCatalogServiceQuery("Internet", [
|
||||
fields.product.internetPlanTier,
|
||||
@ -44,15 +64,17 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
|
||||
return {
|
||||
...product,
|
||||
tierDescription: tierData.tierDescription,
|
||||
features: tierData.features,
|
||||
isRecommended: product.internetPlanTier === "Gold",
|
||||
catalogMetadata: {
|
||||
tierDescription: tierData.tierDescription,
|
||||
features: tierData.features,
|
||||
isRecommended: product.internetPlanTier === "Gold",
|
||||
},
|
||||
description: product.description ?? tierData.description,
|
||||
} satisfies InternetProduct;
|
||||
} satisfies InternetPlanCatalogItem;
|
||||
});
|
||||
}
|
||||
|
||||
async getInstallations(): Promise<InternetProduct[]> {
|
||||
async getInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildProductQuery("Internet", "Installation", [
|
||||
fields.product.billingCycle,
|
||||
@ -72,22 +94,19 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
) as InternetProduct;
|
||||
|
||||
const installationType = this.inferInstallationTypeFromSku(product.sku);
|
||||
const priceValue =
|
||||
product.billingCycle === "Monthly"
|
||||
? product.monthlyPrice ?? product.unitPrice ?? 0
|
||||
: product.oneTimePrice ?? product.unitPrice ?? 0;
|
||||
|
||||
return {
|
||||
...product,
|
||||
type: installationType,
|
||||
price: priceValue,
|
||||
catalogMetadata: {
|
||||
installationTerm: installationType,
|
||||
},
|
||||
displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0),
|
||||
} satisfies InternetProduct;
|
||||
} satisfies InternetInstallationCatalogItem;
|
||||
})
|
||||
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||
}
|
||||
|
||||
async getAddons(): Promise<InternetProduct[]> {
|
||||
async getAddons(): Promise<InternetAddonCatalogItem[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildProductQuery("Internet", "Add-on", [
|
||||
fields.product.billingCycle,
|
||||
@ -109,19 +128,16 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
) as InternetProduct;
|
||||
|
||||
const addonType = this.inferAddonTypeFromSku(product.sku);
|
||||
const activationPrice =
|
||||
product.billingCycle === "Onetime"
|
||||
? product.oneTimePrice ?? product.unitPrice ?? 0
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...product,
|
||||
type: addonType,
|
||||
activationPrice,
|
||||
autoAdd: false,
|
||||
requiredWith: [],
|
||||
catalogMetadata: {
|
||||
addonCategory: addonType,
|
||||
autoAdd: false,
|
||||
requiredWith: [],
|
||||
},
|
||||
displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0),
|
||||
} satisfies InternetProduct;
|
||||
} satisfies InternetAddonCatalogItem;
|
||||
})
|
||||
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||
}
|
||||
@ -135,7 +151,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
return { plans, installations, addons };
|
||||
}
|
||||
|
||||
async getPlansForUser(userId: string): Promise<InternetProduct[]> {
|
||||
async getPlansForUser(userId: string): Promise<InternetPlanCatalogItem[]> {
|
||||
try {
|
||||
// Get all plans first
|
||||
const allPlans = await this.getPlans();
|
||||
@ -162,7 +178,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
|
||||
if (!eligibility) {
|
||||
this.logger.log(`No eligibility field for user ${userId}, filtering to Home 1G plans only`);
|
||||
const homeGPlans = allPlans.filter(plan => plan.offeringType === "Home 1G");
|
||||
const homeGPlans = allPlans.filter(plan => plan.internetOfferingType === "Home 1G");
|
||||
return homeGPlans;
|
||||
}
|
||||
|
||||
@ -171,7 +187,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
const isEligible = this.checkPlanEligibility(plan, eligibility);
|
||||
if (!isEligible) {
|
||||
this.logger.debug(
|
||||
`Plan ${plan.name} (${plan.tier}) not eligible for user ${userId} with eligibility: ${eligibility}`
|
||||
`Plan ${plan.name} (${plan.internetPlanTier ?? "Unknown"}) not eligible for user ${userId} with eligibility: ${eligibility}`
|
||||
);
|
||||
}
|
||||
return isEligible;
|
||||
@ -190,10 +206,10 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
}
|
||||
}
|
||||
|
||||
private checkPlanEligibility(plan: InternetProduct, eligibility: string): boolean {
|
||||
// Simple match: user's eligibility field must equal plan's offering type
|
||||
// e.g., eligibility "Home 1G" matches plan.offeringType "Home 1G"
|
||||
return plan.offeringType === eligibility;
|
||||
private checkPlanEligibility(plan: InternetPlanCatalogItem, eligibility: string): boolean {
|
||||
// Simple match: user's eligibility field must equal plan's Salesforce offering type
|
||||
// e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G"
|
||||
return plan.internetOfferingType === eligibility;
|
||||
}
|
||||
|
||||
private getTierData(tier: string) {
|
||||
@ -239,4 +255,35 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
return tierData[tier] || tierData["Silver"];
|
||||
}
|
||||
|
||||
private inferInstallationTypeFromSku(sku: string): "One-time" | "12-Month" | "24-Month" {
|
||||
const upperSku = sku.toUpperCase();
|
||||
if (upperSku.includes("12M") || upperSku.includes("12-MONTH")) {
|
||||
return "12-Month";
|
||||
}
|
||||
if (upperSku.includes("24M") || upperSku.includes("24-MONTH")) {
|
||||
return "24-Month";
|
||||
}
|
||||
return "One-time";
|
||||
}
|
||||
|
||||
private inferAddonTypeFromSku(
|
||||
sku: string
|
||||
): "hikari-denwa-service" | "hikari-denwa-installation" | "other" {
|
||||
const upperSku = sku.toUpperCase();
|
||||
if (
|
||||
upperSku.includes("DENWA") ||
|
||||
upperSku.includes("HOME-PHONE") ||
|
||||
upperSku.includes("PHONE")
|
||||
) {
|
||||
if (
|
||||
upperSku.includes("INSTALL") ||
|
||||
upperSku.includes("SETUP") ||
|
||||
upperSku.includes("ACTIVATION")
|
||||
) {
|
||||
return "hikari-denwa-installation";
|
||||
}
|
||||
return "hikari-denwa-service";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,22 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { BaseCatalogService } from "./base-catalog.service";
|
||||
import { SimProduct, fromSalesforceProduct2, SalesforceProduct2Record } from "@customer-portal/domain";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||
import { MappingsService } from "../../id-mappings/mappings.service";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsConnectionService } from "@bff/integrations/whmcs/services/whmcs-connection.service";
|
||||
|
||||
export type SimActivationFeeCatalogItem = SimProduct & {
|
||||
catalogMetadata: {
|
||||
isDefault: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SimCatalogService extends BaseCatalogService {
|
||||
constructor(
|
||||
sf: SalesforceConnection,
|
||||
@Inject(Logger) logger: Logger,
|
||||
private salesforceService: SalesforceService,
|
||||
private mappingsService: MappingsService,
|
||||
private whmcs: WhmcsConnectionService
|
||||
) {
|
||||
@ -39,16 +43,13 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
|
||||
return {
|
||||
...product,
|
||||
dataSize: product.simDataSize || "Unknown",
|
||||
planType: product.simPlanType,
|
||||
hasFamilyDiscount: product.simHasFamilyDiscount || false,
|
||||
description: product.name,
|
||||
features: product.features ?? [],
|
||||
} satisfies SimProduct;
|
||||
});
|
||||
}
|
||||
|
||||
async getActivationFees(): Promise<SimProduct[]> {
|
||||
async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildProductQuery("SIM", "Activation", []);
|
||||
const records = await this.executeQuery(soql, "SIM Activation Fees");
|
||||
@ -61,14 +62,13 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
fields.product
|
||||
) as SimProduct;
|
||||
|
||||
const priceValue = product.oneTimePrice ?? product.unitPrice ?? 0;
|
||||
|
||||
return {
|
||||
...product,
|
||||
price: priceValue,
|
||||
description: product.name,
|
||||
isDefault: true,
|
||||
} satisfies SimProduct;
|
||||
catalogMetadata: {
|
||||
isDefault: true,
|
||||
},
|
||||
} satisfies SimActivationFeeCatalogItem;
|
||||
});
|
||||
}
|
||||
|
||||
@ -93,10 +93,6 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
|
||||
return {
|
||||
...product,
|
||||
price:
|
||||
product.billingCycle === "Monthly"
|
||||
? product.monthlyPrice ?? product.unitPrice ?? 0
|
||||
: product.oneTimePrice ?? product.unitPrice ?? 0,
|
||||
description: product.name,
|
||||
displayOrder: product.displayOrder ?? Number(record[fields.product.displayOrder] ?? 0),
|
||||
} satisfies SimProduct;
|
||||
@ -115,17 +111,17 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
if (hasExistingSim) {
|
||||
this.logger.log(`User ${userId} has existing SIM, showing family discount plans`);
|
||||
// Show family discount plans + regular plans for comparison
|
||||
return allPlans; // Family plans are included with hasFamilyDiscount: true
|
||||
return allPlans; // Family plans are included with simHasFamilyDiscount: true
|
||||
} else {
|
||||
this.logger.log(`User ${userId} has no existing SIM, showing regular plans only`);
|
||||
// Show only regular plans (hide family discount plans)
|
||||
return allPlans.filter(plan => !plan.hasFamilyDiscount);
|
||||
return allPlans.filter(plan => !plan.simHasFamilyDiscount);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get personalized SIM plans for user ${userId}`, error);
|
||||
// Fallback to all regular plans
|
||||
const allPlans = await this.getPlans();
|
||||
return allPlans.filter(plan => !plan.hasFamilyDiscount);
|
||||
return allPlans.filter(plan => !plan.simHasFamilyDiscount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -85,10 +85,10 @@ function InternetConfigureContent() {
|
||||
|
||||
const installationSkuParam = searchParams.get("installationSku");
|
||||
if (installationSkuParam) {
|
||||
const installation = installationsData.find(i => i.sku === installationSkuParam);
|
||||
if (installation) {
|
||||
setInstallPlan(installation.type);
|
||||
}
|
||||
const installation = installationsData.find(i => i.sku === installationSkuParam);
|
||||
if (installation) {
|
||||
setInstallPlan(installation.catalogMetadata.installationTerm);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore selected addons from URL parameters
|
||||
@ -129,23 +129,34 @@ function InternetConfigureContent() {
|
||||
// Add selected addons
|
||||
selectedAddonSkus.forEach(addonSku => {
|
||||
const addon = addons.find(a => a.sku === addonSku);
|
||||
if (addon) {
|
||||
if (addon.monthlyPrice) {
|
||||
monthlyTotal += addon.monthlyPrice;
|
||||
}
|
||||
if (addon.activationPrice) {
|
||||
oneTimeTotal += addon.activationPrice;
|
||||
}
|
||||
if (!addon) return;
|
||||
|
||||
const monthlyCharge = addon.monthlyPrice ?? (addon.billingCycle === "Monthly" ? addon.unitPrice : undefined);
|
||||
const oneTimeCharge = addon.oneTimePrice ?? (addon.billingCycle !== "Monthly" ? addon.unitPrice : undefined);
|
||||
|
||||
if (typeof monthlyCharge === "number") {
|
||||
monthlyTotal += monthlyCharge;
|
||||
}
|
||||
if (typeof oneTimeCharge === "number") {
|
||||
oneTimeTotal += oneTimeCharge;
|
||||
}
|
||||
});
|
||||
|
||||
// Add installation cost
|
||||
const installation = installations.find(i => i.type === installPlan);
|
||||
if (installation && installation.price) {
|
||||
if (installation.billingCycle === "Monthly") {
|
||||
monthlyTotal += installation.price;
|
||||
} else {
|
||||
oneTimeTotal += installation.price;
|
||||
const installation = installations.find(
|
||||
i => i.catalogMetadata.installationTerm === installPlan
|
||||
);
|
||||
if (installation) {
|
||||
const monthlyCharge =
|
||||
installation.monthlyPrice ?? (installation.billingCycle === "Monthly" ? installation.unitPrice : undefined);
|
||||
const oneTimeCharge =
|
||||
installation.oneTimePrice ?? (installation.billingCycle !== "Monthly" ? installation.unitPrice : undefined);
|
||||
|
||||
if (typeof monthlyCharge === "number") {
|
||||
monthlyTotal += monthlyCharge;
|
||||
}
|
||||
if (typeof oneTimeCharge === "number") {
|
||||
oneTimeTotal += oneTimeCharge;
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,7 +175,9 @@ function InternetConfigureContent() {
|
||||
});
|
||||
|
||||
// Add installation SKU (not type)
|
||||
const installation = installations.find(i => i.type === installPlan);
|
||||
const installation = installations.find(
|
||||
i => i.catalogMetadata.installationTerm === installPlan
|
||||
);
|
||||
if (installation) {
|
||||
params.append("installationSku", installation.sku);
|
||||
}
|
||||
@ -229,14 +242,14 @@ function InternetConfigureContent() {
|
||||
<div className="inline-flex items-center gap-3 bg-gray-50 px-6 py-3 rounded-2xl border">
|
||||
<div
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
plan.tier === "Platinum"
|
||||
plan.internetPlanTier === "Platinum"
|
||||
? "bg-purple-100 text-purple-800"
|
||||
: plan.tier === "Gold"
|
||||
: plan.internetPlanTier === "Gold"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{plan.tier}
|
||||
{plan.internetPlanTier || "Plan"}
|
||||
</div>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span className="font-medium text-gray-900">{plan.name}</span>
|
||||
@ -274,7 +287,7 @@ function InternetConfigureContent() {
|
||||
</div>
|
||||
|
||||
{/* 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="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
@ -308,7 +321,7 @@ function InternetConfigureContent() {
|
||||
)}
|
||||
|
||||
{/* Access Mode Selection - Only for Silver */}
|
||||
{plan?.tier === "Silver" ? (
|
||||
{plan?.internetPlanTier === "Silver" ? (
|
||||
<div className="mb-6">
|
||||
<h4 className="font-medium text-gray-900 mb-4">
|
||||
Select Your Router & ISP Configuration:
|
||||
@ -438,7 +451,7 @@ function InternetConfigureContent() {
|
||||
/>
|
||||
</svg>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -477,9 +490,13 @@ function InternetConfigureContent() {
|
||||
|
||||
<InstallationOptions
|
||||
installations={installations}
|
||||
selectedInstallation={installations.find(inst => inst.type === installPlan) || null}
|
||||
selectedInstallation={
|
||||
installations.find(
|
||||
inst => inst.catalogMetadata.installationTerm === installPlan
|
||||
) || null
|
||||
}
|
||||
onInstallationSelect={installation => {
|
||||
setInstallPlan(installation.type);
|
||||
setInstallPlan(installation.catalogMetadata.installationTerm);
|
||||
}}
|
||||
showSkus={false}
|
||||
/>
|
||||
@ -634,19 +651,19 @@ function InternetConfigureContent() {
|
||||
<div className="space-y-2">
|
||||
{selectedAddonSkus.map(addonSku => {
|
||||
const addon = addons.find(a => a.sku === addonSku);
|
||||
const monthlyAmount =
|
||||
addon?.monthlyPrice ?? (addon?.billingCycle === "Monthly" ? addon?.unitPrice : undefined);
|
||||
const oneTimeAmount =
|
||||
addon?.oneTimePrice ?? (addon?.billingCycle !== "Monthly" ? addon?.unitPrice : undefined);
|
||||
const amount = monthlyAmount ?? oneTimeAmount ?? 0;
|
||||
const cadence = monthlyAmount ? "mo" : "once";
|
||||
|
||||
return (
|
||||
<div key={addonSku} className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">{addon?.name || addonSku}</span>
|
||||
<span className="text-gray-900">
|
||||
¥
|
||||
{(
|
||||
addon?.monthlyPrice ||
|
||||
addon?.activationPrice ||
|
||||
0
|
||||
).toLocaleString()}
|
||||
<span className="text-xs text-gray-500 ml-1">
|
||||
/{addon?.monthlyPrice ? "mo" : "once"}
|
||||
</span>
|
||||
¥{amount.toLocaleString()}
|
||||
<span className="text-xs text-gray-500 ml-1">/{cadence}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@ -657,16 +674,26 @@ function InternetConfigureContent() {
|
||||
|
||||
{/* Installation Fees */}
|
||||
{(() => {
|
||||
const installation = installations.find(i => i.type === installPlan);
|
||||
return installation && installation.price && installation.price > 0 ? (
|
||||
const installation = installations.find(
|
||||
i => i.catalogMetadata.installationTerm === installPlan
|
||||
);
|
||||
if (!installation) return null;
|
||||
|
||||
const monthlyAmount =
|
||||
installation.monthlyPrice ?? (installation.billingCycle === "Monthly" ? installation.unitPrice : undefined);
|
||||
const oneTimeAmount =
|
||||
installation.oneTimePrice ?? (installation.billingCycle !== "Monthly" ? installation.unitPrice : undefined);
|
||||
const amount = monthlyAmount ?? oneTimeAmount;
|
||||
|
||||
return amount && amount > 0 ? (
|
||||
<div className="border-t border-gray-200 pt-4 mb-6">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Installation</h4>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">{installation.name}</span>
|
||||
<span className="text-gray-900">
|
||||
¥{installation.price.toLocaleString()}
|
||||
¥{amount.toLocaleString()}
|
||||
<span className="text-xs text-gray-500 ml-1">
|
||||
/{installation.billingCycle === "Monthly" ? "mo" : "once"}
|
||||
/{monthlyAmount ? "mo" : "once"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -42,7 +42,7 @@ export default function InternetPlansPage() {
|
||||
setPlans(plans);
|
||||
setInstallations(installations);
|
||||
if (plans.length > 0) {
|
||||
setEligibility(plans[0].offeringType || "Home 1G");
|
||||
setEligibility(plans[0].internetOfferingType || "Home 1G");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@ -58,21 +58,23 @@ export default function InternetPlansPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getEligibilityIcon = (offeringType: string) => {
|
||||
if (offeringType.toLowerCase().includes("home")) {
|
||||
const getEligibilityIcon = (offeringType?: string) => {
|
||||
const lower = (offeringType || "").toLowerCase();
|
||||
if (lower.includes("home")) {
|
||||
return <HomeIcon className="h-5 w-5" />;
|
||||
}
|
||||
if (offeringType.toLowerCase().includes("apartment")) {
|
||||
if (lower.includes("apartment")) {
|
||||
return <BuildingOfficeIcon className="h-5 w-5" />;
|
||||
}
|
||||
return <HomeIcon className="h-5 w-5" />;
|
||||
};
|
||||
|
||||
const getEligibilityColor = (offeringType: string) => {
|
||||
if (offeringType.toLowerCase().includes("home")) {
|
||||
const getEligibilityColor = (offeringType?: string) => {
|
||||
const lower = (offeringType || "").toLowerCase();
|
||||
if (lower.includes("home")) {
|
||||
return "text-blue-600 bg-blue-50 border-blue-200";
|
||||
}
|
||||
if (offeringType.toLowerCase().includes("apartment")) {
|
||||
if (lower.includes("apartment")) {
|
||||
return "text-green-600 bg-green-50 border-green-200";
|
||||
}
|
||||
return "text-gray-600 bg-gray-50 border-gray-200";
|
||||
@ -200,9 +202,10 @@ function InternetPlanCard({
|
||||
plan: InternetPlan;
|
||||
installations: InternetInstallation[];
|
||||
}) {
|
||||
const isGold = plan.tier === "Gold";
|
||||
const isPlatinum = plan.tier === "Platinum";
|
||||
const isSilver = plan.tier === "Silver";
|
||||
const tier = plan.internetPlanTier;
|
||||
const isGold = tier === "Gold";
|
||||
const isPlatinum = tier === "Platinum";
|
||||
const isSilver = tier === "Silver";
|
||||
|
||||
// Use default variant for all cards to avoid green background on gold
|
||||
const cardVariant = "default";
|
||||
@ -215,6 +218,16 @@ function InternetPlanCard({
|
||||
return "border border-gray-200 shadow-lg hover:shadow-xl";
|
||||
};
|
||||
|
||||
const installationPrices = installations
|
||||
.map(inst =>
|
||||
inst.billingCycle === "Monthly"
|
||||
? inst.monthlyPrice ?? inst.unitPrice
|
||||
: inst.oneTimePrice ?? inst.unitPrice
|
||||
)
|
||||
.filter((price): price is number => typeof price === "number" && Number.isFinite(price));
|
||||
|
||||
const minInstallationPrice = installationPrices.length > 0 ? Math.min(...installationPrices) : null;
|
||||
|
||||
return (
|
||||
<AnimatedCard
|
||||
variant={cardVariant}
|
||||
@ -233,7 +246,7 @@ function InternetPlanCard({
|
||||
: "bg-gray-100 text-gray-800 border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{plan.tier}
|
||||
{tier || "Plan"}
|
||||
</span>
|
||||
{isGold && (
|
||||
<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 */}
|
||||
<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 */}
|
||||
<div className="mb-6 flex-grow">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-700">
|
||||
{plan.features && plan.features.length > 0 ? (
|
||||
plan.features.map((feature, index) => (
|
||||
{plan.catalogMetadata.features && plan.catalogMetadata.features.length > 0 ? (
|
||||
plan.catalogMetadata.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
{feature}
|
||||
@ -273,26 +288,26 @@ function InternetPlanCard({
|
||||
<>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>1 NTT Optical Fiber (Flet's
|
||||
Hikari Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
|
||||
{plan.offeringType?.includes("10G")
|
||||
Hikari Next - {plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
|
||||
{plan.internetOfferingType?.includes("10G")
|
||||
? "10Gbps"
|
||||
: plan.offeringType?.includes("100M")
|
||||
: plan.internetOfferingType?.includes("100M")
|
||||
? "100Mbps"
|
||||
: "1Gbps"}
|
||||
) Installation + Monthly
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Monthly: ¥{plan.monthlyPrice?.toLocaleString()}
|
||||
{installations.length > 0 && (
|
||||
<span className="text-gray-600 text-sm ml-2">
|
||||
(+ installation from ¥
|
||||
{Math.min(...installations.map(i => i.price || 0)).toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
) Installation + Monthly
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Monthly: ¥{plan.monthlyPrice?.toLocaleString()}
|
||||
{minInstallationPrice !== null && (
|
||||
<span className="text-gray-600 text-sm ml-2">
|
||||
(+ installation from ¥
|
||||
{minInstallationPrice.toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@ -381,7 +381,7 @@ function SimConfigureContent() {
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<DevicePhoneMobileIcon className="h-5 w-5 text-blue-600" />
|
||||
<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">
|
||||
<UsersIcon className="h-3 w-3" />
|
||||
Family Discount
|
||||
@ -390,13 +390,13 @@ function SimConfigureContent() {
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 mb-2">
|
||||
<span>
|
||||
<strong>Data:</strong> {plan.dataSize}
|
||||
<strong>Data:</strong> {plan.simDataSize}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Type:</strong>{" "}
|
||||
{plan.planType === "DataSmsVoice"
|
||||
{(plan.simPlanType || "DataSmsVoice") === "DataSmsVoice"
|
||||
? "Data + Voice"
|
||||
: plan.planType === "DataOnly"
|
||||
: (plan.simPlanType || "DataSmsVoice") === "DataOnly"
|
||||
? "Data Only"
|
||||
: "Voice Only"}
|
||||
</span>
|
||||
@ -406,7 +406,7 @@ function SimConfigureContent() {
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
¥{plan.monthlyPrice?.toLocaleString()}/mo
|
||||
</div>
|
||||
{plan.hasFamilyDiscount && (
|
||||
{plan.simHasFamilyDiscount && (
|
||||
<div className="text-sm text-green-600 font-medium">Discounted Price</div>
|
||||
)}
|
||||
</div>
|
||||
@ -550,16 +550,16 @@ function SimConfigureContent() {
|
||||
<div className="mb-6">
|
||||
<StepHeader
|
||||
stepNumber={3}
|
||||
title={plan.planType === "DataOnly" ? "Add-ons" : "Voice Add-ons"}
|
||||
title={(plan.simPlanType || "DataSmsVoice") === "DataOnly" ? "Add-ons" : "Voice Add-ons"}
|
||||
description={
|
||||
plan.planType === "DataOnly"
|
||||
(plan.simPlanType || "DataSmsVoice") === "DataOnly"
|
||||
? "No add-ons available for data-only plans"
|
||||
: "Enhance your voice services with these optional features"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{addons.length > 0 && plan.planType !== "DataOnly" ? (
|
||||
{addons.length > 0 && (plan.simPlanType || "DataSmsVoice") !== "DataOnly" ? (
|
||||
<AddonGroup
|
||||
addons={addons.map(addon => ({
|
||||
...addon,
|
||||
@ -579,7 +579,7 @@ function SimConfigureContent() {
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<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 this plan."}
|
||||
</p>
|
||||
@ -685,7 +685,7 @@ function SimConfigureContent() {
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<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 className="text-right">
|
||||
<p className="font-semibold text-gray-900">
|
||||
@ -788,7 +788,7 @@ function SimConfigureContent() {
|
||||
{/* Receipt Footer */}
|
||||
<div className="text-center mt-6 pt-4 border-t border-gray-200">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -41,8 +41,8 @@ function PlanTypeSection({
|
||||
if (plans.length === 0) return null;
|
||||
|
||||
// Separate regular and family plans
|
||||
const regularPlans = plans.filter(p => !p.hasFamilyDiscount);
|
||||
const familyPlans = plans.filter(p => p.hasFamilyDiscount);
|
||||
const regularPlans = plans.filter(p => !p.simHasFamilyDiscount);
|
||||
const familyPlans = plans.filter(p => p.simHasFamilyDiscount);
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in duration-500">
|
||||
@ -89,7 +89,7 @@ function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) {
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<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>
|
||||
{isFamily && (
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
@ -147,7 +147,7 @@ export default function SimPlansPage() {
|
||||
if (mounted) {
|
||||
setPlans(plansData);
|
||||
// Check if any plans have family discount (indicates user has existing SIM)
|
||||
setHasExistingSim(plansData.some(p => p.hasFamilyDiscount));
|
||||
setHasExistingSim(plansData.some(p => p.simHasFamilyDiscount));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@ -196,7 +196,8 @@ export default function SimPlansPage() {
|
||||
// Group plans by type
|
||||
const plansByType: PlansByType = plans.reduce(
|
||||
(acc, plan) => {
|
||||
acc[plan.planType].push(plan);
|
||||
const type = plan.simPlanType || "DataSmsVoice";
|
||||
acc[type].push(plan);
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
|
||||
@ -8,7 +8,7 @@ interface AddonItem {
|
||||
sku: string;
|
||||
description: string;
|
||||
monthlyPrice?: number;
|
||||
activationPrice?: number;
|
||||
oneTimePrice?: number;
|
||||
isBundledAddon?: boolean;
|
||||
bundledAddonId?: string;
|
||||
displayOrder?: number;
|
||||
@ -19,7 +19,7 @@ interface AddonGroup {
|
||||
name: string;
|
||||
description: string;
|
||||
monthlyPrice?: number;
|
||||
activationPrice?: number;
|
||||
oneTimePrice?: number;
|
||||
skus: string[];
|
||||
isBundled: boolean;
|
||||
}
|
||||
@ -55,7 +55,7 @@ export function AddonGroup({
|
||||
if (bundledPartner && !processedAddonIds.has(bundledPartner.id)) {
|
||||
// Create a combined group
|
||||
const monthlyAddon = addon.monthlyPrice ? addon : bundledPartner;
|
||||
const activationAddon = addon.activationPrice ? addon : bundledPartner;
|
||||
const activationAddon = addon.oneTimePrice ? addon : bundledPartner;
|
||||
|
||||
// Generate clean name and description
|
||||
const cleanName = monthlyAddon.name
|
||||
@ -68,7 +68,7 @@ export function AddonGroup({
|
||||
name: bundleName,
|
||||
description: `${bundleName} (installation included)`,
|
||||
monthlyPrice: monthlyAddon.monthlyPrice,
|
||||
activationPrice: activationAddon.activationPrice,
|
||||
oneTimePrice: activationAddon.oneTimePrice,
|
||||
skus: [addon.sku, bundledPartner.sku],
|
||||
isBundled: true,
|
||||
});
|
||||
@ -82,7 +82,7 @@ export function AddonGroup({
|
||||
name: addon.name,
|
||||
description: addon.description,
|
||||
monthlyPrice: addon.monthlyPrice,
|
||||
activationPrice: addon.activationPrice,
|
||||
oneTimePrice: addon.oneTimePrice,
|
||||
skus: [addon.sku],
|
||||
isBundled: false,
|
||||
});
|
||||
@ -95,7 +95,7 @@ export function AddonGroup({
|
||||
name: addon.name,
|
||||
description: addon.description,
|
||||
monthlyPrice: addon.monthlyPrice,
|
||||
activationPrice: addon.activationPrice,
|
||||
oneTimePrice: addon.oneTimePrice,
|
||||
skus: [addon.sku],
|
||||
isBundled: false,
|
||||
});
|
||||
@ -155,16 +155,16 @@ export function AddonGroup({
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{addonGroup.description}</p>
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
{addonGroup.monthlyPrice && (
|
||||
<span className="text-sm font-semibold text-blue-600">
|
||||
¥{addonGroup.monthlyPrice.toLocaleString()}/month
|
||||
</span>
|
||||
)}
|
||||
{addonGroup.activationPrice && (
|
||||
<span className="text-sm font-semibold text-orange-600">
|
||||
Activation: ¥{addonGroup.activationPrice.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{addonGroup.monthlyPrice && (
|
||||
<span className="text-sm font-semibold text-blue-600">
|
||||
¥{addonGroup.monthlyPrice.toLocaleString()}/month
|
||||
</span>
|
||||
)}
|
||||
{addonGroup.oneTimePrice && (
|
||||
<span className="text-sm font-semibold text-orange-600">
|
||||
Activation: ¥{addonGroup.oneTimePrice.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{addonGroup.isBundled && (
|
||||
<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 {
|
||||
name: string;
|
||||
price?: number;
|
||||
monthlyPrice?: number;
|
||||
oneTimePrice?: number;
|
||||
billingCycle?: string;
|
||||
sku?: string;
|
||||
}
|
||||
@ -12,7 +13,7 @@ interface OrderSummaryProps {
|
||||
// Plan details
|
||||
plan: {
|
||||
name: string;
|
||||
tier?: string;
|
||||
internetPlanTier?: string;
|
||||
monthlyPrice?: number;
|
||||
};
|
||||
|
||||
@ -78,7 +79,7 @@ export function OrderSummary({
|
||||
<span className="text-gray-600">Plan:</span>
|
||||
<span className="font-medium">
|
||||
{plan.name}
|
||||
{plan.tier && ` (${plan.tier})`}
|
||||
{plan.internetPlanTier && ` (${plan.internetPlanTier})`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -107,7 +108,9 @@ export function OrderSummary({
|
||||
<div className="text-sm font-medium text-gray-700 mb-1">Monthly Costs:</div>
|
||||
{plan.monthlyPrice && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@ -115,10 +118,12 @@ export function OrderSummary({
|
||||
{selectedAddons.map(
|
||||
(addon, index) =>
|
||||
addon.billingCycle === "Monthly" &&
|
||||
addon.price && (
|
||||
typeof addon.monthlyPrice === "number" && (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<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>
|
||||
)
|
||||
)}
|
||||
@ -143,17 +148,21 @@ export function OrderSummary({
|
||||
{activationFees.map((fee, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<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>
|
||||
))}
|
||||
|
||||
{selectedAddons.map(
|
||||
(addon, index) =>
|
||||
addon.billingCycle !== "Monthly" &&
|
||||
addon.price && (
|
||||
typeof addon.oneTimePrice === "number" && (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<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>
|
||||
)
|
||||
)}
|
||||
@ -185,7 +194,9 @@ export function OrderSummary({
|
||||
{activationFees.map((fee, index) => (
|
||||
<div key={index} className="flex justify-between">
|
||||
<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>
|
||||
))}
|
||||
|
||||
@ -194,7 +205,10 @@ export function OrderSummary({
|
||||
<div key={index} className="flex justify-between">
|
||||
<span className="text-gray-600">{addon.name}</span>
|
||||
<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"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
import { AnimatedCard } from "@/components/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CurrencyYenIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import type { InternetProduct } from "@customer-portal/domain";
|
||||
import type { InternetPlan, InternetInstallation } from "@/shared/types/catalog.types";
|
||||
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
|
||||
|
||||
interface InternetPlanCardProps {
|
||||
plan: InternetProduct;
|
||||
installations: InternetProduct[];
|
||||
plan: InternetPlan;
|
||||
installations: InternetInstallation[];
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
@ -19,14 +19,16 @@ export function InternetPlanCard({ plan, installations, disabled, disabledReason
|
||||
const isPlatinum = tier === "Platinum";
|
||||
const isSilver = tier === "Silver";
|
||||
|
||||
const minInstallationPrice = installations.length
|
||||
? Math.min(
|
||||
...installations.map(installation =>
|
||||
installation.billingCycle === "Monthly"
|
||||
? getMonthlyPrice(installation)
|
||||
: getOneTimePrice(installation)
|
||||
)
|
||||
)
|
||||
const installationPrices = installations
|
||||
.map(installation =>
|
||||
installation.billingCycle === "Monthly"
|
||||
? getMonthlyPrice(installation)
|
||||
: getOneTimePrice(installation)
|
||||
)
|
||||
.filter(price => price > 0);
|
||||
|
||||
const minInstallationPrice = installationPrices.length
|
||||
? Math.min(...installationPrices)
|
||||
: 0;
|
||||
|
||||
const getBorderClass = () => {
|
||||
@ -75,13 +77,15 @@ export function InternetPlanCard({ plan, installations, disabled, disabledReason
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-700">
|
||||
{plan.features && plan.features.length > 0 ? (
|
||||
plan.features.map((feature, index) => (
|
||||
{plan.catalogMetadata.features && plan.catalogMetadata.features.length > 0 ? (
|
||||
plan.catalogMetadata.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
{feature}
|
||||
|
||||
@ -1,86 +1,52 @@
|
||||
/**
|
||||
* Frontend catalog types - Clean, focused, production-ready
|
||||
* NO NAME MATCHING - Direct product access only
|
||||
* NO HARDCODED DATA - Only structured Salesforce data
|
||||
*/
|
||||
import type {
|
||||
InternetProduct,
|
||||
SimProduct,
|
||||
VpnProduct,
|
||||
ProductWithPricing,
|
||||
} from "@customer-portal/domain";
|
||||
|
||||
// Core product interface
|
||||
export interface BaseProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
description: string;
|
||||
}
|
||||
// API shapes returned by the catalog endpoints. We model them as extensions of the
|
||||
// unified domain types so field names stay aligned with Salesforce structures.
|
||||
export type InternetPlan = InternetProduct & {
|
||||
catalogMetadata: {
|
||||
tierDescription: string;
|
||||
features: string[];
|
||||
isRecommended: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// Internet product types
|
||||
export interface InternetPlan extends BaseProduct {
|
||||
tier: "Silver" | "Gold" | "Platinum";
|
||||
offeringType: string;
|
||||
monthlyPrice?: number;
|
||||
features: string[];
|
||||
tierDescription: string;
|
||||
isRecommended: boolean;
|
||||
}
|
||||
export type InternetInstallation = InternetProduct & {
|
||||
catalogMetadata: {
|
||||
installationTerm: "One-time" | "12-Month" | "24-Month";
|
||||
};
|
||||
};
|
||||
|
||||
export interface InternetInstallation extends BaseProduct {
|
||||
type: "One-time" | "12-Month" | "24-Month";
|
||||
price?: number;
|
||||
billingCycle: string;
|
||||
displayOrder: number;
|
||||
}
|
||||
export type InternetAddon = InternetProduct & {
|
||||
catalogMetadata: {
|
||||
addonCategory: "hikari-denwa-service" | "hikari-denwa-installation" | "other";
|
||||
autoAdd: boolean;
|
||||
requiredWith: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export interface InternetAddon extends BaseProduct {
|
||||
type: "hikari-denwa-service" | "hikari-denwa-installation" | "other";
|
||||
monthlyPrice?: number;
|
||||
activationPrice?: number;
|
||||
autoAdd: boolean;
|
||||
requiredWith: string[];
|
||||
bundledAddonId?: string; // ID of the bundled product
|
||||
isBundledAddon?: boolean; // Whether this is part of a bundle
|
||||
displayOrder?: number;
|
||||
}
|
||||
export type SimPlan = SimProduct;
|
||||
|
||||
// SIM product types
|
||||
export interface SimPlan extends BaseProduct {
|
||||
dataSize: string;
|
||||
planType: "DataOnly" | "DataSmsVoice" | "VoiceOnly";
|
||||
hasFamilyDiscount: boolean;
|
||||
monthlyPrice?: number;
|
||||
features: string[];
|
||||
}
|
||||
export type SimActivationFee = SimProduct & {
|
||||
catalogMetadata: {
|
||||
isDefault: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export interface SimActivationFee extends BaseProduct {
|
||||
price: number;
|
||||
isDefault: boolean;
|
||||
}
|
||||
export type SimAddon = SimProduct;
|
||||
|
||||
export interface SimAddon extends BaseProduct {
|
||||
price: number;
|
||||
billingCycle: string;
|
||||
bundledAddonId?: string; // ID of the bundled product
|
||||
isBundledAddon?: boolean; // Whether this is part of a bundle
|
||||
displayOrder?: number;
|
||||
}
|
||||
export type VpnPlan = VpnProduct;
|
||||
export type VpnActivationFee = VpnProduct;
|
||||
|
||||
// VPN product types
|
||||
export interface VpnPlan extends BaseProduct {
|
||||
region: string; // VPN region from VPN_Region__c (USA-SF, UK-London, Global)
|
||||
monthlyPrice?: number;
|
||||
}
|
||||
|
||||
export interface VpnActivationFee extends BaseProduct {
|
||||
price: number;
|
||||
region?: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
// Helper types
|
||||
export type ProductType = "Internet" | "SIM" | "VPN";
|
||||
export type ItemClass = "Service" | "Installation" | "Add-on" | "Activation";
|
||||
export type BillingCycle = "Monthly" | "Onetime";
|
||||
export type PlanTier = "Silver" | "Gold" | "Platinum";
|
||||
|
||||
// Frontend-specific types
|
||||
export interface CheckoutState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@ -102,7 +68,6 @@ export interface OrderTotals {
|
||||
oneTimeTotal: number;
|
||||
}
|
||||
|
||||
// Production-ready utility functions - only structured Salesforce data
|
||||
export const buildInternetOrderItems = (
|
||||
plan: InternetPlan,
|
||||
addons: InternetAddon[],
|
||||
@ -114,29 +79,27 @@ export const buildInternetOrderItems = (
|
||||
): OrderItem[] => {
|
||||
const items: OrderItem[] = [];
|
||||
|
||||
// Add main plan
|
||||
items.push({
|
||||
name: plan.name,
|
||||
sku: plan.sku,
|
||||
monthlyPrice: plan.monthlyPrice,
|
||||
oneTimePrice: plan.oneTimePrice,
|
||||
type: "service",
|
||||
});
|
||||
|
||||
// Add installation if selected (by SKU)
|
||||
if (selections.installationSku) {
|
||||
const installation = installations.find(inst => inst.sku === selections.installationSku);
|
||||
if (installation) {
|
||||
items.push({
|
||||
name: installation.name,
|
||||
sku: installation.sku,
|
||||
monthlyPrice: installation.billingCycle === "Monthly" ? installation.price : undefined,
|
||||
oneTimePrice: installation.billingCycle !== "Monthly" ? installation.price : undefined,
|
||||
monthlyPrice: installation.billingCycle === "Monthly" ? installation.monthlyPrice : undefined,
|
||||
oneTimePrice: installation.billingCycle !== "Monthly" ? installation.oneTimePrice : undefined,
|
||||
type: "installation",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add selected addons (by SKU like SIM flow)
|
||||
if (selections.addonSkus && selections.addonSkus.length > 0) {
|
||||
selections.addonSkus.forEach(addonSku => {
|
||||
const selectedAddon = addons.find(addon => addon.sku === addonSku);
|
||||
@ -145,7 +108,7 @@ export const buildInternetOrderItems = (
|
||||
name: selectedAddon.name,
|
||||
sku: selectedAddon.sku,
|
||||
monthlyPrice: selectedAddon.monthlyPrice,
|
||||
oneTimePrice: selectedAddon.activationPrice,
|
||||
oneTimePrice: selectedAddon.oneTimePrice,
|
||||
type: "addon",
|
||||
});
|
||||
}
|
||||
@ -160,32 +123,29 @@ export const buildSimOrderItems = (
|
||||
activationFees: SimActivationFee[],
|
||||
addons: SimAddon[],
|
||||
selections: {
|
||||
addonSkus?: string[]; // Support multiple add-ons
|
||||
addonSkus?: string[];
|
||||
}
|
||||
): OrderItem[] => {
|
||||
const items: OrderItem[] = [];
|
||||
|
||||
// Add main plan
|
||||
|
||||
items.push({
|
||||
name: plan.name,
|
||||
sku: plan.sku, // This should be the actual StockKeepingUnit from Salesforce
|
||||
sku: plan.sku,
|
||||
monthlyPrice: plan.monthlyPrice,
|
||||
oneTimePrice: plan.oneTimePrice,
|
||||
type: "service",
|
||||
});
|
||||
|
||||
// Add activation fee
|
||||
const activationFee = activationFees[0];
|
||||
const activationFee = activationFees.find(fee => fee.catalogMetadata.isDefault) || activationFees[0];
|
||||
if (activationFee) {
|
||||
items.push({
|
||||
name: activationFee.name,
|
||||
sku: activationFee.sku,
|
||||
oneTimePrice: activationFee.price,
|
||||
oneTimePrice: activationFee.oneTimePrice ?? activationFee.unitPrice,
|
||||
type: "addon",
|
||||
});
|
||||
}
|
||||
|
||||
// Add selected addons from Salesforce (multiple allowed)
|
||||
if (selections.addonSkus && selections.addonSkus.length > 0) {
|
||||
selections.addonSkus.forEach(addonSku => {
|
||||
const selectedAddon = addons.find(addon => addon.sku === addonSku);
|
||||
@ -193,12 +153,10 @@ export const buildSimOrderItems = (
|
||||
items.push({
|
||||
name: selectedAddon.name,
|
||||
sku: selectedAddon.sku,
|
||||
monthlyPrice: selectedAddon.billingCycle === "Monthly" ? selectedAddon.price : undefined,
|
||||
oneTimePrice: selectedAddon.billingCycle !== "Monthly" ? selectedAddon.price : undefined,
|
||||
monthlyPrice: selectedAddon.billingCycle === "Monthly" ? selectedAddon.monthlyPrice ?? selectedAddon.unitPrice : undefined,
|
||||
oneTimePrice: selectedAddon.billingCycle !== "Monthly" ? selectedAddon.oneTimePrice ?? selectedAddon.unitPrice : undefined,
|
||||
type: "addon",
|
||||
});
|
||||
} else {
|
||||
// Addon SKU not found - skip silently in production
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useInternetCatalog } from "@/features/catalog/hooks";
|
||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
import type { InternetProduct } from "@customer-portal/domain";
|
||||
import type { InternetPlan, InternetInstallation } from "@/shared/types/catalog.types";
|
||||
import { getMonthlyPrice } from "../utils/pricing";
|
||||
import { LoadingCard, Skeleton, LoadingTable } from "@/components/ui/loading-skeleton";
|
||||
import { AnimatedCard } from "@/components/ui";
|
||||
@ -24,8 +24,8 @@ import { AlertBanner } from "@/components/common/AlertBanner";
|
||||
|
||||
export function InternetPlansContainer() {
|
||||
const { data, isLoading, error } = useInternetCatalog();
|
||||
const plans: InternetProduct[] = data?.plans || [];
|
||||
const installations: InternetProduct[] = data?.installations || [];
|
||||
const plans: InternetPlan[] = data?.plans || [];
|
||||
const installations: InternetInstallation[] = data?.installations || [];
|
||||
const [eligibility, setEligibility] = useState<string>("");
|
||||
const { data: activeSubs } = useActiveSubscriptions();
|
||||
const hasActiveInternet = Array.isArray(activeSubs)
|
||||
@ -44,16 +44,16 @@ export function InternetPlansContainer() {
|
||||
}
|
||||
}, [plans]);
|
||||
|
||||
const getEligibilityIcon = (offeringType: string) => {
|
||||
const lower = offeringType.toLowerCase();
|
||||
const getEligibilityIcon = (offeringType?: string) => {
|
||||
const lower = (offeringType || "").toLowerCase();
|
||||
if (lower.includes("home")) return <HomeIcon className="h-5 w-5" />;
|
||||
if (lower.includes("apartment"))
|
||||
return <BuildingOfficeIcon className="h-5 w-5" />;
|
||||
return <HomeIcon className="h-5 w-5" />;
|
||||
};
|
||||
|
||||
const getEligibilityColor = (offeringType: string) => {
|
||||
const lower = offeringType.toLowerCase();
|
||||
const getEligibilityColor = (offeringType?: string) => {
|
||||
const lower = (offeringType || "").toLowerCase();
|
||||
if (lower.includes("home"))
|
||||
return "text-blue-600 bg-blue-50 border-blue-200";
|
||||
if (lower.includes("apartment"))
|
||||
@ -186,4 +186,3 @@ export function InternetPlansContainer() {
|
||||
}
|
||||
|
||||
// InternetPlanCard extracted to components/internet/InternetPlanCard
|
||||
|
||||
|
||||
@ -112,12 +112,6 @@ export interface ProductWithPricing extends BaseProduct {
|
||||
unitPrice?: number; // PricebookEntry.UnitPrice
|
||||
monthlyPrice?: number; // UnitPrice if billingCycle === "Monthly"
|
||||
oneTimePrice?: number; // UnitPrice if billingCycle === "Onetime"
|
||||
/** @deprecated Legacy field kept temporarily for UI compatibility. Use monthlyPrice/oneTimePrice instead. */
|
||||
price?: number;
|
||||
/** @deprecated Legacy field kept temporarily for UI compatibility. Use oneTimePrice instead. */
|
||||
activationPrice?: number;
|
||||
/** @deprecated Legacy field kept temporarily for UI compatibility. Prefer deriving type from sku/itemClass. */
|
||||
type?: string;
|
||||
}
|
||||
|
||||
// Internet-specific product fields
|
||||
@ -133,10 +127,6 @@ export interface InternetProduct extends ProductWithPricing {
|
||||
features?: string[];
|
||||
tierDescription?: string;
|
||||
isRecommended?: boolean;
|
||||
/** @deprecated Use internetPlanTier. */
|
||||
tier?: InternetProduct["internetPlanTier"];
|
||||
/** @deprecated Use internetOfferingType. */
|
||||
offeringType?: string;
|
||||
}
|
||||
|
||||
// SIM-specific product fields
|
||||
@ -150,12 +140,6 @@ export interface SimProduct extends ProductWithPricing {
|
||||
|
||||
// UI-specific fields
|
||||
features?: string[];
|
||||
/** @deprecated Use simDataSize. */
|
||||
dataSize?: string;
|
||||
/** @deprecated Use simPlanType. */
|
||||
planType?: SimProduct["simPlanType"];
|
||||
/** @deprecated Use simHasFamilyDiscount. */
|
||||
hasFamilyDiscount?: boolean;
|
||||
}
|
||||
|
||||
// VPN-specific product fields
|
||||
@ -164,8 +148,6 @@ export interface VpnProduct extends ProductWithPricing {
|
||||
|
||||
// VPN-specific fields
|
||||
vpnRegion?: string; // VPN_Region__c
|
||||
/** @deprecated Use vpnRegion. */
|
||||
region?: string;
|
||||
}
|
||||
|
||||
// Generic product for "Other" category
|
||||
@ -339,10 +321,6 @@ export function fromSalesforceProduct2(
|
||||
| InternetProduct["internetPlanTier"]
|
||||
| undefined,
|
||||
internetOfferingType: coerceString(readField(sfProduct, fieldMap.internetOfferingType)) || undefined,
|
||||
tier: coerceString(readField(sfProduct, fieldMap.internetPlanTier)) as
|
||||
| InternetProduct["internetPlanTier"]
|
||||
| undefined,
|
||||
offeringType: coerceString(readField(sfProduct, fieldMap.internetOfferingType)) || undefined,
|
||||
} satisfies InternetProduct;
|
||||
|
||||
case "SIM":
|
||||
@ -351,16 +329,12 @@ export function fromSalesforceProduct2(
|
||||
simDataSize: coerceString(readField(sfProduct, fieldMap.simDataSize)) || undefined,
|
||||
simPlanType: normalizeSimPlanType(readField(sfProduct, fieldMap.simPlanType)) || undefined,
|
||||
simHasFamilyDiscount: normalizeBoolean(readField(sfProduct, fieldMap.simHasFamilyDiscount)),
|
||||
dataSize: coerceString(readField(sfProduct, fieldMap.simDataSize)) || undefined,
|
||||
planType: normalizeSimPlanType(readField(sfProduct, fieldMap.simPlanType)) || undefined,
|
||||
hasFamilyDiscount: normalizeBoolean(readField(sfProduct, fieldMap.simHasFamilyDiscount)) ?? undefined,
|
||||
} satisfies SimProduct;
|
||||
|
||||
case "VPN":
|
||||
return {
|
||||
...baseProduct,
|
||||
vpnRegion: coerceString(readField(sfProduct, fieldMap.vpnRegion)) || undefined,
|
||||
region: coerceString(readField(sfProduct, fieldMap.vpnRegion)) || undefined,
|
||||
} satisfies VpnProduct;
|
||||
|
||||
default:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user