Enhance catalog and order handling by integrating new currency formatting and validation utilities. Updated various components to utilize the centralized currency formatting function, ensuring consistent display across the application. Refactored order creation and validation processes to improve clarity and maintainability, aligning with the updated domain structure.
This commit is contained in:
parent
75d199cb7f
commit
0233ff2dce
@ -33,6 +33,7 @@ import { CatalogModule } from "@bff/modules/catalog/catalog.module";
|
||||
import { OrdersModule } from "@bff/modules/orders/orders.module";
|
||||
import { InvoicesModule } from "@bff/modules/invoices/invoices.module";
|
||||
import { SubscriptionsModule } from "@bff/modules/subscriptions/subscriptions.module";
|
||||
import { CurrencyModule } from "@bff/modules/currency/currency.module";
|
||||
|
||||
// System Modules
|
||||
import { HealthModule } from "@bff/modules/health/health.module";
|
||||
@ -83,6 +84,7 @@ import { HealthModule } from "@bff/modules/health/health.module";
|
||||
OrdersModule,
|
||||
InvoicesModule,
|
||||
SubscriptionsModule,
|
||||
CurrencyModule,
|
||||
|
||||
// === SYSTEM MODULES ===
|
||||
HealthModule,
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Controller, Get, Request } from "@nestjs/common";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
import type {
|
||||
InternetAddonCatalogItem,
|
||||
InternetInstallationCatalogItem,
|
||||
InternetPlanCatalogItem,
|
||||
SimCatalogProduct,
|
||||
SimActivationFeeCatalogItem,
|
||||
VpnCatalogProduct,
|
||||
import {
|
||||
parseInternetCatalog,
|
||||
type InternetAddonCatalogItem,
|
||||
type InternetInstallationCatalogItem,
|
||||
type InternetPlanCatalogItem,
|
||||
type SimActivationFeeCatalogItem,
|
||||
type SimCatalogProduct,
|
||||
type VpnCatalogProduct,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { InternetCatalogService } from "./services/internet-catalog.service";
|
||||
import { SimCatalogService } from "./services/sim-catalog.service";
|
||||
@ -28,18 +29,17 @@ export class CatalogController {
|
||||
}> {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
// Fallback to all catalog data if no user context
|
||||
return this.internetCatalog.getCatalogData();
|
||||
const catalog = await this.internetCatalog.getCatalogData();
|
||||
return parseInternetCatalog(catalog);
|
||||
}
|
||||
|
||||
// Get user-specific plans but all installations and addons
|
||||
const [plans, installations, addons] = await Promise.all([
|
||||
this.internetCatalog.getPlansForUser(userId),
|
||||
this.internetCatalog.getInstallations(),
|
||||
this.internetCatalog.getAddons(),
|
||||
]);
|
||||
|
||||
return { plans, installations, addons };
|
||||
return parseInternetCatalog({ plans, installations, addons });
|
||||
}
|
||||
|
||||
@Get("internet/addons")
|
||||
|
||||
@ -7,7 +7,12 @@ import type {
|
||||
InternetInstallationCatalogItem,
|
||||
InternetAddonCatalogItem,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
|
||||
import {
|
||||
Providers as CatalogProviders,
|
||||
enrichInternetPlanMetadata,
|
||||
inferAddonTypeFromSku,
|
||||
inferInstallationTermFromSku,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
@ -44,7 +49,8 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
|
||||
return records.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
return CatalogProviders.Salesforce.mapInternetPlan(record, entry);
|
||||
const plan = CatalogProviders.Salesforce.mapInternetPlan(record, entry);
|
||||
return enrichInternetPlanMetadata(plan);
|
||||
});
|
||||
}
|
||||
|
||||
@ -63,7 +69,14 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
return records
|
||||
.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
return CatalogProviders.Salesforce.mapInternetInstallation(record, entry);
|
||||
const installation = CatalogProviders.Salesforce.mapInternetInstallation(record, entry);
|
||||
return {
|
||||
...installation,
|
||||
catalogMetadata: {
|
||||
...installation.catalogMetadata,
|
||||
installationTerm: inferInstallationTermFromSku(installation.sku ?? ""),
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||
}
|
||||
@ -85,7 +98,14 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
return records
|
||||
.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
return CatalogProviders.Salesforce.mapInternetAddon(record, entry);
|
||||
const addon = CatalogProviders.Salesforce.mapInternetAddon(record, entry);
|
||||
return {
|
||||
...addon,
|
||||
catalogMetadata: {
|
||||
...addon.catalogMetadata,
|
||||
addonType: inferAddonTypeFromSku(addon.sku ?? ""),
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||
}
|
||||
@ -160,78 +180,4 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
return plan.internetOfferingType === eligibility;
|
||||
}
|
||||
|
||||
private getTierData(tier: string) {
|
||||
const tierData: Record<
|
||||
string,
|
||||
{ description: string; tierDescription: string; features: string[] }
|
||||
> = {
|
||||
Silver: {
|
||||
description: "Simple package with broadband-modem and ISP only",
|
||||
tierDescription: "Simple package with broadband-modem and ISP only",
|
||||
features: [
|
||||
"NTT modem + ISP connection",
|
||||
"Two ISP connection protocols: IPoE (recommended) or PPPoE",
|
||||
"Self-configuration of router (you provide your own)",
|
||||
"Monthly: ¥6,000 | One-time: ¥22,800",
|
||||
],
|
||||
},
|
||||
Gold: {
|
||||
description: "Standard all-inclusive package with basic Wi-Fi",
|
||||
tierDescription: "Standard all-inclusive package with basic Wi-Fi",
|
||||
features: [
|
||||
"NTT modem + wireless router (rental)",
|
||||
"ISP (IPoE) configured automatically within 24 hours",
|
||||
"Basic wireless router included",
|
||||
"Optional: TP-LINK RE650 range extender (¥500/month)",
|
||||
"Monthly: ¥6,500 | One-time: ¥22,800",
|
||||
],
|
||||
},
|
||||
Platinum: {
|
||||
description:
|
||||
"Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
|
||||
tierDescription: "Tailored set up with premier Wi-Fi management support",
|
||||
features: [
|
||||
"NTT modem + Netgear INSIGHT Wi-Fi routers",
|
||||
"Cloud management support for remote router management",
|
||||
"Automatic updates and quicker support",
|
||||
"Seamless wireless network setup",
|
||||
"Monthly: ¥6,500 | One-time: ¥22,800",
|
||||
"Cloud management: ¥500/month per router",
|
||||
],
|
||||
},
|
||||
};
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
24
apps/bff/src/modules/currency/currency.controller.ts
Normal file
24
apps/bff/src/modules/currency/currency.controller.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { WhmcsCurrencyService } from "../../integrations/whmcs/services/whmcs-currency.service";
|
||||
|
||||
@Controller("currency")
|
||||
export class CurrencyController {
|
||||
constructor(private readonly currencyService: WhmcsCurrencyService) {}
|
||||
|
||||
@Get("default")
|
||||
getDefaultCurrency() {
|
||||
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
||||
return {
|
||||
code: defaultCurrency.code,
|
||||
prefix: defaultCurrency.prefix,
|
||||
suffix: defaultCurrency.suffix,
|
||||
format: defaultCurrency.format,
|
||||
rate: defaultCurrency.rate,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("all")
|
||||
getAllCurrencies() {
|
||||
return this.currencyService.getAllCurrencies();
|
||||
}
|
||||
}
|
||||
11
apps/bff/src/modules/currency/currency.module.ts
Normal file
11
apps/bff/src/modules/currency/currency.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CurrencyController } from "./currency.controller";
|
||||
import { WhmcsCurrencyService } from "../../integrations/whmcs/services/whmcs-currency.service";
|
||||
import { WhmcsModule } from "../../integrations/whmcs/whmcs.module";
|
||||
|
||||
@Module({
|
||||
imports: [WhmcsModule],
|
||||
controllers: [CurrencyController],
|
||||
providers: [],
|
||||
})
|
||||
export class CurrencyModule {}
|
||||
@ -9,6 +9,8 @@ import {
|
||||
type CreateOrderRequest,
|
||||
type SfOrderIdParam,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { apiSuccessResponseSchema } from "@customer-portal/domain/common";
|
||||
import { z } from "zod";
|
||||
|
||||
@Controller("orders")
|
||||
export class OrdersController {
|
||||
@ -17,6 +19,14 @@ export class OrdersController {
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
private readonly createOrderResponseSchema = apiSuccessResponseSchema(
|
||||
z.object({
|
||||
sfOrderId: z.string(),
|
||||
status: z.string(),
|
||||
message: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
@Post()
|
||||
@UsePipes(new ZodValidationPipe(createOrderRequestSchema))
|
||||
async create(@Request() req: RequestWithUser, @Body() body: CreateOrderRequest) {
|
||||
@ -30,7 +40,8 @@ export class OrdersController {
|
||||
);
|
||||
|
||||
try {
|
||||
return await this.orderOrchestrator.createOrder(req.user.id, body);
|
||||
const result = await this.orderOrchestrator.createOrder(req.user.id, body);
|
||||
return this.createOrderResponseSchema.parse({ success: true, data: result });
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{
|
||||
|
||||
@ -9,12 +9,6 @@ import {
|
||||
orderBusinessValidationSchema,
|
||||
type CreateOrderRequest,
|
||||
type OrderBusinessValidation,
|
||||
// Import validation helpers from domain
|
||||
getOrderTypeValidationError,
|
||||
hasSimServicePlan,
|
||||
hasSimActivationFee,
|
||||
hasVpnActivationFee,
|
||||
hasInternetServicePlan,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import type { Providers } from "@customer-portal/domain/subscriptions";
|
||||
|
||||
@ -163,35 +157,6 @@ export class OrderValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate business rules based on order type
|
||||
*
|
||||
* Note: SKU-based validation logic has been moved to @customer-portal/domain/orders/validation
|
||||
* This method now delegates to domain validation helpers for consistency.
|
||||
*/
|
||||
validateBusinessRules(orderType: string, skus: string[]): void {
|
||||
// Use domain validation helper to get specific error message
|
||||
const validationError = getOrderTypeValidationError(orderType, skus);
|
||||
|
||||
if (validationError) {
|
||||
this.logger.warn({ orderType, skus }, `Order validation failed: ${validationError}`);
|
||||
throw new BadRequestException(validationError);
|
||||
}
|
||||
|
||||
// Log successful validation for specific order types
|
||||
switch (orderType) {
|
||||
case "SIM":
|
||||
this.logger.debug({ orderType, skus }, "SIM order validation passed (has service + activation)");
|
||||
break;
|
||||
case "VPN":
|
||||
this.logger.debug({ orderType, skus }, "VPN order validation passed (has activation)");
|
||||
break;
|
||||
case "Internet":
|
||||
this.logger.debug({ orderType, skus }, "Internet order validation passed (has service)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete order validation - performs ALL validation (format + business)
|
||||
*/
|
||||
@ -237,7 +202,6 @@ export class OrderValidator {
|
||||
// 3. SKU validation
|
||||
const pricebookId = await this.pricebookService.findPortalPricebookId();
|
||||
await this.validateSKUs(businessValidatedBody.skus, pricebookId);
|
||||
this.validateBusinessRules(businessValidatedBody.orderType, businessValidatedBody.skus);
|
||||
|
||||
// 4. Order-specific business validation
|
||||
if (businessValidatedBody.orderType === "Internet") {
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
export interface SimValidationResult {
|
||||
account: string;
|
||||
}
|
||||
|
||||
export interface SimNotificationContext {
|
||||
userId: string;
|
||||
subscriptionId: number;
|
||||
account?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
// Notification types for SIM management (BFF-specific, not domain types)
|
||||
export type SimNotificationContext = Record<string, unknown>;
|
||||
|
||||
export interface SimActionNotification {
|
||||
action: string;
|
||||
status: "SUCCESS" | "ERROR";
|
||||
context: SimNotificationContext;
|
||||
}
|
||||
|
||||
export interface SimValidationResult {
|
||||
account: string;
|
||||
}
|
||||
|
||||
@ -3,11 +3,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { SubscriptionsService } from "../../subscriptions.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SimValidationResult } from "../interfaces/sim-base.interface";
|
||||
import {
|
||||
isSimSubscription,
|
||||
extractSimAccountFromSubscription,
|
||||
cleanSimAccount,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { cleanSimAccount, extractSimAccountFromSubscription, isSimSubscription } from "@customer-portal/domain/sim";
|
||||
|
||||
@Injectable()
|
||||
export class SimValidationService {
|
||||
|
||||
33
apps/bff/test/catalog-contract.spec.ts
Normal file
33
apps/bff/test/catalog-contract.spec.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/// <reference types="jest" />
|
||||
import request from "supertest";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { Test } from "@nestjs/testing";
|
||||
|
||||
import { AppModule } from "../src/app.module";
|
||||
import { parseInternetCatalog } from "@customer-portal/domain/catalog";
|
||||
|
||||
describe("Catalog contract", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should return internet catalog matching domain schema", async () => {
|
||||
const response = await request(app.getHttpServer()).get("/catalog/internet/plans");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(() => parseInternetCatalog(response.body.data)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
27
apps/bff/test/jest-e2e.json
Normal file
27
apps/bff/test/jest-e2e.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig": {
|
||||
"types": ["jest", "node"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^@bff/(.*)$": "<rootDir>/../src/$1"
|
||||
},
|
||||
"globals": {
|
||||
"ts-jest": {
|
||||
"tsconfig": {
|
||||
"types": ["jest", "node"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
apps/bff/test/tsconfig.json
Normal file
8
apps/bff/test/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["jest", "node", "supertest"]
|
||||
},
|
||||
"include": ["./**/*.ts", "../src/**/*"]
|
||||
}
|
||||
|
||||
@ -9,11 +9,10 @@ interface InvoiceTotalsProps {
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export function InvoiceTotals({ subtotal, tax, total, currency }: InvoiceTotalsProps) {
|
||||
const fmt = (amount: number) => formatCurrency(amount, currency);
|
||||
export function InvoiceTotals({ subtotal, tax, total }: InvoiceTotalsProps) {
|
||||
const fmt = (amount: number) => formatCurrency(amount);
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
@ -38,7 +37,7 @@ export function InvoiceTotals({ subtotal, tax, total, currency }: InvoiceTotalsP
|
||||
<span className="text-xl font-bold text-slate-900">Total Amount</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-3xl font-bold text-slate-900">{fmt(total)}</div>
|
||||
<div className="text-lg font-medium text-slate-500">{currency.toUpperCase()}</div>
|
||||
<div className="text-lg font-medium text-slate-500">JPY</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,25 +1,22 @@
|
||||
"use client";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function InvoiceItemRow({
|
||||
id,
|
||||
description,
|
||||
amount,
|
||||
currency,
|
||||
quantity,
|
||||
serviceId,
|
||||
}: {
|
||||
id: number;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
quantity?: number;
|
||||
serviceId?: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
@ -47,7 +44,7 @@ export function InvoiceItemRow({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900 ml-4 flex-shrink-0">
|
||||
{formatCurrency(amount, currency)}
|
||||
{formatCurrency(amount)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,23 +2,21 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { paymentMethodListSchema, type PaymentMethodList } from "@customer-portal/domain/payments";
|
||||
|
||||
type Tone = "info" | "success" | "warning" | "error";
|
||||
|
||||
interface UsePaymentRefreshOptions<T> {
|
||||
interface UsePaymentRefreshOptions {
|
||||
// Refetch function from usePaymentMethods
|
||||
refetch: () => Promise<{ data: T | undefined }>;
|
||||
// Given refetch result, determine if user has payment methods
|
||||
hasMethods: (data: T | undefined) => boolean;
|
||||
refetch: () => Promise<{ data: PaymentMethodList | undefined }>;
|
||||
// When true, attaches focus/visibility listeners to refresh automatically
|
||||
attachFocusListeners?: boolean;
|
||||
}
|
||||
|
||||
export function usePaymentRefresh<T>({
|
||||
export function usePaymentRefresh({
|
||||
refetch,
|
||||
hasMethods,
|
||||
attachFocusListeners = false,
|
||||
}: UsePaymentRefreshOptions<T>) {
|
||||
}: UsePaymentRefreshOptions) {
|
||||
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
|
||||
visible: false,
|
||||
text: "",
|
||||
@ -35,7 +33,9 @@ export function usePaymentRefresh<T>({
|
||||
// Payment methods cache refresh failed - silently continue
|
||||
}
|
||||
const result = await refetch();
|
||||
const has = hasMethods(result.data);
|
||||
const parsed = paymentMethodListSchema.safeParse(result.data ?? null);
|
||||
const list = parsed.success ? parsed.data : { paymentMethods: [], totalCount: 0 };
|
||||
const has = list.totalCount > 0 || list.paymentMethods.length > 0;
|
||||
setToast({
|
||||
visible: true,
|
||||
text: has ? "Payment methods updated" : "No payment method found yet",
|
||||
|
||||
@ -188,7 +188,6 @@ export function InvoiceDetailContainer() {
|
||||
subtotal={invoice.subtotal}
|
||||
tax={invoice.tax}
|
||||
total={invoice.total}
|
||||
currency={invoice.currency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,6 +8,9 @@ import {
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@ -125,8 +128,6 @@ export function EnhancedOrderSummary({
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => price.toLocaleString();
|
||||
|
||||
const monthlyItems = items.filter(item => item.billingCycle === "Monthly");
|
||||
const oneTimeItems = items.filter(item => item.billingCycle === "Onetime");
|
||||
const serviceItems = items.filter(item => item.itemClass === "Service");
|
||||
@ -143,11 +144,11 @@ export function EnhancedOrderSummary({
|
||||
{variant === "checkout" && (
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
¥{formatPrice(totals.monthlyTotal)}/mo
|
||||
¥{formatCurrency(totals.monthlyTotal)}/mo
|
||||
</div>
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<div className="text-sm text-orange-600 font-medium">
|
||||
+ ¥{formatPrice(totals.oneTimeTotal)} one-time
|
||||
+ ¥{formatCurrency(totals.oneTimeTotal)} one-time
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -208,7 +209,7 @@ export function EnhancedOrderSummary({
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 ml-4">
|
||||
¥{formatPrice(Number(item.monthlyPrice || item.unitPrice || 0))}
|
||||
¥{formatCurrency(Number(item.monthlyPrice || item.unitPrice || 0))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@ -230,7 +231,7 @@ export function EnhancedOrderSummary({
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-orange-600 ml-4">
|
||||
¥{formatPrice(Number(item.oneTimePrice || item.unitPrice || 0))}
|
||||
¥{formatCurrency(Number(item.oneTimePrice || item.unitPrice || 0))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@ -244,7 +245,7 @@ export function EnhancedOrderSummary({
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-green-700">Discount Applied</span>
|
||||
<span className="font-medium text-green-600">
|
||||
-¥{formatPrice(totals.discountAmount)}
|
||||
-¥{formatCurrency(totals.discountAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -255,7 +256,7 @@ export function EnhancedOrderSummary({
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-700">Tax (10%)</span>
|
||||
<span className="font-medium text-gray-900">¥{formatPrice(totals.taxAmount)}</span>
|
||||
<span className="font-medium text-gray-900">¥{formatCurrency(totals.taxAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -270,7 +271,7 @@ export function EnhancedOrderSummary({
|
||||
<span className="text-gray-700">{String(item.name)}</span>
|
||||
<span className="font-medium">
|
||||
¥
|
||||
{formatPrice(
|
||||
{formatCurrency(
|
||||
Number(
|
||||
item.billingCycle === "Monthly"
|
||||
? item.monthlyPrice || item.unitPrice || 0
|
||||
@ -289,20 +290,20 @@ export function EnhancedOrderSummary({
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-lg font-semibold">
|
||||
<span className="text-gray-900">Monthly Total:</span>
|
||||
<span className="text-blue-600">¥{formatPrice(totals.monthlyTotal)}</span>
|
||||
<span className="text-blue-600">{formatCurrency(totals.monthlyTotal)}</span>
|
||||
</div>
|
||||
|
||||
{totals.oneTimeTotal > 0 && (
|
||||
<div className="flex justify-between text-lg font-semibold">
|
||||
<span className="text-gray-900">One-time Total:</span>
|
||||
<span className="text-orange-600">¥{formatPrice(totals.oneTimeTotal)}</span>
|
||||
<span className="text-orange-600">{formatCurrency(totals.oneTimeTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totals.annualTotal && (
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>Annual Total:</span>
|
||||
<span>¥{formatPrice(totals.annualTotal)}</span>
|
||||
<span>{formatCurrency(totals.annualTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,9 @@
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { CurrencyYenIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
const { formatCurrency } = Formatting;
|
||||
|
||||
export interface PricingTier {
|
||||
name: string;
|
||||
@ -57,9 +60,6 @@ export function PricingDisplay({
|
||||
infoText,
|
||||
children,
|
||||
}: PricingDisplayProps) {
|
||||
const formatPrice = (price: number) => {
|
||||
return price.toLocaleString();
|
||||
};
|
||||
|
||||
const getCurrencyIcon = () => {
|
||||
if (!showCurrencySymbol) return null;
|
||||
@ -117,7 +117,7 @@ export function PricingDisplay({
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
{getCurrencyIcon()}
|
||||
<span className={`font-bold text-gray-900 ${sizeClasses[size].price}`}>
|
||||
{formatPrice(tier.price)}
|
||||
{formatCurrency(tier.price)}
|
||||
</span>
|
||||
<span className={`text-gray-500 ${sizeClasses[size].label}`}>
|
||||
/{tier.billingCycle.toLowerCase()}
|
||||
@ -167,17 +167,17 @@ export function PricingDisplay({
|
||||
<div className="flex items-baseline gap-1">
|
||||
{getCurrencyIcon()}
|
||||
<span className={`font-bold text-gray-900 ${sizeClasses[size].price}`}>
|
||||
{formatPrice(monthlyPrice)}
|
||||
{formatCurrency(monthlyPrice)}
|
||||
</span>
|
||||
<span className={`text-gray-500 ${sizeClasses[size].label}`}>/month</span>
|
||||
</div>
|
||||
{originalMonthlyPrice && originalMonthlyPrice > monthlyPrice && (
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className="text-gray-400 line-through text-sm">
|
||||
¥{formatPrice(originalMonthlyPrice)}
|
||||
¥{formatCurrency(originalMonthlyPrice)}
|
||||
</span>
|
||||
<span className="text-green-600 text-sm font-medium">
|
||||
Save ¥{formatPrice(originalMonthlyPrice - monthlyPrice)}/month
|
||||
Save ¥{formatCurrency(originalMonthlyPrice - monthlyPrice)}/month
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@ -190,17 +190,17 @@ export function PricingDisplay({
|
||||
<div className="flex items-baseline gap-1">
|
||||
{getCurrencyIcon()}
|
||||
<span className={`font-bold text-orange-600 ${sizeClasses[size].price}`}>
|
||||
{formatPrice(oneTimePrice)}
|
||||
{formatCurrency(oneTimePrice)}
|
||||
</span>
|
||||
<span className={`text-orange-500 ${sizeClasses[size].label}`}>one-time</span>
|
||||
</div>
|
||||
{originalOneTimePrice && originalOneTimePrice > oneTimePrice && (
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className="text-gray-400 line-through text-sm">
|
||||
¥{formatPrice(originalOneTimePrice)}
|
||||
¥{formatCurrency(originalOneTimePrice)}
|
||||
</span>
|
||||
<span className="text-green-600 text-sm font-medium">
|
||||
Save ¥{formatPrice(originalOneTimePrice - oneTimePrice)}
|
||||
Save ¥{formatCurrency(originalOneTimePrice - oneTimePrice)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@ -232,7 +232,7 @@ export function PricingDisplay({
|
||||
<div className="flex items-baseline gap-1 mb-2">
|
||||
{getCurrencyIcon()}
|
||||
<span className={`font-bold text-gray-900 ${sizeClasses[size].price}`}>
|
||||
{formatPrice(monthlyPrice)}
|
||||
{formatCurrency(monthlyPrice)}
|
||||
</span>
|
||||
<span className={`text-gray-500 ${sizeClasses[size].label}`}>/month</span>
|
||||
</div>
|
||||
@ -242,7 +242,7 @@ export function PricingDisplay({
|
||||
<div className="flex items-baseline gap-1">
|
||||
{getCurrencyIcon()}
|
||||
<span className={`font-bold text-orange-600 ${sizeClasses[size].price}`}>
|
||||
{formatPrice(oneTimePrice)}
|
||||
{formatCurrency(oneTimePrice)}
|
||||
</span>
|
||||
<span className={`text-orange-500 ${sizeClasses[size].label}`}>one-time</span>
|
||||
</div>
|
||||
|
||||
@ -1,143 +1,76 @@
|
||||
import { apiClient, getDataOrDefault, getDataOrThrow } from "@/lib/api";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
internetPlanCatalogItemSchema,
|
||||
EMPTY_INTERNET_CATALOG,
|
||||
EMPTY_SIM_CATALOG,
|
||||
EMPTY_VPN_CATALOG,
|
||||
internetInstallationCatalogItemSchema,
|
||||
internetAddonCatalogItemSchema,
|
||||
simCatalogProductSchema,
|
||||
parseInternetCatalog,
|
||||
parseSimCatalog,
|
||||
parseVpnCatalog,
|
||||
simActivationFeeCatalogItemSchema,
|
||||
simCatalogProductSchema,
|
||||
vpnCatalogProductSchema,
|
||||
type InternetCatalogCollection,
|
||||
type InternetAddonCatalogItem,
|
||||
type InternetInstallationCatalogItem,
|
||||
type SimActivationFeeCatalogItem,
|
||||
type SimCatalogCollection,
|
||||
type SimCatalogProduct,
|
||||
type VpnCatalogCollection,
|
||||
type VpnCatalogProduct,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import type {
|
||||
InternetPlanCatalogItem,
|
||||
InternetInstallationCatalogItem,
|
||||
InternetAddonCatalogItem,
|
||||
SimCatalogProduct,
|
||||
SimActivationFeeCatalogItem,
|
||||
VpnCatalogProduct,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
|
||||
const emptyInternetPlans: InternetPlanCatalogItem[] = [];
|
||||
const emptyInternetAddons: InternetAddonCatalogItem[] = [];
|
||||
const emptyInternetInstallations: InternetInstallationCatalogItem[] = [];
|
||||
const emptySimPlans: SimCatalogProduct[] = [];
|
||||
const emptySimAddons: SimCatalogProduct[] = [];
|
||||
const emptySimActivationFees: SimActivationFeeCatalogItem[] = [];
|
||||
const emptyVpnPlans: VpnCatalogProduct[] = [];
|
||||
|
||||
const defaultInternetCatalog = {
|
||||
plans: emptyInternetPlans,
|
||||
installations: emptyInternetInstallations,
|
||||
addons: emptyInternetAddons,
|
||||
};
|
||||
|
||||
const defaultSimCatalog = {
|
||||
plans: emptySimPlans,
|
||||
activationFees: emptySimActivationFees,
|
||||
addons: emptySimAddons,
|
||||
};
|
||||
|
||||
const defaultVpnCatalog = {
|
||||
plans: emptyVpnPlans,
|
||||
activationFees: emptyVpnPlans,
|
||||
};
|
||||
|
||||
const internetCatalogSchema = z.object({
|
||||
plans: z.array(internetPlanCatalogItemSchema),
|
||||
installations: z.array(internetInstallationCatalogItemSchema),
|
||||
addons: z.array(internetAddonCatalogItemSchema),
|
||||
});
|
||||
|
||||
const internetInstallationsSchema = z.array(internetInstallationCatalogItemSchema);
|
||||
const internetAddonsSchema = z.array(internetAddonCatalogItemSchema);
|
||||
|
||||
const simCatalogSchema = z.object({
|
||||
plans: z.array(simCatalogProductSchema),
|
||||
activationFees: z.array(simActivationFeeCatalogItemSchema),
|
||||
addons: z.array(simCatalogProductSchema),
|
||||
});
|
||||
|
||||
const simActivationFeesSchema = z.array(simActivationFeeCatalogItemSchema);
|
||||
const simAddonsSchema = z.array(simCatalogProductSchema);
|
||||
|
||||
const vpnCatalogSchema = z.object({
|
||||
plans: z.array(vpnCatalogProductSchema),
|
||||
activationFees: z.array(vpnCatalogProductSchema),
|
||||
});
|
||||
|
||||
const vpnActivationFeesSchema = z.array(vpnCatalogProductSchema);
|
||||
import type { InternetPlanCatalogItem } from "@customer-portal/domain/catalog";
|
||||
|
||||
export const catalogService = {
|
||||
async getInternetCatalog(): Promise<{
|
||||
plans: InternetPlanCatalogItem[];
|
||||
installations: InternetInstallationCatalogItem[];
|
||||
addons: InternetAddonCatalogItem[];
|
||||
}> {
|
||||
const response = await apiClient.GET<typeof defaultInternetCatalog>(
|
||||
"/api/catalog/internet/plans"
|
||||
);
|
||||
const data = getDataOrThrow<typeof defaultInternetCatalog>(
|
||||
async getInternetCatalog(): Promise<InternetCatalogCollection> {
|
||||
const response = await apiClient.GET<InternetCatalogCollection>("/api/catalog/internet/plans");
|
||||
const data = getDataOrThrow<InternetCatalogCollection>(
|
||||
response,
|
||||
"Failed to load internet catalog"
|
||||
);
|
||||
return internetCatalogSchema.parse(data);
|
||||
return parseInternetCatalog(data);
|
||||
},
|
||||
|
||||
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
||||
const response = await apiClient.GET<InternetInstallationCatalogItem[]>(
|
||||
"/api/catalog/internet/installations"
|
||||
);
|
||||
const data = getDataOrDefault<InternetInstallationCatalogItem[]>(
|
||||
response,
|
||||
emptyInternetInstallations
|
||||
);
|
||||
return internetInstallationsSchema.parse(data);
|
||||
const response = await apiClient.GET<InternetInstallationCatalogItem[]>("/api/catalog/internet/installations");
|
||||
const data = getDataOrDefault<InternetInstallationCatalogItem[]>(response, []);
|
||||
return internetInstallationCatalogItemSchema.array().parse(data);
|
||||
},
|
||||
|
||||
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
|
||||
const response = await apiClient.GET<InternetAddonCatalogItem[]>(
|
||||
"/api/catalog/internet/addons"
|
||||
);
|
||||
const data = getDataOrDefault<InternetAddonCatalogItem[]>(response, emptyInternetAddons);
|
||||
return internetAddonsSchema.parse(data);
|
||||
const response = await apiClient.GET<InternetAddonCatalogItem[]>("/api/catalog/internet/addons");
|
||||
const data = getDataOrDefault<InternetAddonCatalogItem[]>(response, []);
|
||||
return internetAddonCatalogItemSchema.array().parse(data);
|
||||
},
|
||||
|
||||
async getSimCatalog(): Promise<{
|
||||
plans: SimCatalogProduct[];
|
||||
activationFees: SimActivationFeeCatalogItem[];
|
||||
addons: SimCatalogProduct[];
|
||||
}> {
|
||||
const response = await apiClient.GET<typeof defaultSimCatalog>("/api/catalog/sim/plans");
|
||||
const data = getDataOrDefault<typeof defaultSimCatalog>(response, defaultSimCatalog);
|
||||
return simCatalogSchema.parse(data);
|
||||
async getSimCatalog(): Promise<SimCatalogCollection> {
|
||||
const response = await apiClient.GET<SimCatalogCollection>("/api/catalog/sim/plans");
|
||||
const data = getDataOrDefault<SimCatalogCollection>(response, EMPTY_SIM_CATALOG);
|
||||
return parseSimCatalog(data);
|
||||
},
|
||||
|
||||
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>(
|
||||
"/api/catalog/sim/activation-fees"
|
||||
);
|
||||
const data = getDataOrDefault<SimActivationFeeCatalogItem[]>(response, emptySimActivationFees);
|
||||
return simActivationFeesSchema.parse(data);
|
||||
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>("/api/catalog/sim/activation-fees");
|
||||
const data = getDataOrDefault<SimActivationFeeCatalogItem[]>(response, []);
|
||||
return simActivationFeeCatalogItemSchema.array().parse(data);
|
||||
},
|
||||
|
||||
async getSimAddons(): Promise<SimCatalogProduct[]> {
|
||||
const response = await apiClient.GET<SimCatalogProduct[]>("/api/catalog/sim/addons");
|
||||
const data = getDataOrDefault<SimCatalogProduct[]>(response, emptySimAddons);
|
||||
return simAddonsSchema.parse(data);
|
||||
const data = getDataOrDefault<SimCatalogProduct[]>(response, []);
|
||||
return simCatalogProductSchema.array().parse(data);
|
||||
},
|
||||
|
||||
async getVpnCatalog(): Promise<{
|
||||
plans: VpnCatalogProduct[];
|
||||
activationFees: VpnCatalogProduct[];
|
||||
}> {
|
||||
const response = await apiClient.GET<typeof defaultVpnCatalog>("/api/catalog/vpn/plans");
|
||||
const data = getDataOrDefault<typeof defaultVpnCatalog>(response, defaultVpnCatalog);
|
||||
return vpnCatalogSchema.parse(data);
|
||||
async getVpnCatalog(): Promise<VpnCatalogCollection> {
|
||||
const response = await apiClient.GET<VpnCatalogCollection>("/api/catalog/vpn/plans");
|
||||
const data = getDataOrDefault<VpnCatalogCollection>(response, EMPTY_VPN_CATALOG);
|
||||
return parseVpnCatalog(data);
|
||||
},
|
||||
|
||||
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||
const response = await apiClient.GET<VpnCatalogProduct[]>("/api/catalog/vpn/activation-fees");
|
||||
const data = getDataOrDefault<VpnCatalogProduct[]>(response, emptyVpnPlans);
|
||||
return vpnActivationFeesSchema.parse(data);
|
||||
const data = getDataOrDefault<VpnCatalogProduct[]>(response, []);
|
||||
return vpnCatalogProductSchema.array().parse(data);
|
||||
},
|
||||
};
|
||||
|
||||
@ -29,13 +29,6 @@ type CatalogProduct =
|
||||
| SimCatalogProduct
|
||||
| VpnCatalogProduct;
|
||||
|
||||
/**
|
||||
* Format price with currency (wrapper for centralized utility)
|
||||
*/
|
||||
export function formatPrice(price: number, currency: string = "JPY"): string {
|
||||
return formatCurrency(price, currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter products based on criteria
|
||||
* Note: This is a simplified version. In practice, filtering is done server-side via API params.
|
||||
|
||||
33
apps/portal/src/features/catalog/utils/pricing.ts
Normal file
33
apps/portal/src/features/catalog/utils/pricing.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
||||
|
||||
export interface PriceInfo {
|
||||
display: string;
|
||||
monthly: number | null;
|
||||
oneTime: number | null;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export function getDisplayPrice(item: CatalogProductBase): PriceInfo | null {
|
||||
const monthlyPrice = item.monthlyPrice ?? null;
|
||||
const oneTimePrice = item.oneTimePrice ?? null;
|
||||
const currency = "JPY";
|
||||
|
||||
if (monthlyPrice === null && oneTimePrice === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let display = "";
|
||||
if (monthlyPrice !== null && monthlyPrice > 0) {
|
||||
display = `¥${monthlyPrice.toLocaleString()}/month`;
|
||||
} else if (oneTimePrice !== null && oneTimePrice > 0) {
|
||||
display = `¥${oneTimePrice.toLocaleString()} (one-time)`;
|
||||
}
|
||||
|
||||
return {
|
||||
display,
|
||||
monthly: monthlyPrice,
|
||||
oneTime: oneTimePrice,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
||||
import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit";
|
||||
import type { AsyncState } from "@customer-portal/domain/toolkit";
|
||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
import { ORDER_TYPE, type OrderConfigurations, type OrderTypeValue } from "@customer-portal/domain/orders";
|
||||
|
||||
// Use domain Address type
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
@ -30,7 +31,7 @@ interface CheckoutTotals {
|
||||
interface CheckoutCart {
|
||||
items: CheckoutItem[];
|
||||
totals: CheckoutTotals;
|
||||
configuration: Record<string, unknown>;
|
||||
configuration: OrderConfigurations;
|
||||
}
|
||||
|
||||
export function useCheckout() {
|
||||
@ -57,21 +58,21 @@ export function useCheckout() {
|
||||
|
||||
const paymentRefresh = usePaymentRefresh({
|
||||
refetch: refetchPaymentMethods,
|
||||
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,
|
||||
attachFocusListeners: true,
|
||||
});
|
||||
|
||||
const orderType = useMemo(() => {
|
||||
const type = params.get("type") || "internet";
|
||||
switch (type.toLowerCase()) {
|
||||
const orderType: OrderTypeValue = useMemo(() => {
|
||||
const type = params.get("type")?.toLowerCase() ?? "internet";
|
||||
switch (type) {
|
||||
case "sim":
|
||||
return "SIM" as const;
|
||||
case "internet":
|
||||
return "Internet" as const;
|
||||
return ORDER_TYPE.SIM;
|
||||
case "vpn":
|
||||
return "VPN" as const;
|
||||
return ORDER_TYPE.VPN;
|
||||
case "other":
|
||||
return ORDER_TYPE.OTHER;
|
||||
case "internet":
|
||||
default:
|
||||
return "Other" as const;
|
||||
return ORDER_TYPE.INTERNET;
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
@ -229,7 +230,7 @@ export function useCheckout() {
|
||||
createSuccessState({
|
||||
items,
|
||||
totals,
|
||||
configuration: {},
|
||||
configuration: {} as OrderConfigurations,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
@ -265,30 +266,45 @@ export function useCheckout() {
|
||||
throw new Error("No products selected for order. Please go back and select products.");
|
||||
}
|
||||
|
||||
const configurations: Record<string, unknown> = {};
|
||||
if (selections.accessMode) configurations.accessMode = selections.accessMode;
|
||||
if (selections.simType) configurations.simType = selections.simType;
|
||||
if (selections.eid) configurations.eid = selections.eid;
|
||||
if (selections.activationType) configurations.activationType = selections.activationType;
|
||||
if (selections.scheduledAt) configurations.scheduledAt = selections.scheduledAt;
|
||||
if (selections.isMnp) configurations.isMnp = selections.isMnp;
|
||||
if (selections.reservationNumber) configurations.mnpNumber = selections.reservationNumber;
|
||||
if (selections.expiryDate) configurations.mnpExpiry = selections.expiryDate;
|
||||
if (selections.phoneNumber) configurations.mnpPhone = selections.phoneNumber;
|
||||
if (selections.mvnoAccountNumber)
|
||||
configurations.mvnoAccountNumber = selections.mvnoAccountNumber;
|
||||
if (selections.portingLastName) configurations.portingLastName = selections.portingLastName;
|
||||
if (selections.portingFirstName)
|
||||
configurations.portingFirstName = selections.portingFirstName;
|
||||
if (selections.portingLastNameKatakana)
|
||||
configurations.portingLastNameKatakana = selections.portingLastNameKatakana;
|
||||
if (selections.portingFirstNameKatakana)
|
||||
configurations.portingFirstNameKatakana = selections.portingFirstNameKatakana;
|
||||
if (selections.portingGender) configurations.portingGender = selections.portingGender;
|
||||
if (selections.portingDateOfBirth)
|
||||
configurations.portingDateOfBirth = selections.portingDateOfBirth;
|
||||
const configurations: OrderConfigurations = {
|
||||
...(selections.accessMode ? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] } : {}),
|
||||
...(selections.activationType
|
||||
? { activationType: selections.activationType as OrderConfigurations["activationType"] }
|
||||
: {}),
|
||||
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
|
||||
...(selections.simType ? { simType: selections.simType as OrderConfigurations["simType"] } : {}),
|
||||
...(selections.eid ? { eid: selections.eid } : {}),
|
||||
...(selections.isMnp ? { isMnp: selections.isMnp } : {}),
|
||||
...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}),
|
||||
...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}),
|
||||
...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}),
|
||||
...(selections.mvnoAccountNumber ? { mvnoAccountNumber: selections.mvnoAccountNumber } : {}),
|
||||
...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}),
|
||||
...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}),
|
||||
...(selections.portingLastNameKatakana
|
||||
? { portingLastNameKatakana: selections.portingLastNameKatakana }
|
||||
: {}),
|
||||
...(selections.portingFirstNameKatakana
|
||||
? { portingFirstNameKatakana: selections.portingFirstNameKatakana }
|
||||
: {}),
|
||||
...(selections.portingGender
|
||||
? {
|
||||
portingGender: selections.portingGender as OrderConfigurations["portingGender"],
|
||||
}
|
||||
: {}),
|
||||
...(selections.portingDateOfBirth ? { portingDateOfBirth: selections.portingDateOfBirth } : {}),
|
||||
};
|
||||
|
||||
if (confirmedAddress) configurations.address = confirmedAddress;
|
||||
if (confirmedAddress) {
|
||||
configurations.address = {
|
||||
street: confirmedAddress.street ?? undefined,
|
||||
streetLine2: confirmedAddress.streetLine2 ?? undefined,
|
||||
city: confirmedAddress.city ?? undefined,
|
||||
state: confirmedAddress.state ?? undefined,
|
||||
postalCode: confirmedAddress.postalCode ?? undefined,
|
||||
country: confirmedAddress.country ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const orderData = {
|
||||
orderType,
|
||||
|
||||
@ -33,7 +33,7 @@ export function UpcomingPaymentBanner({ invoice, onPay, loading }: UpcomingPayme
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(invoice.amount, invoice.currency || "JPY")}
|
||||
{formatCurrency(invoice.amount)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Exact due date: {format(new Date(invoice.dueDate), "MMMM d, yyyy")}
|
||||
|
||||
@ -194,7 +194,7 @@ export function DashboardView() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(upcomingInvoice.amount, upcomingInvoice.currency || "JPY")}
|
||||
{formatCurrency(upcomingInvoice.amount)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Exact due date: {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}
|
||||
|
||||
@ -1,17 +1,34 @@
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type { CreateOrderRequest, OrderDetails, OrderSummary } from "@customer-portal/domain/orders";
|
||||
import {
|
||||
createOrderRequest,
|
||||
orderDetailsSchema,
|
||||
orderSummarySchema,
|
||||
type CreateOrderRequest,
|
||||
type OrderDetails,
|
||||
type OrderSummary,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import {
|
||||
assertSuccess,
|
||||
type DomainApiResponse,
|
||||
} from "@/lib/api/response-helpers";
|
||||
|
||||
async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> {
|
||||
const response = await apiClient.POST("/api/orders", { body: payload });
|
||||
if (!response.data) {
|
||||
throw new Error("Order creation failed");
|
||||
}
|
||||
return response.data;
|
||||
const body = createOrderRequest({
|
||||
orderType: payload.orderType,
|
||||
skus: payload.skus,
|
||||
configurations: payload.configurations ?? undefined,
|
||||
});
|
||||
const response = await apiClient.POST("/api/orders", { body });
|
||||
const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>(
|
||||
response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }>
|
||||
);
|
||||
return { sfOrderId: parsed.data.sfOrderId };
|
||||
}
|
||||
|
||||
async function getMyOrders(): Promise<OrderSummary[]> {
|
||||
const response = await apiClient.GET("/api/orders/user");
|
||||
return (response.data ?? []) as OrderSummary[];
|
||||
const data = Array.isArray(response.data) ? response.data : [];
|
||||
return data.map(item => orderSummarySchema.parse(item));
|
||||
}
|
||||
|
||||
async function getOrderById(orderId: string): Promise<OrderDetails> {
|
||||
@ -21,7 +38,7 @@ async function getOrderById(orderId: string): Promise<OrderDetails> {
|
||||
if (!response.data) {
|
||||
throw new Error("Order not found");
|
||||
}
|
||||
return response.data as OrderDetails;
|
||||
return orderDetailsSchema.parse(response.data);
|
||||
}
|
||||
|
||||
export const ordersService = {
|
||||
|
||||
@ -111,7 +111,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
|
||||
<div>
|
||||
<p className="text-gray-500">Price</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{formatCurrency(subscription.amount, subscription.currency)}
|
||||
{formatCurrency(subscription.amount)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
|
||||
</div>
|
||||
@ -171,7 +171,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
|
||||
<div className="flex items-center space-x-6 text-sm">
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{formatCurrency(subscription.amount, "JPY")}
|
||||
{formatCurrency(subscription.amount)}
|
||||
</p>
|
||||
<p className="text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
|
||||
</div>
|
||||
|
||||
@ -135,7 +135,7 @@ export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetail
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(subscription.amount, "JPY")}
|
||||
{formatCurrency(subscription.amount)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{formatBillingLabel(subscription.cycle)}</p>
|
||||
</div>
|
||||
|
||||
@ -129,7 +129,7 @@ export function SubscriptionDetailContainer() {
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) =>
|
||||
sharedFormatCurrency(amount || 0, subscription?.currency ?? "JPY");
|
||||
sharedFormatCurrency(amount || 0);
|
||||
|
||||
const formatBillingLabel = (cycle: string) => {
|
||||
switch (cycle) {
|
||||
|
||||
@ -135,7 +135,7 @@ export function SubscriptionsListContainer() {
|
||||
render: (s: Subscription) => (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{formatCurrency(s.amount, s.currency)}
|
||||
{formatCurrency(s.amount)}
|
||||
</span>
|
||||
<div className="text-xs text-gray-500">
|
||||
{s.cycle === "Monthly"
|
||||
|
||||
@ -3,7 +3,7 @@ export type { ApiClient, AuthHeaderResolver, CreateClientOptions, QueryParams, P
|
||||
export { ApiError } from "./runtime/client";
|
||||
|
||||
// Re-export API helpers
|
||||
export * from "./helpers";
|
||||
export * from "./response-helpers";
|
||||
|
||||
// Import createClient for internal use
|
||||
import { createClient } from "./runtime/client";
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
import {
|
||||
apiErrorResponseSchema,
|
||||
apiSuccessResponseSchema,
|
||||
type ApiErrorResponse,
|
||||
type ApiSuccessResponse,
|
||||
} from "@customer-portal/domain/common";
|
||||
|
||||
/**
|
||||
* API Response Helper Types and Functions
|
||||
*
|
||||
@ -60,3 +67,26 @@ export function hasData<T>(response: ApiResponse<T>): boolean {
|
||||
return response.data !== undefined && !response.error;
|
||||
}
|
||||
|
||||
export type DomainApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
|
||||
export function assertSuccess<T>(response: DomainApiResponse<T>): ApiSuccessResponse<T> {
|
||||
if (response.success === true) {
|
||||
return response;
|
||||
}
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
export function parseDomainResponse<T>(response: DomainApiResponse<T>, parser?: (payload: T) => T): T {
|
||||
const success = assertSuccess(response);
|
||||
return parser ? parser(success.data) : success.data;
|
||||
}
|
||||
|
||||
export function parseDomainError(payload: unknown): ApiErrorResponse | null {
|
||||
const parsed = apiErrorResponseSchema.safeParse(payload);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function buildSuccessResponse<T>(data: T, schema: (value: T) => T): ApiSuccessResponse<T> {
|
||||
return apiSuccessResponseSchema({ parse: () => schema(data) } as any).parse({ success: true, data });
|
||||
}
|
||||
|
||||
|
||||
43
apps/portal/src/lib/hooks/useCurrency.ts
Normal file
43
apps/portal/src/lib/hooks/useCurrency.ts
Normal file
@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { currencyService, type CurrencyInfo } from "@/lib/services/currency.service";
|
||||
|
||||
export function useCurrency() {
|
||||
const [defaultCurrency, setDefaultCurrency] = useState<CurrencyInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCurrency = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const currency = await currencyService.getDefaultCurrency();
|
||||
setDefaultCurrency(currency);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load currency");
|
||||
// Fallback to JPY if API fails
|
||||
setDefaultCurrency({
|
||||
code: "JPY",
|
||||
prefix: "¥",
|
||||
suffix: "",
|
||||
format: "1",
|
||||
rate: "1.00000",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCurrency();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currency: defaultCurrency,
|
||||
loading,
|
||||
error,
|
||||
currencyCode: defaultCurrency?.code || "JPY",
|
||||
currencySymbol: defaultCurrency?.prefix || "¥",
|
||||
};
|
||||
}
|
||||
30
apps/portal/src/lib/hooks/useFormatCurrency.ts
Normal file
30
apps/portal/src/lib/hooks/useFormatCurrency.ts
Normal file
@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useCurrency } from "@/lib/hooks/useCurrency";
|
||||
import { formatCurrency as baseFormatCurrency } from "@customer-portal/domain/toolkit";
|
||||
|
||||
export function useFormatCurrency() {
|
||||
const { currencyCode, loading, error } = useCurrency();
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
if (loading) {
|
||||
// Show loading state or fallback
|
||||
return "¥" + amount.toLocaleString();
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Fallback to JPY if there's an error
|
||||
return baseFormatCurrency(amount, "JPY");
|
||||
}
|
||||
|
||||
// Use the currency from WHMCS API
|
||||
return baseFormatCurrency(amount, currencyCode);
|
||||
};
|
||||
|
||||
return {
|
||||
formatCurrency,
|
||||
currencyCode,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
34
apps/portal/src/lib/services/currency.service.ts
Normal file
34
apps/portal/src/lib/services/currency.service.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { apiClient } from "@/lib/api";
|
||||
|
||||
export interface CurrencyInfo {
|
||||
code: string;
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
format: string;
|
||||
rate: string;
|
||||
}
|
||||
|
||||
export interface CurrencyService {
|
||||
getDefaultCurrency(): Promise<CurrencyInfo>;
|
||||
getAllCurrencies(): Promise<CurrencyInfo[]>;
|
||||
}
|
||||
|
||||
class CurrencyServiceImpl implements CurrencyService {
|
||||
async getDefaultCurrency(): Promise<CurrencyInfo> {
|
||||
const response = await apiClient.GET("/api/currency/default");
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to get default currency");
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAllCurrencies(): Promise<CurrencyInfo[]> {
|
||||
const response = await apiClient.GET("/api/currency/all");
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to get currencies");
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const currencyService = new CurrencyServiceImpl();
|
||||
@ -4,17 +4,9 @@
|
||||
*/
|
||||
|
||||
import { ApiError as ClientApiError } from "@/lib/api";
|
||||
import { apiErrorResponseSchema, type ApiErrorResponse } from "@customer-portal/domain/common";
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
timestamp?: string;
|
||||
path?: string;
|
||||
}
|
||||
export type ApiErrorPayload = ApiErrorResponse;
|
||||
|
||||
export interface ApiErrorInfo {
|
||||
code: string;
|
||||
@ -69,14 +61,8 @@ export function getErrorInfo(error: unknown): ApiErrorInfo {
|
||||
}
|
||||
|
||||
export function isApiErrorPayload(error: unknown): error is ApiErrorPayload {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"success" in error &&
|
||||
(error as { success?: unknown }).success === false &&
|
||||
"error" in error &&
|
||||
typeof (error as { error?: unknown }).error === "object"
|
||||
);
|
||||
const parsed = apiErrorResponseSchema.safeParse(error);
|
||||
return parsed.success;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -184,11 +170,12 @@ function parseRawErrorBody(body: unknown): unknown {
|
||||
}
|
||||
|
||||
function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo | null {
|
||||
if (isApiErrorPayload(payload)) {
|
||||
const code = payload.error.code;
|
||||
const parsed = apiErrorResponseSchema.safeParse(payload);
|
||||
if (parsed.success) {
|
||||
const code = parsed.data.error.code;
|
||||
return {
|
||||
code,
|
||||
message: payload.error.message,
|
||||
message: parsed.data.error.message,
|
||||
shouldLogout: shouldLogoutForError(code) || status === 401,
|
||||
shouldRetry: shouldRetryForError(code),
|
||||
};
|
||||
|
||||
@ -42,3 +42,6 @@ export type {
|
||||
WhmcsCatalogProduct,
|
||||
WhmcsCatalogProductListResponse,
|
||||
} from "./providers/whmcs/raw.types";
|
||||
|
||||
// Utilities
|
||||
export * from "./utils";
|
||||
|
||||
@ -9,7 +9,6 @@ import type {
|
||||
InternetPlanCatalogItem,
|
||||
InternetInstallationCatalogItem,
|
||||
InternetAddonCatalogItem,
|
||||
InternetPlanTemplate,
|
||||
SimCatalogProduct,
|
||||
SimActivationFeeCatalogItem,
|
||||
VpnCatalogProduct,
|
||||
@ -18,70 +17,16 @@ import type {
|
||||
SalesforceProduct2WithPricebookEntries,
|
||||
SalesforcePricebookEntryRecord,
|
||||
} from "./raw.types";
|
||||
import {
|
||||
enrichInternetPlanMetadata,
|
||||
inferAddonTypeFromSku,
|
||||
inferInstallationTermFromSku,
|
||||
} from "../../utils";
|
||||
|
||||
// ============================================================================
|
||||
// Tier Templates (Hardcoded Product Metadata)
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT_PLAN_TEMPLATE: InternetPlanTemplate = {
|
||||
tierDescription: "Standard plan",
|
||||
description: undefined,
|
||||
features: undefined,
|
||||
};
|
||||
|
||||
function getTierTemplate(tier?: string): InternetPlanTemplate {
|
||||
if (!tier) {
|
||||
return DEFAULT_PLAN_TEMPLATE;
|
||||
}
|
||||
|
||||
const normalized = tier.toLowerCase();
|
||||
switch (normalized) {
|
||||
case "silver":
|
||||
return {
|
||||
tierDescription: "Simple package with broadband-modem and ISP only",
|
||||
description: "Simple package with broadband-modem and ISP only",
|
||||
features: [
|
||||
"NTT modem + ISP connection",
|
||||
"Two ISP connection protocols: IPoE (recommended) or PPPoE",
|
||||
"Self-configuration of router (you provide your own)",
|
||||
"Monthly: ¥6,000 | One-time: ¥22,800",
|
||||
],
|
||||
};
|
||||
case "gold":
|
||||
return {
|
||||
tierDescription: "Standard all-inclusive package with basic Wi-Fi",
|
||||
description: "Standard all-inclusive package with basic Wi-Fi",
|
||||
features: [
|
||||
"NTT modem + wireless router (rental)",
|
||||
"ISP (IPoE) configured automatically within 24 hours",
|
||||
"Basic wireless router included",
|
||||
"Optional: TP-LINK RE650 range extender (¥500/month)",
|
||||
"Monthly: ¥6,500 | One-time: ¥22,800",
|
||||
],
|
||||
};
|
||||
case "platinum":
|
||||
return {
|
||||
tierDescription: "Tailored set up with premier Wi-Fi management support",
|
||||
description:
|
||||
"Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
|
||||
features: [
|
||||
"NTT modem + Netgear INSIGHT Wi-Fi routers",
|
||||
"Cloud management support for remote router management",
|
||||
"Automatic updates and quicker support",
|
||||
"Seamless wireless network setup",
|
||||
"Monthly: ¥6,500 | One-time: ¥22,800",
|
||||
"Cloud management: ¥500/month per router",
|
||||
],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
tierDescription: `${tier} plan`,
|
||||
description: undefined,
|
||||
features: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
@ -115,6 +60,7 @@ function baseProduct(
|
||||
id: product.Id,
|
||||
sku,
|
||||
name: product.Name ?? sku,
|
||||
catalogMetadata: {},
|
||||
};
|
||||
|
||||
if (product.Description) base.description = product.Description;
|
||||
@ -148,20 +94,12 @@ export function mapInternetPlan(
|
||||
const base = baseProduct(product, pricebookEntry);
|
||||
const tier = product.Internet_Plan_Tier__c ?? undefined;
|
||||
const offeringType = product.Internet_Offering_Type__c ?? undefined;
|
||||
const tierData = getTierTemplate(tier);
|
||||
|
||||
return {
|
||||
return enrichInternetPlanMetadata({
|
||||
...base,
|
||||
internetPlanTier: tier,
|
||||
internetOfferingType: offeringType,
|
||||
features: tierData.features,
|
||||
catalogMetadata: {
|
||||
tierDescription: tierData.tierDescription,
|
||||
features: tierData.features,
|
||||
isRecommended: tier === "Gold",
|
||||
},
|
||||
description: base.description ?? tierData.description,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function mapInternetInstallation(
|
||||
@ -173,7 +111,8 @@ export function mapInternetInstallation(
|
||||
return {
|
||||
...base,
|
||||
catalogMetadata: {
|
||||
installationTerm: inferInstallationTypeFromSku(base.sku),
|
||||
...base.catalogMetadata,
|
||||
installationTerm: inferInstallationTermFromSku(base.sku),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -190,6 +129,10 @@ export function mapInternetAddon(
|
||||
...base,
|
||||
bundledAddonId,
|
||||
isBundledAddon,
|
||||
catalogMetadata: {
|
||||
...base.catalogMetadata,
|
||||
addonType: inferAddonTypeFromSku(base.sku),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ export const catalogProductBaseSchema = z.object({
|
||||
monthlyPrice: z.number().optional(),
|
||||
oneTimePrice: z.number().optional(),
|
||||
unitPrice: z.number().optional(),
|
||||
catalogMetadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
@ -70,6 +71,14 @@ export const internetAddonCatalogItemSchema = internetCatalogProductSchema.exten
|
||||
bundledAddonId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const internetCatalogCollectionSchema = z.object({
|
||||
plans: z.array(internetPlanCatalogItemSchema),
|
||||
installations: z.array(internetInstallationCatalogItemSchema),
|
||||
addons: z.array(internetAddonCatalogItemSchema),
|
||||
});
|
||||
|
||||
export const internetCatalogResponseSchema = internetCatalogCollectionSchema;
|
||||
|
||||
// ============================================================================
|
||||
// SIM Product Schemas
|
||||
// ============================================================================
|
||||
@ -88,6 +97,14 @@ export const simActivationFeeCatalogItemSchema = simCatalogProductSchema.extend(
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const simCatalogCollectionSchema = z.object({
|
||||
plans: z.array(simCatalogProductSchema),
|
||||
activationFees: z.array(simActivationFeeCatalogItemSchema),
|
||||
addons: z.array(simCatalogProductSchema),
|
||||
});
|
||||
|
||||
export const simCatalogResponseSchema = simCatalogCollectionSchema;
|
||||
|
||||
// ============================================================================
|
||||
// VPN Product Schema
|
||||
// ============================================================================
|
||||
@ -96,6 +113,13 @@ export const vpnCatalogProductSchema = catalogProductBaseSchema.extend({
|
||||
vpnRegion: z.string().optional(),
|
||||
});
|
||||
|
||||
export const vpnCatalogCollectionSchema = z.object({
|
||||
plans: z.array(vpnCatalogProductSchema),
|
||||
activationFees: z.array(vpnCatalogProductSchema),
|
||||
});
|
||||
|
||||
export const vpnCatalogResponseSchema = vpnCatalogCollectionSchema;
|
||||
|
||||
// ============================================================================
|
||||
// Inferred Types from Schemas (Schema-First Approach)
|
||||
// ============================================================================
|
||||
@ -109,13 +133,16 @@ export type InternetPlanTemplate = z.infer<typeof internetPlanTemplateSchema>;
|
||||
export type InternetPlanCatalogItem = z.infer<typeof internetPlanCatalogItemSchema>;
|
||||
export type InternetInstallationCatalogItem = z.infer<typeof internetInstallationCatalogItemSchema>;
|
||||
export type InternetAddonCatalogItem = z.infer<typeof internetAddonCatalogItemSchema>;
|
||||
export type InternetCatalogCollection = z.infer<typeof internetCatalogCollectionSchema>;
|
||||
|
||||
// SIM products
|
||||
export type SimCatalogProduct = z.infer<typeof simCatalogProductSchema>;
|
||||
export type SimActivationFeeCatalogItem = z.infer<typeof simActivationFeeCatalogItemSchema>;
|
||||
export type SimCatalogCollection = z.infer<typeof simCatalogCollectionSchema>;
|
||||
|
||||
// VPN products
|
||||
export type VpnCatalogProduct = z.infer<typeof vpnCatalogProductSchema>;
|
||||
export type VpnCatalogCollection = z.infer<typeof vpnCatalogCollectionSchema>;
|
||||
|
||||
// Union type for all catalog products
|
||||
export type CatalogProduct =
|
||||
|
||||
158
packages/domain/catalog/utils.ts
Normal file
158
packages/domain/catalog/utils.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import {
|
||||
internetCatalogResponseSchema,
|
||||
internetPlanCatalogItemSchema,
|
||||
simCatalogResponseSchema,
|
||||
vpnCatalogResponseSchema,
|
||||
type InternetCatalogCollection,
|
||||
type InternetPlanCatalogItem,
|
||||
type SimCatalogCollection,
|
||||
type VpnCatalogCollection,
|
||||
type InternetPlanTemplate,
|
||||
} from "./schema";
|
||||
|
||||
/**
|
||||
* Empty catalog defaults shared by portal and BFF.
|
||||
*/
|
||||
export const EMPTY_INTERNET_CATALOG: InternetCatalogCollection = {
|
||||
plans: [],
|
||||
installations: [],
|
||||
addons: [],
|
||||
};
|
||||
|
||||
export const EMPTY_SIM_CATALOG: SimCatalogCollection = {
|
||||
plans: [],
|
||||
activationFees: [],
|
||||
addons: [],
|
||||
};
|
||||
|
||||
export const EMPTY_VPN_CATALOG: VpnCatalogCollection = {
|
||||
plans: [],
|
||||
activationFees: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe parser helpers for catalog payloads coming from HTTP boundaries.
|
||||
*/
|
||||
export function parseInternetCatalog(data: unknown): InternetCatalogCollection {
|
||||
return internetCatalogResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
export function parseSimCatalog(data: unknown): SimCatalogCollection {
|
||||
return simCatalogResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
export function parseVpnCatalog(data: unknown): VpnCatalogCollection {
|
||||
return vpnCatalogResponseSchema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internet tier metadata map shared between BFF and portal presenters.
|
||||
*/
|
||||
const INTERNET_TIER_METADATA: Record<string, InternetPlanTemplate> = {
|
||||
silver: {
|
||||
tierDescription: "Simple package with broadband-modem and ISP only",
|
||||
description: "Simple package with broadband-modem and ISP only",
|
||||
features: [
|
||||
"NTT modem + ISP connection",
|
||||
"Two ISP connection protocols: IPoE (recommended) or PPPoE",
|
||||
"Self-configuration of router (you provide your own)",
|
||||
"Monthly: ¥6,000 | One-time: ¥22,800",
|
||||
],
|
||||
},
|
||||
gold: {
|
||||
tierDescription: "Standard all-inclusive package with basic Wi-Fi",
|
||||
description: "Standard all-inclusive package with basic Wi-Fi",
|
||||
features: [
|
||||
"NTT modem + wireless router (rental)",
|
||||
"ISP (IPoE) configured automatically within 24 hours",
|
||||
"Basic wireless router included",
|
||||
"Optional: TP-LINK RE650 range extender (¥500/month)",
|
||||
"Monthly: ¥6,500 | One-time: ¥22,800",
|
||||
],
|
||||
},
|
||||
platinum: {
|
||||
tierDescription: "Tailored set up with premier Wi-Fi management support",
|
||||
description:
|
||||
"Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
|
||||
features: [
|
||||
"NTT modem + Netgear INSIGHT Wi-Fi routers",
|
||||
"Cloud management support for remote router management",
|
||||
"Automatic updates and quicker support",
|
||||
"Seamless wireless network setup",
|
||||
"Monthly: ¥6,500 | One-time: ¥22,800",
|
||||
"Cloud management: ¥500/month per router",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_PLAN_TEMPLATE: InternetPlanTemplate = {
|
||||
tierDescription: "Standard plan",
|
||||
description: undefined,
|
||||
features: undefined,
|
||||
};
|
||||
|
||||
export function getInternetTierTemplate(tier?: string | null): InternetPlanTemplate {
|
||||
if (!tier) {
|
||||
return DEFAULT_PLAN_TEMPLATE;
|
||||
}
|
||||
|
||||
const normalized = tier.trim().toLowerCase();
|
||||
return INTERNET_TIER_METADATA[normalized] ?? {
|
||||
tierDescription: `${tier} plan`,
|
||||
description: undefined,
|
||||
features: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export type InternetInstallationTerm = "One-time" | "12-Month" | "24-Month";
|
||||
|
||||
export function inferInstallationTermFromSku(sku: string): InternetInstallationTerm {
|
||||
const normalized = sku.toLowerCase();
|
||||
if (normalized.includes("24")) return "24-Month";
|
||||
if (normalized.includes("12")) return "12-Month";
|
||||
return "One-time";
|
||||
}
|
||||
|
||||
export type InternetAddonType = "hikari-denwa-service" | "hikari-denwa-installation" | "other";
|
||||
|
||||
export function inferAddonTypeFromSku(sku: string): InternetAddonType {
|
||||
const upperSku = sku.toUpperCase();
|
||||
const isDenwa =
|
||||
upperSku.includes("DENWA") ||
|
||||
upperSku.includes("HOME-PHONE") ||
|
||||
upperSku.includes("PHONE");
|
||||
|
||||
if (!isDenwa) {
|
||||
return "other";
|
||||
}
|
||||
|
||||
const isInstallation =
|
||||
upperSku.includes("INSTALL") || upperSku.includes("SETUP") || upperSku.includes("ACTIVATION");
|
||||
|
||||
return isInstallation ? "hikari-denwa-installation" : "hikari-denwa-service";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to apply tier metadata to a plan item.
|
||||
*/
|
||||
export function enrichInternetPlanMetadata(plan: InternetPlanCatalogItem): InternetPlanCatalogItem {
|
||||
const template = getInternetTierTemplate(plan.internetPlanTier ?? null);
|
||||
const existingMetadata = plan.catalogMetadata ?? {};
|
||||
const metadata = {
|
||||
...existingMetadata,
|
||||
tierDescription: existingMetadata.tierDescription ?? template.tierDescription,
|
||||
features: existingMetadata.features ?? template.features,
|
||||
isRecommended:
|
||||
existingMetadata.isRecommended ?? (plan.internetPlanTier?.toLowerCase() === "gold" ? true : undefined),
|
||||
};
|
||||
|
||||
return internetPlanCatalogItemSchema.parse({
|
||||
...plan,
|
||||
description: plan.description ?? template.description,
|
||||
catalogMetadata: metadata,
|
||||
features: plan.features ?? template.features,
|
||||
});
|
||||
}
|
||||
|
||||
export const internetPlanCollectionSchema = internetPlanCatalogItemSchema.array();
|
||||
|
||||
@ -339,7 +339,7 @@ export type UserRole = "USER" | "ADMIN";
|
||||
export type Address = z.infer<typeof addressSchema>;
|
||||
export type AddressFormData = z.infer<typeof addressFormSchema>;
|
||||
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
|
||||
export type ProfileDisplayData = ProfileEditFormData; // Alias for display purposes
|
||||
export type ProfileDisplayData = z.infer<typeof profileDisplayDataSchema>;
|
||||
|
||||
// Convenience aliases
|
||||
export type UserProfile = User; // Alias for user profile
|
||||
|
||||
@ -27,6 +27,9 @@ export * from "./schema";
|
||||
// Validation (extended business rules)
|
||||
export * from "./validation";
|
||||
|
||||
// Utilities
|
||||
export * from "./utils";
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
// Order item types
|
||||
|
||||
73
packages/domain/orders/utils.ts
Normal file
73
packages/domain/orders/utils.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
orderConfigurationsSchema,
|
||||
type OrderConfigurations,
|
||||
type CreateOrderRequest,
|
||||
} from "./schema";
|
||||
import { ORDER_TYPE } from "./contract";
|
||||
|
||||
const orderSelectionsSchema = z.object({
|
||||
accessMode: z.enum(["IPoE-BYOR", "IPoE-HGW", "PPPoE"]).optional(),
|
||||
activationType: z.enum(["Immediate", "Scheduled"]).optional(),
|
||||
scheduledAt: z.string().optional(),
|
||||
simType: z.enum(["eSIM", "Physical SIM"]).optional(),
|
||||
eid: z.string().optional(),
|
||||
isMnp: z.string().optional(),
|
||||
mnpNumber: z.string().optional(),
|
||||
mnpExpiry: z.string().optional(),
|
||||
mnpPhone: z.string().optional(),
|
||||
mvnoAccountNumber: z.string().optional(),
|
||||
portingLastName: z.string().optional(),
|
||||
portingFirstName: z.string().optional(),
|
||||
portingLastNameKatakana: z.string().optional(),
|
||||
portingFirstNameKatakana: z.string().optional(),
|
||||
portingGender: z.enum(["Male", "Female", "Corporate/Other"]).optional(),
|
||||
portingDateOfBirth: z.string().optional(),
|
||||
address: z
|
||||
.object({
|
||||
street: z.string().optional(),
|
||||
streetLine2: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
postalCode: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type OrderSelections = z.infer<typeof orderSelectionsSchema>;
|
||||
|
||||
export function buildOrderConfigurations(selections: OrderSelections): OrderConfigurations {
|
||||
return orderConfigurationsSchema.parse({
|
||||
...selections,
|
||||
address: selections.address
|
||||
? {
|
||||
street: selections.address.street ?? null,
|
||||
streetLine2: selections.address.streetLine2 ?? null,
|
||||
city: selections.address.city ?? null,
|
||||
state: selections.address.state ?? null,
|
||||
postalCode: selections.address.postalCode ?? null,
|
||||
country: selections.address.country ?? null,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function createOrderRequest(payload: {
|
||||
orderType?: string;
|
||||
skus: string[];
|
||||
configurations?: OrderSelections | null;
|
||||
}): CreateOrderRequest {
|
||||
const orderType = (payload.orderType ?? ORDER_TYPE.OTHER) as CreateOrderRequest["orderType"];
|
||||
const configurations = payload.configurations
|
||||
? buildOrderConfigurations(payload.configurations)
|
||||
: undefined;
|
||||
return {
|
||||
orderType,
|
||||
skus: payload.skus,
|
||||
...(configurations ? { configurations } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -8,29 +8,31 @@
|
||||
export type SupportedCurrency = "JPY" | "USD" | "EUR";
|
||||
|
||||
/**
|
||||
* Format a number as currency
|
||||
* Format a number as currency using WHMCS default currency
|
||||
*
|
||||
* @param amount - The numeric amount to format
|
||||
* @param currency - Currency code (defaults to "JPY")
|
||||
* @param currencyCode - Optional currency code (defaults to WHMCS default)
|
||||
* @param locale - Optional locale (defaults to currency-specific locale)
|
||||
*
|
||||
* @example
|
||||
* formatCurrency(1000) // "¥1,000" (defaults to JPY)
|
||||
* formatCurrency(1000, "JPY") // "¥1,000"
|
||||
* formatCurrency(1000, invoice.currency) // Uses invoice currency
|
||||
* formatCurrency(1000) // Uses WHMCS default currency
|
||||
* formatCurrency(1000, "USD") // Uses specific currency
|
||||
* formatCurrency(1000, "JPY", "ja-JP") // Uses specific currency and locale
|
||||
*/
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
currency: string = "JPY"
|
||||
currencyCode: string = "JPY",
|
||||
locale?: string
|
||||
): string {
|
||||
// Determine locale from currency
|
||||
const locale = getCurrencyLocale(currency as SupportedCurrency);
|
||||
// Use provided locale or get from currency
|
||||
const currencyLocale = locale || getCurrencyLocale(currencyCode as SupportedCurrency);
|
||||
|
||||
// JPY doesn't use decimal places, other currencies use 2
|
||||
const fractionDigits = currency === "JPY" ? 0 : 2;
|
||||
// Determine fraction digits based on currency
|
||||
const fractionDigits = currencyCode === "JPY" ? 0 : 2;
|
||||
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
const formatter = new Intl.NumberFormat(currencyLocale, {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
currency: currencyCode,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user