Refactor order and subscription services to enhance validation and type safety. Updated order validation to use new SKU validation schema, improved SIM reissue profile handling by integrating a structured request object, and refined subscription fetching logic to utilize updated schemas. Additionally, enhanced error handling and logging for better traceability in service operations.
This commit is contained in:
parent
7ffd2d562f
commit
939922a40e
@ -6,7 +6,7 @@ import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/conn
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import {
|
||||
createOrderRequestSchema,
|
||||
orderBusinessValidationSchema,
|
||||
orderWithSkuValidationSchema,
|
||||
type CreateOrderRequest,
|
||||
type OrderBusinessValidation,
|
||||
} from "@customer-portal/domain/orders";
|
||||
@ -176,10 +176,11 @@ export class OrderValidator {
|
||||
// 1b. Business validation (ensures userId-specific constraints)
|
||||
let businessValidatedBody: OrderBusinessValidation;
|
||||
try {
|
||||
businessValidatedBody = orderBusinessValidationSchema.parse({
|
||||
const skuValidatedBody = orderWithSkuValidationSchema.parse({
|
||||
...validatedBody,
|
||||
userId,
|
||||
});
|
||||
businessValidatedBody = skuValidatedBody;
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const issues = error.issues.map(issue => {
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
SimCancelRequest,
|
||||
SimTopUpHistoryRequest,
|
||||
SimFeaturesUpdateRequest,
|
||||
SimReissueRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
|
||||
|
||||
@ -108,8 +109,12 @@ export class SimManagementService {
|
||||
/**
|
||||
* Reissue eSIM profile
|
||||
*/
|
||||
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
||||
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, newEid);
|
||||
async reissueEsimProfile(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimReissueRequest
|
||||
): Promise<void> {
|
||||
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, request);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -4,6 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f
|
||||
import { SimValidationService } from "./sim-validation.service";
|
||||
import { SimNotificationService } from "./sim-notification.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SimReissueRequest } from "@customer-portal/domain/sim";
|
||||
|
||||
@Injectable()
|
||||
export class EsimManagementService {
|
||||
@ -17,7 +18,11 @@ export class EsimManagementService {
|
||||
/**
|
||||
* Reissue eSIM profile
|
||||
*/
|
||||
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
||||
async reissueEsimProfile(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimReissueRequest
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
|
||||
@ -27,10 +32,9 @@ export class EsimManagementService {
|
||||
throw new BadRequestException("This operation is only available for eSIM subscriptions");
|
||||
}
|
||||
|
||||
const newEid = request.newEid;
|
||||
|
||||
if (newEid) {
|
||||
if (!/^\d{32}$/.test(newEid)) {
|
||||
throw new BadRequestException("Invalid EID format. Expected 32 digits.");
|
||||
}
|
||||
await this.freebitService.reissueEsimProfileEnhanced(account, newEid, {
|
||||
oldEid: simDetails.eid,
|
||||
planCode: simDetails.planCode,
|
||||
@ -60,12 +64,12 @@ export class EsimManagementService {
|
||||
error: sanitizedError,
|
||||
userId,
|
||||
subscriptionId,
|
||||
newEid: newEid || undefined,
|
||||
newEid: request.newEid || undefined,
|
||||
});
|
||||
await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
newEid: newEid || undefined,
|
||||
newEid: request.newEid || undefined,
|
||||
error: sanitizedError,
|
||||
});
|
||||
throw error;
|
||||
|
||||
@ -15,6 +15,7 @@ import type {
|
||||
SimCancelRequest,
|
||||
SimTopUpHistoryRequest,
|
||||
SimFeaturesUpdateRequest,
|
||||
SimReissueRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
|
||||
@Injectable()
|
||||
@ -98,8 +99,12 @@ export class SimOrchestratorService {
|
||||
/**
|
||||
* Reissue eSIM profile
|
||||
*/
|
||||
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
||||
return this.esimManagement.reissueEsimProfile(userId, subscriptionId, newEid);
|
||||
async reissueEsimProfile(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
request: SimReissueRequest
|
||||
): Promise<void> {
|
||||
return this.esimManagement.reissueEsimProfile(userId, subscriptionId, request);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -23,6 +23,9 @@ export class SimPlanService {
|
||||
subscriptionId: number,
|
||||
request: SimPlanChangeRequest
|
||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||
const assignGlobalIp = request.assignGlobalIp ?? false;
|
||||
let scheduledAt: string | undefined;
|
||||
|
||||
try {
|
||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
|
||||
@ -31,27 +34,35 @@ export class SimPlanService {
|
||||
throw new BadRequestException("Invalid plan code");
|
||||
}
|
||||
|
||||
// Automatically set to 1st of next month
|
||||
scheduledAt = request.scheduledAt;
|
||||
if (scheduledAt) {
|
||||
if (!/^\d{8}$/.test(scheduledAt)) {
|
||||
throw new BadRequestException("scheduledAt must be in YYYYMMDD format");
|
||||
}
|
||||
} else {
|
||||
// Default to the 1st of the next month if no schedule provided.
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
nextMonth.setDate(1); // Set to 1st of the month
|
||||
|
||||
// Format as YYYYMMDD for Freebit API
|
||||
nextMonth.setDate(1);
|
||||
const year = nextMonth.getFullYear();
|
||||
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(nextMonth.getDate()).padStart(2, "0");
|
||||
const scheduledAt = `${year}${month}${day}`;
|
||||
scheduledAt = `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, {
|
||||
this.logger.log("Submitting SIM plan change request", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
newPlanCode: request.newPlanCode,
|
||||
scheduledAt,
|
||||
assignGlobalIp,
|
||||
scheduleOrigin: request.scheduledAt ? "user-provided" : "auto-default",
|
||||
});
|
||||
|
||||
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
|
||||
assignGlobalIp: false, // Default to no global IP
|
||||
scheduledAt: scheduledAt,
|
||||
assignGlobalIp,
|
||||
scheduledAt: scheduledAt!,
|
||||
});
|
||||
|
||||
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||||
@ -59,8 +70,8 @@ export class SimPlanService {
|
||||
subscriptionId,
|
||||
account,
|
||||
newPlanCode: request.newPlanCode,
|
||||
scheduledAt: scheduledAt,
|
||||
assignGlobalIp: false,
|
||||
scheduledAt,
|
||||
assignGlobalIp,
|
||||
});
|
||||
|
||||
await this.simNotification.notifySimAction("Change Plan", "SUCCESS", {
|
||||
@ -69,6 +80,7 @@ export class SimPlanService {
|
||||
account,
|
||||
newPlanCode: request.newPlanCode,
|
||||
scheduledAt,
|
||||
assignGlobalIp,
|
||||
});
|
||||
|
||||
return result;
|
||||
@ -79,11 +91,15 @@ export class SimPlanService {
|
||||
userId,
|
||||
subscriptionId,
|
||||
newPlanCode: request.newPlanCode,
|
||||
assignGlobalIp,
|
||||
scheduledAt,
|
||||
});
|
||||
await this.simNotification.notifySimAction("Change Plan", "ERROR", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
newPlanCode: request.newPlanCode,
|
||||
assignGlobalIp,
|
||||
scheduledAt,
|
||||
error: sanitizedError,
|
||||
});
|
||||
throw error;
|
||||
|
||||
@ -32,10 +32,12 @@ import {
|
||||
simChangePlanRequestSchema,
|
||||
simCancelRequestSchema,
|
||||
simFeaturesRequestSchema,
|
||||
simReissueRequestSchema,
|
||||
type SimTopupRequest,
|
||||
type SimChangePlanRequest,
|
||||
type SimCancelRequest,
|
||||
type SimFeaturesRequest,
|
||||
type SimReissueRequest,
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { ZodValidationPipe } from "@bff/core/validation";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||
@ -52,12 +54,9 @@ export class SubscriptionsController {
|
||||
async getSubscriptions(
|
||||
@Request() req: RequestWithUser,
|
||||
@Query() query: SubscriptionQuery
|
||||
): Promise<SubscriptionList | Subscription[]> {
|
||||
if (query.status) {
|
||||
return this.subscriptionsService.getSubscriptionsByStatus(req.user.id, query.status);
|
||||
}
|
||||
|
||||
return this.subscriptionsService.getSubscriptions(req.user.id);
|
||||
): Promise<SubscriptionList> {
|
||||
const { status } = query;
|
||||
return this.subscriptionsService.getSubscriptions(req.user.id, { status });
|
||||
}
|
||||
|
||||
@Get("active")
|
||||
@ -185,12 +184,13 @@ export class SubscriptionsController {
|
||||
}
|
||||
|
||||
@Post(":id/sim/reissue-esim")
|
||||
@UsePipes(new ZodValidationPipe(simReissueRequestSchema))
|
||||
async reissueEsimProfile(
|
||||
@Request() req: RequestWithUser,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Body() body: { newEid?: string } = {}
|
||||
@Body() body: SimReissueRequest
|
||||
): Promise<SimActionResponse> {
|
||||
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid);
|
||||
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body);
|
||||
return { success: true, message: "eSIM profile reissue completed successfully" };
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,22 @@
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
|
||||
import {
|
||||
Subscription,
|
||||
SubscriptionList,
|
||||
subscriptionListSchema,
|
||||
subscriptionStatusSchema,
|
||||
type SubscriptionStatus,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { z } from "zod";
|
||||
import { subscriptionSchema } from "@customer-portal/domain/subscriptions";
|
||||
import type { Providers } from "@customer-portal/domain/subscriptions";
|
||||
|
||||
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
|
||||
|
||||
export interface GetSubscriptionsOptions {
|
||||
status?: string;
|
||||
status?: SubscriptionStatus;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@ -44,25 +48,18 @@ export class SubscriptionsService {
|
||||
{ status }
|
||||
);
|
||||
|
||||
const parsed = z
|
||||
.object({
|
||||
subscriptions: z.array(subscriptionSchema),
|
||||
totalCount: z.number(),
|
||||
})
|
||||
.safeParse(subscriptionList);
|
||||
const parsed = subscriptionListSchema.parse(subscriptionList);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.message);
|
||||
let subscriptions = parsed.subscriptions;
|
||||
if (status) {
|
||||
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
||||
subscriptions = subscriptions.filter(sub => sub.status === normalizedStatus);
|
||||
}
|
||||
|
||||
const filtered = status
|
||||
? parsed.data.subscriptions.filter(sub => sub.status.toLowerCase() === status.toLowerCase())
|
||||
: parsed.data.subscriptions;
|
||||
|
||||
return {
|
||||
subscriptions: filtered,
|
||||
totalCount: filtered.length,
|
||||
} satisfies SubscriptionList;
|
||||
return subscriptionListSchema.parse({
|
||||
subscriptions,
|
||||
totalCount: subscriptions.length,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get subscriptions for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
@ -141,22 +138,10 @@ export class SubscriptionsService {
|
||||
/**
|
||||
* Get subscriptions by status
|
||||
*/
|
||||
async getSubscriptionsByStatus(userId: string, status: string): Promise<Subscription[]> {
|
||||
async getSubscriptionsByStatus(userId: string, status: SubscriptionStatus): Promise<Subscription[]> {
|
||||
try {
|
||||
// Validate status
|
||||
const validStatuses = [
|
||||
"Active",
|
||||
"Suspended",
|
||||
"Terminated",
|
||||
"Cancelled",
|
||||
"Pending",
|
||||
"Completed",
|
||||
];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error(`Invalid status. Must be one of: ${validStatuses.join(", ")}`);
|
||||
}
|
||||
|
||||
const subscriptionList = await this.getSubscriptions(userId, { status });
|
||||
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
||||
const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus });
|
||||
return subscriptionList.subscriptions;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, {
|
||||
|
||||
@ -11,11 +11,14 @@ interface UsePaymentRefreshOptions {
|
||||
refetch: () => Promise<{ data: PaymentMethodList | undefined }>;
|
||||
// When true, attaches focus/visibility listeners to refresh automatically
|
||||
attachFocusListeners?: boolean;
|
||||
// Optional custom detector for whether payment methods exist
|
||||
hasMethods?: (data?: PaymentMethodList | undefined) => boolean;
|
||||
}
|
||||
|
||||
export function usePaymentRefresh({
|
||||
refetch,
|
||||
attachFocusListeners = false,
|
||||
hasMethods,
|
||||
}: UsePaymentRefreshOptions) {
|
||||
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
|
||||
visible: false,
|
||||
@ -35,7 +38,10 @@ export function usePaymentRefresh({
|
||||
const result = await refetch();
|
||||
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;
|
||||
const has =
|
||||
typeof hasMethods === "function"
|
||||
? hasMethods(parsed.success ? parsed.data : undefined)
|
||||
: list.totalCount > 0 || list.paymentMethods.length > 0;
|
||||
setToast({
|
||||
visible: true,
|
||||
text: has ? "Payment methods updated" : "No payment method found yet",
|
||||
|
||||
@ -42,7 +42,8 @@ export function PaymentMethodsContainer() {
|
||||
const result = await paymentMethodsQuery.refetch();
|
||||
return { data: result.data };
|
||||
},
|
||||
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,
|
||||
hasMethods: data =>
|
||||
Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
|
||||
attachFocusListeners: true,
|
||||
});
|
||||
|
||||
|
||||
@ -185,7 +185,7 @@ export function OrderSummary({
|
||||
<div key={index} className="flex justify-between">
|
||||
<span className="text-gray-600">{String(fee.name)}</span>
|
||||
<span className="font-medium">
|
||||
¥{getOneTimePrice(fee).toLocaleString()} one-time
|
||||
¥{(fee.oneTimePrice ?? fee.unitPrice ?? 0).toLocaleString()} one-time
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -53,8 +53,17 @@ function getPriceLabel(installation: InternetInstallationCatalogItem): string {
|
||||
if (!priceInfo) {
|
||||
return "Price not available";
|
||||
}
|
||||
const suffix = priceInfo.billingCycle === "Monthly" ? "/month" : " one-time";
|
||||
return `¥${priceInfo.amount.toLocaleString()}${suffix}`;
|
||||
const billingCycle = installation.billingCycle?.toLowerCase();
|
||||
if (billingCycle === "monthly" && priceInfo.monthly !== null) {
|
||||
return `¥${priceInfo.monthly.toLocaleString()}/month`;
|
||||
}
|
||||
if (priceInfo.oneTime !== null) {
|
||||
return `¥${priceInfo.oneTime.toLocaleString()} one-time`;
|
||||
}
|
||||
if (priceInfo.monthly !== null) {
|
||||
return `¥${priceInfo.monthly.toLocaleString()}`;
|
||||
}
|
||||
return priceInfo.display || "Price not available";
|
||||
}
|
||||
|
||||
export function InstallationOptions({
|
||||
|
||||
@ -169,7 +169,3 @@ function calculateOneTimeTotal(
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
type InstallationTerm = NonNullable<
|
||||
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
|
||||
>;
|
||||
|
||||
@ -1,17 +1,5 @@
|
||||
import React from "react";
|
||||
|
||||
interface MnpData {
|
||||
reservationNumber: string;
|
||||
expiryDate: string;
|
||||
phoneNumber: string;
|
||||
mvnoAccountNumber: string;
|
||||
portingLastName: string;
|
||||
portingFirstName: string;
|
||||
portingLastNameKatakana: string;
|
||||
portingFirstNameKatakana: string;
|
||||
portingGender: "Male" | "Female" | "Corporate/Other" | "";
|
||||
portingDateOfBirth: string;
|
||||
}
|
||||
import type { MnpData } from "@customer-portal/domain/sim";
|
||||
|
||||
interface MnpFormProps {
|
||||
wantsMnp: boolean;
|
||||
@ -77,7 +65,7 @@ export function MnpForm({
|
||||
<input
|
||||
type="text"
|
||||
id="reservationNumber"
|
||||
value={mnpData.reservationNumber}
|
||||
value={mnpData.reservationNumber ?? ""}
|
||||
onChange={e => handleInputChange("reservationNumber", e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="10-digit reservation number"
|
||||
@ -95,7 +83,7 @@ export function MnpForm({
|
||||
<input
|
||||
type="date"
|
||||
id="expiryDate"
|
||||
value={mnpData.expiryDate}
|
||||
value={mnpData.expiryDate ?? ""}
|
||||
onChange={e => handleInputChange("expiryDate", e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
@ -112,7 +100,7 @@ export function MnpForm({
|
||||
<input
|
||||
type="tel"
|
||||
id="phoneNumber"
|
||||
value={mnpData.phoneNumber}
|
||||
value={mnpData.phoneNumber ?? ""}
|
||||
onChange={e => handleInputChange("phoneNumber", e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="090-1234-5678"
|
||||
@ -133,7 +121,7 @@ export function MnpForm({
|
||||
<input
|
||||
type="text"
|
||||
id="mvnoAccountNumber"
|
||||
value={mnpData.mvnoAccountNumber}
|
||||
value={mnpData.mvnoAccountNumber ?? ""}
|
||||
onChange={e => handleInputChange("mvnoAccountNumber", e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Your current carrier account number"
|
||||
@ -151,7 +139,7 @@ export function MnpForm({
|
||||
<input
|
||||
type="text"
|
||||
id="portingLastName"
|
||||
value={mnpData.portingLastName}
|
||||
value={mnpData.portingLastName ?? ""}
|
||||
onChange={e => handleInputChange("portingLastName", e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Tanaka"
|
||||
@ -172,7 +160,7 @@ export function MnpForm({
|
||||
<input
|
||||
type="text"
|
||||
id="portingFirstName"
|
||||
value={mnpData.portingFirstName}
|
||||
value={mnpData.portingFirstName ?? ""}
|
||||
onChange={e => handleInputChange("portingFirstName", e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Taro"
|
||||
@ -193,7 +181,7 @@ export function MnpForm({
|
||||
<input
|
||||
type="text"
|
||||
id="portingLastNameKatakana"
|
||||
value={mnpData.portingLastNameKatakana}
|
||||
value={mnpData.portingLastNameKatakana ?? ""}
|
||||
onChange={e => handleInputChange("portingLastNameKatakana", e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="タナカ"
|
||||
@ -214,7 +202,7 @@ export function MnpForm({
|
||||
<input
|
||||
type="text"
|
||||
id="portingFirstNameKatakana"
|
||||
value={mnpData.portingFirstNameKatakana}
|
||||
value={mnpData.portingFirstNameKatakana ?? ""}
|
||||
onChange={e => handleInputChange("portingFirstNameKatakana", e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="タロウ"
|
||||
@ -234,10 +222,8 @@ export function MnpForm({
|
||||
</label>
|
||||
<select
|
||||
id="portingGender"
|
||||
value={mnpData.portingGender}
|
||||
onChange={e =>
|
||||
handleInputChange("portingGender", e.target.value as MnpData["portingGender"])
|
||||
}
|
||||
value={mnpData.portingGender ?? ""}
|
||||
onChange={e => handleInputChange("portingGender", e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Select gender</option>
|
||||
@ -261,7 +247,7 @@ export function MnpForm({
|
||||
<input
|
||||
type="date"
|
||||
id="portingDateOfBirth"
|
||||
value={mnpData.portingDateOfBirth}
|
||||
value={mnpData.portingDateOfBirth ?? ""}
|
||||
onChange={e => handleInputChange("portingDateOfBirth", e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
|
||||
@ -31,24 +31,48 @@ export function useSimConfigureParams() {
|
||||
activationTypeParam === "Immediate" || activationTypeParam === "Scheduled"
|
||||
? activationTypeParam
|
||||
: null;
|
||||
const scheduledAt = params.get("scheduledAt");
|
||||
const scheduledAt = params.get("scheduledAt") ?? params.get("scheduledDate");
|
||||
const addonSkus = params.getAll("addonSku");
|
||||
const isMnp = params.get("isMnp") === "true";
|
||||
const isMnpParam = params.get("isMnp") ?? params.get("wantsMnp");
|
||||
const isMnp = isMnpParam === "true";
|
||||
|
||||
// Optional detailed MNP fields if present
|
||||
const mnp = {
|
||||
reservationNumber: params.get("reservationNumber") || undefined,
|
||||
expiryDate: params.get("expiryDate") || undefined,
|
||||
phoneNumber: params.get("phoneNumber") || undefined,
|
||||
mvnoAccountNumber: params.get("mvnoAccountNumber") || undefined,
|
||||
portingLastName: params.get("portingLastName") || undefined,
|
||||
portingFirstName: params.get("portingFirstName") || undefined,
|
||||
portingLastNameKatakana: params.get("portingLastNameKatakana") || undefined,
|
||||
portingFirstNameKatakana: params.get("portingFirstNameKatakana") || undefined,
|
||||
portingGender:
|
||||
(params.get("portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ||
|
||||
reservationNumber:
|
||||
params.get("mnpNumber") ??
|
||||
params.get("reservationNumber") ??
|
||||
params.get("mnp_reservationNumber") ??
|
||||
undefined,
|
||||
portingDateOfBirth: params.get("portingDateOfBirth") || undefined,
|
||||
expiryDate:
|
||||
params.get("mnpExpiry") ??
|
||||
params.get("expiryDate") ??
|
||||
params.get("mnp_expiryDate") ??
|
||||
undefined,
|
||||
phoneNumber:
|
||||
params.get("mnpPhone") ??
|
||||
params.get("phoneNumber") ??
|
||||
params.get("mnp_phoneNumber") ??
|
||||
undefined,
|
||||
mvnoAccountNumber:
|
||||
params.get("mvnoAccountNumber") ?? params.get("mnp_mvnoAccountNumber") ?? undefined,
|
||||
portingLastName:
|
||||
params.get("portingLastName") ?? params.get("mnp_portingLastName") ?? undefined,
|
||||
portingFirstName:
|
||||
params.get("portingFirstName") ?? params.get("mnp_portingFirstName") ?? undefined,
|
||||
portingLastNameKatakana:
|
||||
params.get("portingLastNameKatakana") ??
|
||||
params.get("mnp_portingLastNameKatakana") ??
|
||||
undefined,
|
||||
portingFirstNameKatakana:
|
||||
params.get("portingFirstNameKatakana") ??
|
||||
params.get("mnp_portingFirstNameKatakana") ??
|
||||
undefined,
|
||||
portingGender:
|
||||
(params.get("portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ??
|
||||
(params.get("mnp_portingGender") as "Male" | "Female" | "Corporate/Other" | undefined) ??
|
||||
undefined,
|
||||
portingDateOfBirth:
|
||||
params.get("portingDateOfBirth") ?? params.get("mnp_portingDateOfBirth") ?? undefined,
|
||||
} as const;
|
||||
|
||||
return {
|
||||
|
||||
@ -6,12 +6,12 @@ import { useSimCatalog, useSimPlan, useSimConfigureParams } from ".";
|
||||
import { useZodForm } from "@customer-portal/validation";
|
||||
import {
|
||||
simConfigureFormSchema,
|
||||
simConfigureFormToRequest,
|
||||
type SimConfigureFormData,
|
||||
type SimType,
|
||||
type SimCardType,
|
||||
type ActivationType,
|
||||
type MnpData,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
} from "@customer-portal/domain/sim";
|
||||
import { buildSimOrderConfigurations } from "@customer-portal/domain/orders";
|
||||
import type { SimCatalogProduct, SimActivationFeeCatalogItem } from "@customer-portal/domain/catalog";
|
||||
|
||||
export type UseSimConfigureResult = {
|
||||
@ -31,8 +31,8 @@ export type UseSimConfigureResult = {
|
||||
validate: () => boolean;
|
||||
|
||||
// Convenience getters for specific fields
|
||||
simType: SimType;
|
||||
setSimType: (value: SimType) => void;
|
||||
simType: SimCardType;
|
||||
setSimType: (value: SimCardType) => void;
|
||||
eid: string;
|
||||
setEid: (value: string) => void;
|
||||
selectedAddons: string[];
|
||||
@ -63,6 +63,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||
const searchParams = useSearchParams();
|
||||
const { data: simData, isLoading: simLoading } = useSimCatalog();
|
||||
const { plan: selectedPlan } = useSimPlan(planId);
|
||||
const configureParams = useSimConfigureParams();
|
||||
|
||||
// Step orchestration state
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
@ -93,13 +94,10 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||
const { values, errors, setValue, validate } = useZodForm<SimConfigureFormData>({
|
||||
schema: simConfigureFormSchema,
|
||||
initialValues,
|
||||
onSubmit: data => {
|
||||
simConfigureFormToRequest(data);
|
||||
},
|
||||
});
|
||||
|
||||
// Convenience setters that update the Zod form
|
||||
const setSimType = useCallback((value: SimType) => setValue("simType", value), [setValue]);
|
||||
const setSimType = useCallback((value: SimCardType) => setValue("simType", value), [setValue]);
|
||||
const setEid = useCallback((value: string) => setValue("eid", value), [setValue]);
|
||||
const setSelectedAddons = useCallback(
|
||||
(value: SimConfigureFormData["selectedAddons"]) => setValue("selectedAddons", value),
|
||||
@ -124,16 +122,70 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||
|
||||
if (mounted) {
|
||||
// Set initial values from URL params or defaults
|
||||
const initialSimType = (searchParams.get("simType") as SimType) || "eSIM";
|
||||
const initialSimType =
|
||||
(configureParams.simType as SimCardType | null) ??
|
||||
(searchParams.get("simType") as SimCardType) ??
|
||||
"eSIM";
|
||||
const initialActivationType =
|
||||
(searchParams.get("activationType") as ActivationType) || "Immediate";
|
||||
(configureParams.activationType as ActivationType | null) ??
|
||||
(searchParams.get("activationType") as ActivationType) ??
|
||||
"Immediate";
|
||||
|
||||
setSimType(initialSimType);
|
||||
setEid(searchParams.get("eid") || "");
|
||||
setSelectedAddons(searchParams.get("addons")?.split(",").filter(Boolean) || []);
|
||||
setEid(configureParams.eid ?? searchParams.get("eid") ?? "");
|
||||
const addonSkuSet = new Set<string>();
|
||||
configureParams.addonSkus.forEach(sku => addonSkuSet.add(sku));
|
||||
searchParams
|
||||
.get("addons")
|
||||
?.split(",")
|
||||
.filter(Boolean)
|
||||
.forEach(sku => addonSkuSet.add(sku));
|
||||
setSelectedAddons(Array.from(addonSkuSet));
|
||||
setActivationType(initialActivationType);
|
||||
setScheduledActivationDate(searchParams.get("scheduledDate") || "");
|
||||
setWantsMnp(searchParams.get("wantsMnp") === "true");
|
||||
const scheduledAt =
|
||||
configureParams.scheduledAt ??
|
||||
searchParams.get("scheduledAt") ??
|
||||
searchParams.get("scheduledDate") ??
|
||||
"";
|
||||
if (scheduledAt) {
|
||||
setScheduledActivationDate(
|
||||
scheduledAt.includes("-")
|
||||
? scheduledAt
|
||||
: `${scheduledAt.slice(0, 4)}-${scheduledAt.slice(4, 6)}-${scheduledAt.slice(6, 8)}`
|
||||
);
|
||||
}
|
||||
|
||||
const wantsMnp = configureParams.isMnp;
|
||||
setWantsMnp(wantsMnp);
|
||||
|
||||
if (wantsMnp) {
|
||||
const mnp = configureParams.mnp;
|
||||
setMnpData({
|
||||
reservationNumber: mnp.reservationNumber ?? "",
|
||||
expiryDate: mnp.expiryDate ?? "",
|
||||
phoneNumber: mnp.phoneNumber ?? "",
|
||||
mvnoAccountNumber: mnp.mvnoAccountNumber ?? "",
|
||||
portingLastName: mnp.portingLastName ?? "",
|
||||
portingFirstName: mnp.portingFirstName ?? "",
|
||||
portingLastNameKatakana: mnp.portingLastNameKatakana ?? "",
|
||||
portingFirstNameKatakana: mnp.portingFirstNameKatakana ?? "",
|
||||
portingGender: mnp.portingGender ?? "",
|
||||
portingDateOfBirth: mnp.portingDateOfBirth ?? "",
|
||||
});
|
||||
} else {
|
||||
setMnpData({
|
||||
reservationNumber: "",
|
||||
expiryDate: "",
|
||||
phoneNumber: "",
|
||||
mvnoAccountNumber: "",
|
||||
portingLastName: "",
|
||||
portingFirstName: "",
|
||||
portingLastNameKatakana: "",
|
||||
portingFirstNameKatakana: "",
|
||||
portingGender: "",
|
||||
portingDateOfBirth: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -144,14 +196,15 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||
}, [
|
||||
simLoading,
|
||||
simData,
|
||||
selectedPlan,
|
||||
searchParams,
|
||||
configureParams,
|
||||
setSimType,
|
||||
setEid,
|
||||
setSelectedAddons,
|
||||
setActivationType,
|
||||
setScheduledActivationDate,
|
||||
setWantsMnp,
|
||||
setMnpData,
|
||||
]);
|
||||
|
||||
// Step transition handler (memoized)
|
||||
@ -227,15 +280,57 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||
params.set("activationType", values.activationType);
|
||||
if (values.scheduledActivationDate) {
|
||||
params.set("scheduledDate", values.scheduledActivationDate);
|
||||
params.set(
|
||||
"scheduledAt",
|
||||
values.scheduledActivationDate.replace(/-/g, "")
|
||||
);
|
||||
} else {
|
||||
params.delete("scheduledDate");
|
||||
params.delete("scheduledAt");
|
||||
}
|
||||
|
||||
if (values.wantsMnp) {
|
||||
params.set("wantsMnp", "true");
|
||||
if (values.mnpData) {
|
||||
Object.entries(values.mnpData).forEach(([key, value]) => {
|
||||
if (value) params.set(`mnp_${key}`, value.toString());
|
||||
});
|
||||
const simConfig = buildSimOrderConfigurations(values);
|
||||
params.set("simConfig", JSON.stringify(simConfig));
|
||||
|
||||
if (simConfig.scheduledAt && !values.scheduledActivationDate) {
|
||||
params.set("scheduledAt", simConfig.scheduledAt);
|
||||
}
|
||||
|
||||
params.set("wantsMnp", simConfig.isMnp === "true" ? "true" : "false");
|
||||
|
||||
if (simConfig.isMnp === "true") {
|
||||
params.set("isMnp", "true");
|
||||
if (simConfig.mnpNumber) params.set("mnpNumber", simConfig.mnpNumber);
|
||||
if (simConfig.mnpExpiry) params.set("mnpExpiry", simConfig.mnpExpiry);
|
||||
if (simConfig.mnpPhone) params.set("mnpPhone", simConfig.mnpPhone);
|
||||
if (simConfig.mvnoAccountNumber)
|
||||
params.set("mvnoAccountNumber", simConfig.mvnoAccountNumber);
|
||||
if (simConfig.portingLastName)
|
||||
params.set("portingLastName", simConfig.portingLastName);
|
||||
if (simConfig.portingFirstName)
|
||||
params.set("portingFirstName", simConfig.portingFirstName);
|
||||
if (simConfig.portingLastNameKatakana)
|
||||
params.set("portingLastNameKatakana", simConfig.portingLastNameKatakana);
|
||||
if (simConfig.portingFirstNameKatakana)
|
||||
params.set("portingFirstNameKatakana", simConfig.portingFirstNameKatakana);
|
||||
if (simConfig.portingGender)
|
||||
params.set("portingGender", simConfig.portingGender);
|
||||
if (simConfig.portingDateOfBirth)
|
||||
params.set("portingDateOfBirth", simConfig.portingDateOfBirth);
|
||||
} else {
|
||||
params.set("isMnp", "false");
|
||||
[
|
||||
"mnpNumber",
|
||||
"mnpExpiry",
|
||||
"mnpPhone",
|
||||
"mvnoAccountNumber",
|
||||
"portingLastName",
|
||||
"portingFirstName",
|
||||
"portingLastNameKatakana",
|
||||
"portingFirstNameKatakana",
|
||||
"portingGender",
|
||||
"portingDateOfBirth",
|
||||
].forEach(key => params.delete(key));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,12 @@ 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";
|
||||
import {
|
||||
ORDER_TYPE,
|
||||
orderConfigurationsSchema,
|
||||
type OrderConfigurations,
|
||||
type OrderTypeValue,
|
||||
} from "@customer-portal/domain/orders";
|
||||
|
||||
// Use domain Address type
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
@ -84,6 +89,25 @@ export function useCheckout() {
|
||||
return obj;
|
||||
}, [params]);
|
||||
|
||||
const simConfig = useMemo(() => {
|
||||
if (orderType !== ORDER_TYPE.SIM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawConfig = params.get("simConfig");
|
||||
if (!rawConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawConfig) as unknown;
|
||||
return orderConfigurationsSchema.parse(parsed);
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse SIM order configuration from query params", error);
|
||||
return null;
|
||||
}
|
||||
}, [orderType, params]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
@ -226,20 +250,20 @@ export function useCheckout() {
|
||||
if (!mounted) return;
|
||||
|
||||
const totals = calculateTotals(items);
|
||||
const configuration =
|
||||
orderType === ORDER_TYPE.SIM && simConfig ? simConfig : ({} as OrderConfigurations);
|
||||
setCheckoutState(
|
||||
createSuccessState({
|
||||
items,
|
||||
totals,
|
||||
configuration: {} as OrderConfigurations,
|
||||
configuration,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
setCheckoutState(
|
||||
createErrorState(
|
||||
error instanceof Error ? error.message : "Failed to load checkout data"
|
||||
)
|
||||
);
|
||||
const reason =
|
||||
error instanceof Error ? error.message : "Failed to load checkout data";
|
||||
setCheckoutState(createErrorState(new Error(reason)));
|
||||
}
|
||||
}
|
||||
})();
|
||||
@ -247,7 +271,7 @@ export function useCheckout() {
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [orderType, params, selections]);
|
||||
}, [orderType, params, selections, simConfig]);
|
||||
|
||||
const handleSubmitOrder = useCallback(async () => {
|
||||
try {
|
||||
@ -266,19 +290,28 @@ export function useCheckout() {
|
||||
throw new Error("No products selected for order. Please go back and select products.");
|
||||
}
|
||||
|
||||
const configurations: OrderConfigurations = {
|
||||
...(selections.accessMode ? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] } : {}),
|
||||
let configurationAccumulator: Partial<OrderConfigurations> = {};
|
||||
|
||||
if (orderType === ORDER_TYPE.SIM) {
|
||||
if (simConfig) {
|
||||
configurationAccumulator = { ...simConfig };
|
||||
} else {
|
||||
configurationAccumulator = {
|
||||
...(selections.simType
|
||||
? { simType: selections.simType as OrderConfigurations["simType"] }
|
||||
: {}),
|
||||
...(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.mvnoAccountNumber
|
||||
? { mvnoAccountNumber: selections.mvnoAccountNumber }
|
||||
: {}),
|
||||
...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}),
|
||||
...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}),
|
||||
...(selections.portingLastNameKatakana
|
||||
@ -292,33 +325,55 @@ export function useCheckout() {
|
||||
portingGender: selections.portingGender as OrderConfigurations["portingGender"],
|
||||
}
|
||||
: {}),
|
||||
...(selections.portingDateOfBirth ? { portingDateOfBirth: selections.portingDateOfBirth } : {}),
|
||||
...(selections.portingDateOfBirth
|
||||
? { portingDateOfBirth: selections.portingDateOfBirth }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
configurationAccumulator = {
|
||||
...(selections.accessMode
|
||||
? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] }
|
||||
: {}),
|
||||
...(selections.activationType
|
||||
? { activationType: selections.activationType as OrderConfigurations["activationType"] }
|
||||
: {}),
|
||||
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (confirmedAddress) {
|
||||
configurations.address = {
|
||||
street: confirmedAddress.street ?? undefined,
|
||||
streetLine2: confirmedAddress.streetLine2 ?? undefined,
|
||||
configurationAccumulator.address = {
|
||||
street: confirmedAddress.address1 ?? undefined,
|
||||
streetLine2: confirmedAddress.address2 ?? undefined,
|
||||
city: confirmedAddress.city ?? undefined,
|
||||
state: confirmedAddress.state ?? undefined,
|
||||
postalCode: confirmedAddress.postalCode ?? undefined,
|
||||
postalCode: confirmedAddress.postcode ?? undefined,
|
||||
country: confirmedAddress.country ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const hasConfiguration = Object.keys(configurationAccumulator).length > 0;
|
||||
const configurations = hasConfiguration
|
||||
? orderConfigurationsSchema.parse(configurationAccumulator)
|
||||
: undefined;
|
||||
|
||||
const orderData = {
|
||||
orderType,
|
||||
skus: uniqueSkus,
|
||||
...(Object.keys(configurations).length > 0 && { configurations }),
|
||||
...(configurations ? { configurations } : {}),
|
||||
};
|
||||
|
||||
if (orderType === "SIM") {
|
||||
if (!selections.eid && selections.simType === "eSIM") {
|
||||
if (orderType === ORDER_TYPE.SIM) {
|
||||
if (!configurations) {
|
||||
throw new Error("SIM configuration is incomplete. Please restart the SIM configuration flow.");
|
||||
}
|
||||
if (configurations?.simType === "eSIM" && !configurations.eid) {
|
||||
throw new Error(
|
||||
"EID is required for eSIM activation. Please go back and provide your EID."
|
||||
);
|
||||
}
|
||||
if (!selections.phoneNumber && !selections.mnpPhone) {
|
||||
if (!configurations?.mnpPhone) {
|
||||
throw new Error(
|
||||
"Phone number is required for SIM activation. Please go back and provide a phone number."
|
||||
);
|
||||
@ -340,16 +395,16 @@ export function useCheckout() {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await ordersService.createOrder<{ sfOrderId: string }>(orderData);
|
||||
const response = await ordersService.createOrder(orderData);
|
||||
router.push(`/orders/${response.sfOrderId}?status=success`);
|
||||
} catch (error) {
|
||||
let errorMessage = "Order submission failed";
|
||||
if (error instanceof Error) errorMessage = error.message;
|
||||
setCheckoutState(createErrorState(errorMessage));
|
||||
setCheckoutState(createErrorState(new Error(errorMessage)));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [checkoutState, confirmedAddress, orderType, selections, router]);
|
||||
}, [checkoutState, confirmedAddress, orderType, selections, router, simConfig, activeSubs]);
|
||||
|
||||
const confirmAddress = useCallback((address?: Address) => {
|
||||
setAddressConfirmed(true);
|
||||
|
||||
@ -55,7 +55,7 @@ export function CheckoutContainer() {
|
||||
<div className="py-6">
|
||||
<AlertBanner variant="error" title="Unable to load checkout" elevated>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{checkoutState.error}</span>
|
||||
<span>{checkoutState.error.message}</span>
|
||||
<Button variant="link" onClick={navigateBackToConfigure}>
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { apiClient } from "@/lib/api";
|
||||
import {
|
||||
createOrderRequest,
|
||||
orderDetailsSchema,
|
||||
orderSummarySchema,
|
||||
type CreateOrderRequest,
|
||||
@ -13,11 +12,11 @@ import {
|
||||
} from "@/lib/api/response-helpers";
|
||||
|
||||
async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> {
|
||||
const body = createOrderRequest({
|
||||
const body: CreateOrderRequest = {
|
||||
orderType: payload.orderType,
|
||||
skus: payload.skus,
|
||||
configurations: payload.configurations ?? undefined,
|
||||
});
|
||||
...(payload.configurations ? { configurations: payload.configurations } : {}),
|
||||
};
|
||||
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 }>
|
||||
|
||||
@ -55,7 +55,7 @@ export function OrderDetailContainer() {
|
||||
? deriveOrderStatusDescriptor({
|
||||
status: data.status,
|
||||
activationStatus: data.activationStatus,
|
||||
scheduledAt: data.scheduledAt,
|
||||
scheduledAt: data.activationScheduledAt,
|
||||
})
|
||||
: null;
|
||||
|
||||
@ -68,7 +68,7 @@ export function OrderDetailContainer() {
|
||||
const totals = calculateOrderTotals(
|
||||
data?.items?.map(item => ({
|
||||
totalPrice: item.totalPrice,
|
||||
billingCycle: item.product?.billingCycle,
|
||||
billingCycle: item.billingCycle,
|
||||
})),
|
||||
data?.totalAmount
|
||||
);
|
||||
@ -176,7 +176,7 @@ export function OrderDetailContainer() {
|
||||
{data.items.map(item => {
|
||||
const productName = item.product?.name ?? "Product";
|
||||
const sku = item.product?.sku ?? "N/A";
|
||||
const billingCycle = item.product?.billingCycle ?? "";
|
||||
const billingCycle = item.billingCycle ?? "";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@ -4,12 +4,10 @@ import React from "react";
|
||||
import { formatPlanShort } from "@/lib/utils";
|
||||
import {
|
||||
DevicePhoneMobileIcon,
|
||||
WifiIcon,
|
||||
SignalIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
ClockIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||
|
||||
@ -17,363 +15,206 @@ interface SimDetailsCardProps {
|
||||
simDetails: SimDetails;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
embedded?: boolean; // when true, render content without card container
|
||||
showFeaturesSummary?: boolean; // show the right-side Service Features summary
|
||||
embedded?: boolean;
|
||||
showFeaturesSummary?: boolean;
|
||||
}
|
||||
|
||||
const statusIconMap: Record<string, React.ReactNode> = {
|
||||
active: <CheckCircleIcon className="h-5 w-5 text-green-500" />,
|
||||
suspended: <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />,
|
||||
cancelled: <XCircleIcon className="h-5 w-5 text-red-500" />,
|
||||
pending: <ClockIcon className="h-5 w-5 text-blue-500" />,
|
||||
};
|
||||
|
||||
const statusBadgeClass: Record<string, string> = {
|
||||
active: "bg-green-100 text-green-800",
|
||||
suspended: "bg-yellow-100 text-yellow-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
pending: "bg-blue-100 text-blue-800",
|
||||
};
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? value
|
||||
: date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
const formatQuota = (remainingMb: number) => {
|
||||
if (remainingMb >= 1000) {
|
||||
return `${(remainingMb / 1000).toFixed(1)} GB`;
|
||||
}
|
||||
return `${remainingMb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
const FeatureToggleRow = ({
|
||||
label,
|
||||
enabled,
|
||||
}: {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}) => (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-700">{label}</span>
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-1 rounded-full ${
|
||||
enabled ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LoadingCard = ({ embedded }: { embedded: boolean }) => (
|
||||
<div
|
||||
className={`${embedded ? "" : "bg-white shadow rounded-xl border border-gray-100"} p-6 lg:p-8`}
|
||||
>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-full bg-gray-200" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2" />
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4 bg-gray-200 rounded" />
|
||||
<div className="h-4 bg-gray-200 rounded w-5/6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="h-24 bg-gray-200 rounded" />
|
||||
<div className="h-24 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorCard = ({ embedded, message }: { embedded: boolean; message: string }) => (
|
||||
<div
|
||||
className={`${embedded ? "" : "bg-white shadow rounded-xl border border-red-100"} p-6 lg:p-8`}
|
||||
>
|
||||
<div className="text-center text-red-600 text-sm">{message}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function SimDetailsCard({
|
||||
simDetails,
|
||||
isLoading,
|
||||
error,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
embedded = false,
|
||||
showFeaturesSummary = true,
|
||||
}: SimDetailsCardProps) {
|
||||
const formatPlan = (code?: string) => formatPlanShort(code);
|
||||
const isEsim = simDetails.simType === "esim";
|
||||
const hasVoice = Boolean(simDetails.hasVoice ?? simDetails.voiceMailEnabled);
|
||||
const hasSms = Boolean(simDetails.hasSms);
|
||||
const voiceMailEnabled = Boolean(simDetails.voiceMailEnabled);
|
||||
const callWaitingEnabled = Boolean(simDetails.callWaitingEnabled);
|
||||
const internationalRoamingEnabled = Boolean(simDetails.internationalRoamingEnabled);
|
||||
const sizeLabel = simDetails.size ?? simDetails.simType;
|
||||
const ipv4Address = simDetails.ipv4;
|
||||
const ipv6Address = simDetails.ipv6;
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
|
||||
case "suspended":
|
||||
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
|
||||
case "cancelled":
|
||||
return <XCircleIcon className="h-6 w-6 text-red-500" />;
|
||||
case "pending":
|
||||
return <ClockIcon className="h-6 w-6 text-blue-500" />;
|
||||
default:
|
||||
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "suspended":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "cancelled":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "pending":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const formatQuota = (quotaMb: number) => {
|
||||
if (quotaMb >= 1000) {
|
||||
return `${(quotaMb / 1000).toFixed(1)} GB`;
|
||||
}
|
||||
return `${quotaMb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
const Skeleton = (
|
||||
<div
|
||||
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 "}p-6 lg:p-8`}
|
||||
>
|
||||
<div className="animate-pulse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
|
||||
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg"></div>
|
||||
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-5/6"></div>
|
||||
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-4/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return Skeleton;
|
||||
return <LoadingCard embedded={embedded} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-red-100 "}p-6 lg:p-8`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
|
||||
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error Loading SIM Details</h3>
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <ErrorCard embedded={embedded} message={error} />;
|
||||
}
|
||||
|
||||
// Specialized, minimal eSIM details view
|
||||
if (simDetails.simType === "esim") {
|
||||
return (
|
||||
<div
|
||||
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-3 sm:space-y-0">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
||||
<WifiIcon className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3>
|
||||
<p className="text-sm text-gray-600 font-medium">
|
||||
Current Plan: {formatPlan(simDetails.planCode)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex px-4 py-2 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)} self-start sm:self-auto`}
|
||||
>
|
||||
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
|
||||
<DevicePhoneMobileIcon className="h-4 w-4 mr-2 text-blue-500" />
|
||||
SIM Information
|
||||
</h4>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Phone Number
|
||||
</label>
|
||||
<p className="text-lg font-semibold text-gray-900 mt-1">{simDetails.msisdn}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Data Remaining
|
||||
</label>
|
||||
<p className="text-2xl font-bold text-green-600 mt-1">
|
||||
{formatQuota(simDetails.remainingQuotaMb)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFeaturesSummary && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4 flex items-center">
|
||||
<CheckCircleIcon className="h-4 w-4 mr-2 text-green-500" />
|
||||
Service Features
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-700">Voice Mail (¥300/month)</span>
|
||||
<span
|
||||
className={`text-sm font-semibold px-2 py-1 rounded-full ${voiceMailEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{voiceMailEnabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-700">Call Waiting (¥300/month)</span>
|
||||
<span
|
||||
className={`text-sm font-semibold px-2 py-1 rounded-full ${callWaitingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{callWaitingEnabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-700">International Roaming</span>
|
||||
<span
|
||||
className={`text-sm font-semibold px-2 py-1 rounded-full ${internationalRoamingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
|
||||
>
|
||||
{internationalRoamingEnabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 px-3 bg-blue-50 rounded-lg">
|
||||
<span className="text-sm text-gray-700">4G/5G</span>
|
||||
<span className="text-sm font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800">
|
||||
{simDetails.networkType || "5G"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const planName = simDetails.planName || formatPlanShort(simDetails.planCode) || "SIM Plan";
|
||||
const normalizedStatus = simDetails.status?.toLowerCase() ?? "unknown";
|
||||
const statusIcon = statusIconMap[normalizedStatus] ?? (
|
||||
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
|
||||
);
|
||||
}
|
||||
const statusClass = statusBadgeClass[normalizedStatus] ?? "bg-gray-100 text-gray-800";
|
||||
const containerClasses = embedded
|
||||
? ""
|
||||
: "bg-white shadow-lg rounded-xl border border-gray-100";
|
||||
|
||||
return (
|
||||
<div className={`${embedded ? "" : "bg-white shadow rounded-lg"}`}>
|
||||
{/* Header */}
|
||||
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="text-2xl mr-3">
|
||||
<DevicePhoneMobileIcon className="h-8 w-8 text-blue-600" />
|
||||
<div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<DevicePhoneMobileIcon className="h-7 w-7 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formatPlan(simDetails.planCode)} • {`${sizeLabel ?? "Unknown"} SIM`}
|
||||
</p>
|
||||
<h3 className="text-xl font-semibold text-gray-900">{planName}</h3>
|
||||
<p className="text-sm text-gray-600">Account #{simDetails.account}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{getStatusIcon(simDetails.status)}
|
||||
<span
|
||||
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
|
||||
>
|
||||
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
||||
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full ${statusClass}`}>
|
||||
{statusIcon}
|
||||
<span className="text-sm font-medium capitalize">{simDetails.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`${embedded ? "" : "px-6 py-4"}`}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* SIM Information */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<section className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
SIM Information
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">Phone Number</label>
|
||||
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
|
||||
<dl className="space-y-2 text-sm text-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">Phone Number</dt>
|
||||
<dd className="font-semibold text-gray-900">{simDetails.msisdn}</dd>
|
||||
</div>
|
||||
|
||||
{simDetails.simType === "physical" && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">ICCID</label>
|
||||
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">SIM Type</dt>
|
||||
<dd className="font-semibold text-gray-900">{simDetails.simType}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">ICCID</dt>
|
||||
<dd className="font-mono text-gray-900 break-all">{simDetails.iccid}</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{simDetails.eid && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">EID (eSIM)</label>
|
||||
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p>
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">EID</dt>
|
||||
<dd className="font-mono text-gray-900 break-all">{simDetails.eid}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">Network Type</dt>
|
||||
<dd className="font-semibold text-gray-900">{simDetails.networkType}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{simDetails.imsi && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">IMSI</label>
|
||||
<p className="text-sm font-mono text-gray-900">{simDetails.imsi}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{simDetails.startDate && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">Service Start Date</label>
|
||||
<p className="text-sm text-gray-900">{formatDate(simDetails.startDate)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Features */}
|
||||
{showFeaturesSummary && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
Service Features
|
||||
<section className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
Data Remaining
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">Data Remaining</label>
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{formatQuota(simDetails.remainingQuotaMb)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">Remaining allowance in current cycle</p>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<SignalIcon
|
||||
className={`h-4 w-4 mr-1 ${hasVoice ? "text-green-500" : "text-gray-400"}`}
|
||||
/>
|
||||
<span className={`text-sm ${hasVoice ? "text-green-600" : "text-gray-500"}`}>
|
||||
Voice {hasVoice ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DevicePhoneMobileIcon
|
||||
className={`h-4 w-4 mr-1 ${hasSms ? "text-green-500" : "text-gray-400"}`}
|
||||
/>
|
||||
<span className={`text-sm ${hasSms ? "text-green-600" : "text-gray-500"}`}>
|
||||
SMS {hasSms ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(simDetails.ipv4 || simDetails.ipv6) && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-500">IP Address</label>
|
||||
<div className="space-y-1">
|
||||
{ipv4Address && (
|
||||
<p className="text-sm font-mono text-gray-900">IPv4: {ipv4Address}</p>
|
||||
)}
|
||||
{ipv6Address && (
|
||||
<p className="text-sm font-mono text-gray-900">IPv6: {ipv6Address}</p>
|
||||
)}
|
||||
{!ipv4Address && !ipv6Address && (
|
||||
<p className="text-sm text-gray-500">No IP assigned</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pending Operations */}
|
||||
{simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||
Pending Operations
|
||||
<section className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
Activation Timeline
|
||||
</h4>
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
{simDetails.pendingOperations.map((operation, index) => (
|
||||
<div key={index} className="flex items-center text-sm">
|
||||
<ClockIcon className="h-4 w-4 text-blue-500 mr-2" />
|
||||
<span className="text-blue-800">
|
||||
{operation.operation} scheduled for {formatDate(operation.scheduledDate)}
|
||||
</span>
|
||||
<dl className="space-y-2 text-sm text-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">Activated</dt>
|
||||
<dd>{formatDate(simDetails.activatedAt)}</dd>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between">
|
||||
<dt className="font-medium text-gray-600">Expires</dt>
|
||||
<dd>{formatDate(simDetails.expiresAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{showFeaturesSummary && (
|
||||
<section className="space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
|
||||
Service Features
|
||||
</h4>
|
||||
<FeatureToggleRow label="Voice Mail" enabled={simDetails.voiceMailEnabled} />
|
||||
<FeatureToggleRow label="Call Waiting" enabled={simDetails.callWaitingEnabled} />
|
||||
<FeatureToggleRow
|
||||
label="International Roaming"
|
||||
enabled={simDetails.internationalRoamingEnabled}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { SimDetails };
|
||||
|
||||
@ -6,11 +6,12 @@ import {
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { SimDetailsCard, type SimDetails } from "./SimDetailsCard";
|
||||
import { SimDetailsCard } from "./SimDetailsCard";
|
||||
import { DataUsageChart, type SimUsage } from "./DataUsageChart";
|
||||
import { SimActions } from "./SimActions";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { SimFeatureToggles } from "./SimFeatureToggles";
|
||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||
|
||||
interface SimManagementSectionProps {
|
||||
subscriptionId: number;
|
||||
@ -137,6 +138,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
return null;
|
||||
}
|
||||
|
||||
const actionSimType: "esim" | "physical" =
|
||||
simInfo.details.simType.toLowerCase() === "esim" ? "esim" : "physical";
|
||||
|
||||
return (
|
||||
<div id="sim-management" className="space-y-8">
|
||||
{/* SIM Details and Usage - Main Content */}
|
||||
@ -146,7 +150,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
|
||||
<SimActions
|
||||
subscriptionId={subscriptionId}
|
||||
simType={simInfo.details.simType}
|
||||
simType={actionSimType}
|
||||
status={simInfo.details.status}
|
||||
currentPlanCode={simInfo.details.planCode}
|
||||
onTopUpSuccess={handleActionSuccess}
|
||||
|
||||
@ -148,7 +148,7 @@ export function SimCancelContainer() {
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
||||
<InfoRow label="Start Date" value={details?.startDate || "—"} />
|
||||
<InfoRow label="Activated" value={details?.activatedAt || "—"} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cancellation Month
|
||||
|
||||
@ -14,11 +14,6 @@ interface UseSubscriptionsOptions {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const emptySubscriptionList: SubscriptionList = {
|
||||
subscriptions: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
|
||||
const emptyStats = {
|
||||
total: 0,
|
||||
active: 0,
|
||||
@ -35,19 +30,6 @@ const emptyInvoiceList: InvoiceList = {
|
||||
},
|
||||
};
|
||||
|
||||
function toSubscriptionList(payload?: SubscriptionList | Subscription[] | null): SubscriptionList {
|
||||
if (!payload) {
|
||||
return emptySubscriptionList;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
return {
|
||||
subscriptions: payload,
|
||||
totalCount: payload.length,
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch all subscriptions
|
||||
*/
|
||||
@ -62,9 +44,7 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
||||
"/api/subscriptions",
|
||||
status ? { params: { query: { status } } } : undefined
|
||||
);
|
||||
return toSubscriptionList(
|
||||
getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions")
|
||||
);
|
||||
return getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions");
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
|
||||
@ -146,7 +146,7 @@ export function SimCancelContainer() {
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
||||
<InfoRow label="Start Date" value={details?.startDate || "—"} />
|
||||
<InfoRow label="Activated" value={details?.activatedAt || "—"} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cancellation Month
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
export { createClient, resolveBaseUrl } from "./runtime/client";
|
||||
export type { ApiClient, AuthHeaderResolver, CreateClientOptions, QueryParams, PathParams } from "./runtime/client";
|
||||
export { ApiError } from "./runtime/client";
|
||||
export { ApiError, isApiError } from "./runtime/client";
|
||||
|
||||
// Re-export API helpers
|
||||
export * from "./response-helpers";
|
||||
|
||||
@ -22,7 +22,7 @@ export type HttpMethod =
|
||||
| "HEAD"
|
||||
| "OPTIONS";
|
||||
|
||||
type PathParams = Record<string, string | number>;
|
||||
export type PathParams = Record<string, string | number>;
|
||||
export type QueryPrimitive = string | number | boolean;
|
||||
export type QueryParams = Record<
|
||||
string,
|
||||
@ -383,8 +383,12 @@ export function createClient(options: CreateClientOptions = {}): ApiClient {
|
||||
|
||||
const parsedBody = await parseResponseBody(response);
|
||||
|
||||
if (parsedBody === undefined || parsedBody === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
data: (parsedBody as T | null | undefined) ?? null,
|
||||
data: parsedBody as T,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
71
packages/domain/orders/helpers.ts
Normal file
71
packages/domain/orders/helpers.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { orderConfigurationsSchema, type OrderConfigurations } from "./schema";
|
||||
import type { SimConfigureFormData } from "../sim";
|
||||
|
||||
export interface BuildSimOrderConfigurationsOptions {
|
||||
/**
|
||||
* Optional fallback phone number when the SIM form does not include it directly.
|
||||
* Useful for flows where the phone number is collected separately.
|
||||
*/
|
||||
phoneNumber?: string | null | undefined;
|
||||
}
|
||||
|
||||
const normalizeString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeDate = (value: unknown): string | undefined => {
|
||||
const str = normalizeString(value);
|
||||
if (!str) return undefined;
|
||||
return str.replace(/-/g, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an OrderConfigurations object for SIM orders from the shared SimConfigureFormData.
|
||||
* Ensures the resulting payload conforms to the domain schema before it is sent to the BFF.
|
||||
*/
|
||||
export function buildSimOrderConfigurations(
|
||||
formData: SimConfigureFormData,
|
||||
options: BuildSimOrderConfigurationsOptions = {}
|
||||
): OrderConfigurations {
|
||||
const base: Record<string, unknown> = {
|
||||
simType: formData.simType,
|
||||
activationType: formData.activationType,
|
||||
};
|
||||
|
||||
const eid = normalizeString(formData.eid);
|
||||
if (formData.simType === "eSIM" && eid) {
|
||||
base.eid = eid;
|
||||
}
|
||||
|
||||
const scheduledDate = normalizeDate(formData.scheduledActivationDate);
|
||||
if (formData.activationType === "Scheduled" && scheduledDate) {
|
||||
base.scheduledAt = scheduledDate;
|
||||
}
|
||||
|
||||
const phoneCandidate =
|
||||
normalizeString(formData.mnpData?.phoneNumber) ?? normalizeString(options.phoneNumber);
|
||||
if (phoneCandidate) {
|
||||
base.mnpPhone = phoneCandidate;
|
||||
}
|
||||
|
||||
if (formData.wantsMnp && formData.mnpData) {
|
||||
const mnp = formData.mnpData;
|
||||
base.isMnp = "true";
|
||||
base.mnpNumber = normalizeString(mnp.reservationNumber);
|
||||
base.mnpExpiry = normalizeDate(mnp.expiryDate);
|
||||
base.mvnoAccountNumber = normalizeString(mnp.mvnoAccountNumber);
|
||||
base.portingLastName = normalizeString(mnp.portingLastName);
|
||||
base.portingFirstName = normalizeString(mnp.portingFirstName);
|
||||
base.portingLastNameKatakana = normalizeString(mnp.portingLastNameKatakana);
|
||||
base.portingFirstNameKatakana = normalizeString(mnp.portingFirstNameKatakana);
|
||||
base.portingGender = normalizeString(mnp.portingGender);
|
||||
base.portingDateOfBirth = normalizeDate(mnp.portingDateOfBirth);
|
||||
} else if (formData.wantsMnp) {
|
||||
// When wantsMnp is true but data is missing, mark the flag so validation fails clearly downstream.
|
||||
base.isMnp = "true";
|
||||
}
|
||||
|
||||
return orderConfigurationsSchema.parse(base);
|
||||
}
|
||||
@ -11,6 +11,7 @@ export {
|
||||
type OrderCreationType,
|
||||
type OrderStatus,
|
||||
type OrderType,
|
||||
type OrderTypeValue,
|
||||
type UserMapping,
|
||||
// Constants
|
||||
ORDER_TYPE,
|
||||
@ -29,6 +30,10 @@ export * from "./validation";
|
||||
|
||||
// Utilities
|
||||
export * from "./utils";
|
||||
export {
|
||||
buildSimOrderConfigurations,
|
||||
type BuildSimOrderConfigurationsOptions,
|
||||
} from "./helpers";
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
|
||||
@ -30,6 +30,11 @@ export type {
|
||||
SimCancelRequest,
|
||||
SimTopUpHistoryRequest,
|
||||
SimFeaturesUpdateRequest,
|
||||
SimReissueRequest,
|
||||
SimConfigureFormData,
|
||||
SimCardType,
|
||||
ActivationType,
|
||||
MnpData,
|
||||
// Activation types
|
||||
SimOrderActivationRequest,
|
||||
SimOrderActivationMnp,
|
||||
|
||||
@ -111,6 +111,95 @@ export const simReissueRequestSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const simMnpFormSchema = z.object({
|
||||
reservationNumber: z.string().min(1, "Reservation number is required"),
|
||||
expiryDate: z.string().regex(/^\d{8}$/, "Expiry date must be in YYYYMMDD format"),
|
||||
phoneNumber: z.string().min(1, "Phone number is required"),
|
||||
mvnoAccountNumber: z.string().optional(),
|
||||
portingLastName: z.string().optional(),
|
||||
portingFirstName: z.string().optional(),
|
||||
portingLastNameKatakana: z.string().optional(),
|
||||
portingFirstNameKatakana: z.string().optional(),
|
||||
portingGender: z.string().optional(),
|
||||
portingDateOfBirth: z
|
||||
.string()
|
||||
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const simCardTypeSchema = z.enum(["eSIM", "Physical SIM"]);
|
||||
|
||||
export const simActivationTypeSchema = z.enum(["Immediate", "Scheduled"]);
|
||||
|
||||
export const simConfigureFormSchema = z
|
||||
.object({
|
||||
simType: simCardTypeSchema,
|
||||
eid: z
|
||||
.string()
|
||||
.min(15, "EID must be at least 15 characters")
|
||||
.max(32, "EID must be at most 32 characters")
|
||||
.optional(),
|
||||
selectedAddons: z.array(z.string()).default([]),
|
||||
activationType: simActivationTypeSchema,
|
||||
scheduledActivationDate: z
|
||||
.string()
|
||||
.regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format")
|
||||
.optional(),
|
||||
wantsMnp: z.boolean().default(false),
|
||||
mnpData: simMnpFormSchema.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.simType === "eSIM" && (!data.eid || data.eid.trim().length < 15)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["eid"],
|
||||
message: "EID is required for eSIM configuration",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.activationType === "Scheduled" && !data.scheduledActivationDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["scheduledActivationDate"],
|
||||
message: "Scheduled activation date is required when activation type is Scheduled",
|
||||
});
|
||||
}
|
||||
|
||||
if (data.wantsMnp) {
|
||||
if (!data.mnpData) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["mnpData"],
|
||||
message: "MNP data is required when porting is selected",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { reservationNumber, expiryDate, phoneNumber } = data.mnpData;
|
||||
if (!reservationNumber) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["mnpData", "reservationNumber"],
|
||||
message: "Reservation number is required",
|
||||
});
|
||||
}
|
||||
if (!expiryDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["mnpData", "expiryDate"],
|
||||
message: "Reservation expiry date is required",
|
||||
});
|
||||
}
|
||||
if (!phoneNumber) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["mnpData", "phoneNumber"],
|
||||
message: "Phone number is required for porting",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// SIM Order Activation Schemas
|
||||
// ============================================================================
|
||||
@ -134,9 +223,9 @@ export const simOrderActivationAddonsSchema = z.object({
|
||||
|
||||
export const simOrderActivationRequestSchema = z.object({
|
||||
planSku: z.string().min(1, "Plan SKU is required"),
|
||||
simType: z.enum(["eSIM", "Physical SIM"]),
|
||||
simType: simCardTypeSchema,
|
||||
eid: z.string().min(15, "EID must be at least 15 characters").optional(),
|
||||
activationType: z.enum(["Immediate", "Scheduled"]),
|
||||
activationType: simActivationTypeSchema,
|
||||
scheduledAt: z.string().regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format").optional(),
|
||||
addons: simOrderActivationAddonsSchema.optional(),
|
||||
mnp: simOrderActivationMnpSchema.optional(),
|
||||
@ -202,3 +291,51 @@ export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
|
||||
export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>;
|
||||
export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>;
|
||||
export type SimReissueRequest = z.infer<typeof simReissueRequestSchema>;
|
||||
export type SimConfigureFormData = z.infer<typeof simConfigureFormSchema>;
|
||||
export type SimCardType = z.infer<typeof simCardTypeSchema>;
|
||||
export type ActivationType = z.infer<typeof simActivationTypeSchema>;
|
||||
export type MnpData = z.infer<typeof simMnpFormSchema>;
|
||||
|
||||
export interface SimConfigureFormToRequestOptions {
|
||||
planSku: string;
|
||||
msisdn: string;
|
||||
monthlyAmountJpy: number;
|
||||
oneTimeAmountJpy: number;
|
||||
addons?: SimOrderActivationAddons;
|
||||
}
|
||||
|
||||
export function simConfigureFormToRequest(
|
||||
formData: SimConfigureFormData,
|
||||
options: SimConfigureFormToRequestOptions
|
||||
): SimOrderActivationRequest {
|
||||
const scheduledAt =
|
||||
formData.activationType === "Scheduled" ? formData.scheduledActivationDate : undefined;
|
||||
|
||||
const mnp =
|
||||
formData.wantsMnp && formData.mnpData
|
||||
? {
|
||||
reserveNumber: formData.mnpData.reservationNumber,
|
||||
reserveExpireDate: formData.mnpData.expiryDate,
|
||||
account: formData.mnpData.mvnoAccountNumber ?? undefined,
|
||||
firstnameKanji: formData.mnpData.portingFirstName ?? undefined,
|
||||
lastnameKanji: formData.mnpData.portingLastName ?? undefined,
|
||||
firstnameZenKana: formData.mnpData.portingFirstNameKatakana ?? undefined,
|
||||
lastnameZenKana: formData.mnpData.portingLastNameKatakana ?? undefined,
|
||||
gender: formData.mnpData.portingGender ?? undefined,
|
||||
birthday: formData.mnpData.portingDateOfBirth ?? undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return simOrderActivationRequestSchema.parse({
|
||||
planSku: options.planSku,
|
||||
simType: formData.simType,
|
||||
eid: formData.simType === "eSIM" ? formData.eid?.trim() || undefined : undefined,
|
||||
activationType: formData.activationType,
|
||||
scheduledAt,
|
||||
addons: options.addons,
|
||||
mnp,
|
||||
msisdn: options.msisdn,
|
||||
oneTimeAmountJpy: options.oneTimeAmountJpy,
|
||||
monthlyAmountJpy: options.monthlyAmountJpy,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,49 +1,97 @@
|
||||
/**
|
||||
* Toolkit - Currency Formatting
|
||||
*
|
||||
* Simple currency formatting. Currency code comes from user's WHMCS profile.
|
||||
* Typically JPY for this application.
|
||||
* Our product currently operates in Japanese Yen only, but we keep a single
|
||||
* helper so the portal and BFF share the same formatting behaviour.
|
||||
*
|
||||
* The function still accepts legacy signatures (`formatCurrency(amount, "JPY", "¥")`)
|
||||
* so existing call sites remain compatible.
|
||||
*/
|
||||
|
||||
export type SupportedCurrency = "JPY" | "USD" | "EUR";
|
||||
export type SupportedCurrency = "JPY";
|
||||
|
||||
type LegacyOptions = {
|
||||
/**
|
||||
* Format a number as currency using WHMCS currency data
|
||||
*
|
||||
* @param amount - The numeric amount to format
|
||||
* @param currencyCode - Currency code from WHMCS API (e.g., "JPY", "USD", "EUR")
|
||||
* @param currencyPrefix - Currency symbol from WHMCS API (e.g., "¥", "$", "€")
|
||||
*
|
||||
* @example
|
||||
* formatCurrency(1000, "JPY", "¥") // ¥1,000
|
||||
* formatCurrency(1000, "USD", "$") // $1,000.00
|
||||
* formatCurrency(1000, "EUR", "€") // €1,000.00
|
||||
* Optional locale override. Defaults to "ja-JP".
|
||||
*/
|
||||
locale?: string;
|
||||
/**
|
||||
* Set to false if you ever want to hide the symbol. Defaults to true.
|
||||
*/
|
||||
showSymbol?: boolean;
|
||||
/**
|
||||
* Optional custom symbol. Defaults to "¥".
|
||||
*/
|
||||
currencySymbol?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_CURRENCY: SupportedCurrency = "JPY";
|
||||
const DEFAULT_SYMBOL = "¥";
|
||||
const DEFAULT_LOCALE = "ja-JP";
|
||||
|
||||
export const getCurrencyLocale = (_currency?: string): string => DEFAULT_LOCALE;
|
||||
|
||||
const normalizeOptions = (
|
||||
currencyOrOptions?: string | LegacyOptions,
|
||||
symbolOrOptions?: string | LegacyOptions
|
||||
) => {
|
||||
const result: {
|
||||
currency: string;
|
||||
symbol: string;
|
||||
locale: string;
|
||||
showSymbol: boolean;
|
||||
} = {
|
||||
currency: DEFAULT_CURRENCY,
|
||||
symbol: DEFAULT_SYMBOL,
|
||||
locale: DEFAULT_LOCALE,
|
||||
showSymbol: true,
|
||||
};
|
||||
|
||||
const applyOptions = (opts?: LegacyOptions) => {
|
||||
if (!opts) return;
|
||||
if (opts.locale) result.locale = opts.locale;
|
||||
if (typeof opts.showSymbol === "boolean") result.showSymbol = opts.showSymbol;
|
||||
if (opts.currencySymbol) result.symbol = opts.currencySymbol;
|
||||
};
|
||||
|
||||
if (typeof currencyOrOptions === "string") {
|
||||
result.currency = currencyOrOptions;
|
||||
} else {
|
||||
applyOptions(currencyOrOptions);
|
||||
}
|
||||
|
||||
if (typeof symbolOrOptions === "string") {
|
||||
result.symbol = symbolOrOptions;
|
||||
} else {
|
||||
applyOptions(symbolOrOptions);
|
||||
}
|
||||
|
||||
// Even if a different currency code is provided, we treat it like JPY for now.
|
||||
const fractionDigits = result.currency.toUpperCase() === "JPY" ? 0 : 2;
|
||||
|
||||
return { ...result, fractionDigits };
|
||||
};
|
||||
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
currencyCode: string,
|
||||
currencyPrefix: string
|
||||
currencyOrOptions?: string | LegacyOptions,
|
||||
symbolOrOptions?: string | LegacyOptions
|
||||
): string {
|
||||
// Determine fraction digits based on currency
|
||||
const fractionDigits = currencyCode === "JPY" ? 0 : 2;
|
||||
const { locale, symbol, showSymbol, fractionDigits } = normalizeOptions(
|
||||
currencyOrOptions,
|
||||
symbolOrOptions
|
||||
);
|
||||
|
||||
// Format the number with appropriate decimal places
|
||||
const formattedAmount = amount.toLocaleString("en-US", {
|
||||
const formatted = amount.toLocaleString(locale, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
|
||||
// Add currency prefix
|
||||
return `${currencyPrefix}${formattedAmount}`;
|
||||
return showSymbol ? `${symbol}${formatted}` : formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a currency string to a number
|
||||
*/
|
||||
export function parseCurrency(value: string): number | null {
|
||||
// Remove currency symbols, commas, and whitespace
|
||||
const cleaned = value.replace(/[¥$€,\s]/g, "");
|
||||
const parsed = Number.parseFloat(cleaned);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user