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 { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import {
|
import {
|
||||||
createOrderRequestSchema,
|
createOrderRequestSchema,
|
||||||
orderBusinessValidationSchema,
|
orderWithSkuValidationSchema,
|
||||||
type CreateOrderRequest,
|
type CreateOrderRequest,
|
||||||
type OrderBusinessValidation,
|
type OrderBusinessValidation,
|
||||||
} from "@customer-portal/domain/orders";
|
} from "@customer-portal/domain/orders";
|
||||||
@ -176,10 +176,11 @@ export class OrderValidator {
|
|||||||
// 1b. Business validation (ensures userId-specific constraints)
|
// 1b. Business validation (ensures userId-specific constraints)
|
||||||
let businessValidatedBody: OrderBusinessValidation;
|
let businessValidatedBody: OrderBusinessValidation;
|
||||||
try {
|
try {
|
||||||
businessValidatedBody = orderBusinessValidationSchema.parse({
|
const skuValidatedBody = orderWithSkuValidationSchema.parse({
|
||||||
...validatedBody,
|
...validatedBody,
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
businessValidatedBody = skuValidatedBody;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const issues = error.issues.map(issue => {
|
const issues = error.issues.map(issue => {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type {
|
|||||||
SimCancelRequest,
|
SimCancelRequest,
|
||||||
SimTopUpHistoryRequest,
|
SimTopUpHistoryRequest,
|
||||||
SimFeaturesUpdateRequest,
|
SimFeaturesUpdateRequest,
|
||||||
|
SimReissueRequest,
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
|
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
|
||||||
|
|
||||||
@ -108,8 +109,12 @@ export class SimManagementService {
|
|||||||
/**
|
/**
|
||||||
* Reissue eSIM profile
|
* Reissue eSIM profile
|
||||||
*/
|
*/
|
||||||
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
async reissueEsimProfile(
|
||||||
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, newEid);
|
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 { SimValidationService } from "./sim-validation.service";
|
||||||
import { SimNotificationService } from "./sim-notification.service";
|
import { SimNotificationService } from "./sim-notification.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import type { SimReissueRequest } from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EsimManagementService {
|
export class EsimManagementService {
|
||||||
@ -17,7 +18,11 @@ export class EsimManagementService {
|
|||||||
/**
|
/**
|
||||||
* Reissue eSIM profile
|
* Reissue eSIM profile
|
||||||
*/
|
*/
|
||||||
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
async reissueEsimProfile(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimReissueRequest
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
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");
|
throw new BadRequestException("This operation is only available for eSIM subscriptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newEid = request.newEid;
|
||||||
|
|
||||||
if (newEid) {
|
if (newEid) {
|
||||||
if (!/^\d{32}$/.test(newEid)) {
|
|
||||||
throw new BadRequestException("Invalid EID format. Expected 32 digits.");
|
|
||||||
}
|
|
||||||
await this.freebitService.reissueEsimProfileEnhanced(account, newEid, {
|
await this.freebitService.reissueEsimProfileEnhanced(account, newEid, {
|
||||||
oldEid: simDetails.eid,
|
oldEid: simDetails.eid,
|
||||||
planCode: simDetails.planCode,
|
planCode: simDetails.planCode,
|
||||||
@ -60,12 +64,12 @@ export class EsimManagementService {
|
|||||||
error: sanitizedError,
|
error: sanitizedError,
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
newEid: newEid || undefined,
|
newEid: request.newEid || undefined,
|
||||||
});
|
});
|
||||||
await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", {
|
await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
newEid: newEid || undefined,
|
newEid: request.newEid || undefined,
|
||||||
error: sanitizedError,
|
error: sanitizedError,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import type {
|
|||||||
SimCancelRequest,
|
SimCancelRequest,
|
||||||
SimTopUpHistoryRequest,
|
SimTopUpHistoryRequest,
|
||||||
SimFeaturesUpdateRequest,
|
SimFeaturesUpdateRequest,
|
||||||
|
SimReissueRequest,
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -98,8 +99,12 @@ export class SimOrchestratorService {
|
|||||||
/**
|
/**
|
||||||
* Reissue eSIM profile
|
* Reissue eSIM profile
|
||||||
*/
|
*/
|
||||||
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
async reissueEsimProfile(
|
||||||
return this.esimManagement.reissueEsimProfile(userId, subscriptionId, newEid);
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimReissueRequest
|
||||||
|
): Promise<void> {
|
||||||
|
return this.esimManagement.reissueEsimProfile(userId, subscriptionId, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -23,6 +23,9 @@ export class SimPlanService {
|
|||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
request: SimPlanChangeRequest
|
request: SimPlanChangeRequest
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
|
const assignGlobalIp = request.assignGlobalIp ?? false;
|
||||||
|
let scheduledAt: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
@ -31,27 +34,35 @@ export class SimPlanService {
|
|||||||
throw new BadRequestException("Invalid plan code");
|
throw new BadRequestException("Invalid plan code");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically set to 1st of next month
|
scheduledAt = request.scheduledAt;
|
||||||
const nextMonth = new Date();
|
if (scheduledAt) {
|
||||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
if (!/^\d{8}$/.test(scheduledAt)) {
|
||||||
nextMonth.setDate(1); // Set to 1st of the month
|
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);
|
||||||
|
const year = nextMonth.getFullYear();
|
||||||
|
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(nextMonth.getDate()).padStart(2, "0");
|
||||||
|
scheduledAt = `${year}${month}${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Format as YYYYMMDD for Freebit API
|
this.logger.log("Submitting SIM plan change request", {
|
||||||
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}`;
|
|
||||||
|
|
||||||
this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, {
|
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
account,
|
account,
|
||||||
newPlanCode: request.newPlanCode,
|
newPlanCode: request.newPlanCode,
|
||||||
|
scheduledAt,
|
||||||
|
assignGlobalIp,
|
||||||
|
scheduleOrigin: request.scheduledAt ? "user-provided" : "auto-default",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
|
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
|
||||||
assignGlobalIp: false, // Default to no global IP
|
assignGlobalIp,
|
||||||
scheduledAt: scheduledAt,
|
scheduledAt: scheduledAt!,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||||||
@ -59,8 +70,8 @@ export class SimPlanService {
|
|||||||
subscriptionId,
|
subscriptionId,
|
||||||
account,
|
account,
|
||||||
newPlanCode: request.newPlanCode,
|
newPlanCode: request.newPlanCode,
|
||||||
scheduledAt: scheduledAt,
|
scheduledAt,
|
||||||
assignGlobalIp: false,
|
assignGlobalIp,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.simNotification.notifySimAction("Change Plan", "SUCCESS", {
|
await this.simNotification.notifySimAction("Change Plan", "SUCCESS", {
|
||||||
@ -69,6 +80,7 @@ export class SimPlanService {
|
|||||||
account,
|
account,
|
||||||
newPlanCode: request.newPlanCode,
|
newPlanCode: request.newPlanCode,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
|
assignGlobalIp,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -79,11 +91,15 @@ export class SimPlanService {
|
|||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
newPlanCode: request.newPlanCode,
|
newPlanCode: request.newPlanCode,
|
||||||
|
assignGlobalIp,
|
||||||
|
scheduledAt,
|
||||||
});
|
});
|
||||||
await this.simNotification.notifySimAction("Change Plan", "ERROR", {
|
await this.simNotification.notifySimAction("Change Plan", "ERROR", {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
newPlanCode: request.newPlanCode,
|
newPlanCode: request.newPlanCode,
|
||||||
|
assignGlobalIp,
|
||||||
|
scheduledAt,
|
||||||
error: sanitizedError,
|
error: sanitizedError,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -32,10 +32,12 @@ import {
|
|||||||
simChangePlanRequestSchema,
|
simChangePlanRequestSchema,
|
||||||
simCancelRequestSchema,
|
simCancelRequestSchema,
|
||||||
simFeaturesRequestSchema,
|
simFeaturesRequestSchema,
|
||||||
|
simReissueRequestSchema,
|
||||||
type SimTopupRequest,
|
type SimTopupRequest,
|
||||||
type SimChangePlanRequest,
|
type SimChangePlanRequest,
|
||||||
type SimCancelRequest,
|
type SimCancelRequest,
|
||||||
type SimFeaturesRequest,
|
type SimFeaturesRequest,
|
||||||
|
type SimReissueRequest,
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
import { ZodValidationPipe } from "@bff/core/validation";
|
import { ZodValidationPipe } from "@bff/core/validation";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||||
@ -52,12 +54,9 @@ export class SubscriptionsController {
|
|||||||
async getSubscriptions(
|
async getSubscriptions(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Query() query: SubscriptionQuery
|
@Query() query: SubscriptionQuery
|
||||||
): Promise<SubscriptionList | Subscription[]> {
|
): Promise<SubscriptionList> {
|
||||||
if (query.status) {
|
const { status } = query;
|
||||||
return this.subscriptionsService.getSubscriptionsByStatus(req.user.id, query.status);
|
return this.subscriptionsService.getSubscriptions(req.user.id, { status });
|
||||||
}
|
|
||||||
|
|
||||||
return this.subscriptionsService.getSubscriptions(req.user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("active")
|
@Get("active")
|
||||||
@ -185,12 +184,13 @@ export class SubscriptionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(":id/sim/reissue-esim")
|
@Post(":id/sim/reissue-esim")
|
||||||
|
@UsePipes(new ZodValidationPipe(simReissueRequestSchema))
|
||||||
async reissueEsimProfile(
|
async reissueEsimProfile(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Body() body: { newEid?: string } = {}
|
@Body() body: SimReissueRequest
|
||||||
): Promise<SimActionResponse> {
|
): 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" };
|
return { success: true, message: "eSIM profile reissue completed successfully" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,22 @@
|
|||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
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 type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
|
||||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { z } from "zod";
|
|
||||||
import { subscriptionSchema } from "@customer-portal/domain/subscriptions";
|
|
||||||
import type { Providers } from "@customer-portal/domain/subscriptions";
|
import type { Providers } from "@customer-portal/domain/subscriptions";
|
||||||
|
|
||||||
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
|
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
|
||||||
|
|
||||||
export interface GetSubscriptionsOptions {
|
export interface GetSubscriptionsOptions {
|
||||||
status?: string;
|
status?: SubscriptionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -44,25 +48,18 @@ export class SubscriptionsService {
|
|||||||
{ status }
|
{ status }
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsed = z
|
const parsed = subscriptionListSchema.parse(subscriptionList);
|
||||||
.object({
|
|
||||||
subscriptions: z.array(subscriptionSchema),
|
|
||||||
totalCount: z.number(),
|
|
||||||
})
|
|
||||||
.safeParse(subscriptionList);
|
|
||||||
|
|
||||||
if (!parsed.success) {
|
let subscriptions = parsed.subscriptions;
|
||||||
throw new Error(parsed.error.message);
|
if (status) {
|
||||||
|
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
||||||
|
subscriptions = subscriptions.filter(sub => sub.status === normalizedStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = status
|
return subscriptionListSchema.parse({
|
||||||
? parsed.data.subscriptions.filter(sub => sub.status.toLowerCase() === status.toLowerCase())
|
subscriptions,
|
||||||
: parsed.data.subscriptions;
|
totalCount: subscriptions.length,
|
||||||
|
});
|
||||||
return {
|
|
||||||
subscriptions: filtered,
|
|
||||||
totalCount: filtered.length,
|
|
||||||
} satisfies SubscriptionList;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to get subscriptions for user ${userId}`, {
|
this.logger.error(`Failed to get subscriptions for user ${userId}`, {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
@ -141,22 +138,10 @@ export class SubscriptionsService {
|
|||||||
/**
|
/**
|
||||||
* Get subscriptions by status
|
* Get subscriptions by status
|
||||||
*/
|
*/
|
||||||
async getSubscriptionsByStatus(userId: string, status: string): Promise<Subscription[]> {
|
async getSubscriptionsByStatus(userId: string, status: SubscriptionStatus): Promise<Subscription[]> {
|
||||||
try {
|
try {
|
||||||
// Validate status
|
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
||||||
const validStatuses = [
|
const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus });
|
||||||
"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 });
|
|
||||||
return subscriptionList.subscriptions;
|
return subscriptionList.subscriptions;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, {
|
this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, {
|
||||||
|
|||||||
@ -11,11 +11,14 @@ interface UsePaymentRefreshOptions {
|
|||||||
refetch: () => Promise<{ data: PaymentMethodList | undefined }>;
|
refetch: () => Promise<{ data: PaymentMethodList | undefined }>;
|
||||||
// When true, attaches focus/visibility listeners to refresh automatically
|
// When true, attaches focus/visibility listeners to refresh automatically
|
||||||
attachFocusListeners?: boolean;
|
attachFocusListeners?: boolean;
|
||||||
|
// Optional custom detector for whether payment methods exist
|
||||||
|
hasMethods?: (data?: PaymentMethodList | undefined) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePaymentRefresh({
|
export function usePaymentRefresh({
|
||||||
refetch,
|
refetch,
|
||||||
attachFocusListeners = false,
|
attachFocusListeners = false,
|
||||||
|
hasMethods,
|
||||||
}: UsePaymentRefreshOptions) {
|
}: UsePaymentRefreshOptions) {
|
||||||
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
|
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
|
||||||
visible: false,
|
visible: false,
|
||||||
@ -35,7 +38,10 @@ export function usePaymentRefresh({
|
|||||||
const result = await refetch();
|
const result = await refetch();
|
||||||
const parsed = paymentMethodListSchema.safeParse(result.data ?? null);
|
const parsed = paymentMethodListSchema.safeParse(result.data ?? null);
|
||||||
const list = parsed.success ? parsed.data : { paymentMethods: [], totalCount: 0 };
|
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({
|
setToast({
|
||||||
visible: true,
|
visible: true,
|
||||||
text: has ? "Payment methods updated" : "No payment method found yet",
|
text: has ? "Payment methods updated" : "No payment method found yet",
|
||||||
|
|||||||
@ -42,7 +42,8 @@ export function PaymentMethodsContainer() {
|
|||||||
const result = await paymentMethodsQuery.refetch();
|
const result = await paymentMethodsQuery.refetch();
|
||||||
return { data: result.data };
|
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,
|
attachFocusListeners: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -245,4 +246,4 @@ export function PaymentMethodsContainer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PaymentMethodsContainer;
|
export default PaymentMethodsContainer;
|
||||||
|
|||||||
@ -185,7 +185,7 @@ export function OrderSummary({
|
|||||||
<div key={index} className="flex justify-between">
|
<div key={index} className="flex justify-between">
|
||||||
<span className="text-gray-600">{String(fee.name)}</span>
|
<span className="text-gray-600">{String(fee.name)}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
¥{getOneTimePrice(fee).toLocaleString()} one-time
|
¥{(fee.oneTimePrice ?? fee.unitPrice ?? 0).toLocaleString()} one-time
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -53,8 +53,17 @@ function getPriceLabel(installation: InternetInstallationCatalogItem): string {
|
|||||||
if (!priceInfo) {
|
if (!priceInfo) {
|
||||||
return "Price not available";
|
return "Price not available";
|
||||||
}
|
}
|
||||||
const suffix = priceInfo.billingCycle === "Monthly" ? "/month" : " one-time";
|
const billingCycle = installation.billingCycle?.toLowerCase();
|
||||||
return `¥${priceInfo.amount.toLocaleString()}${suffix}`;
|
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({
|
export function InstallationOptions({
|
||||||
|
|||||||
@ -169,7 +169,3 @@ function calculateOneTimeTotal(
|
|||||||
|
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
type InstallationTerm = NonNullable<
|
|
||||||
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
|
|
||||||
>;
|
|
||||||
|
|||||||
@ -1,17 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import type { MnpData } from "@customer-portal/domain/sim";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MnpFormProps {
|
interface MnpFormProps {
|
||||||
wantsMnp: boolean;
|
wantsMnp: boolean;
|
||||||
@ -77,7 +65,7 @@ export function MnpForm({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="reservationNumber"
|
id="reservationNumber"
|
||||||
value={mnpData.reservationNumber}
|
value={mnpData.reservationNumber ?? ""}
|
||||||
onChange={e => handleInputChange("reservationNumber", e.target.value)}
|
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"
|
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"
|
placeholder="10-digit reservation number"
|
||||||
@ -95,7 +83,7 @@ export function MnpForm({
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="expiryDate"
|
id="expiryDate"
|
||||||
value={mnpData.expiryDate}
|
value={mnpData.expiryDate ?? ""}
|
||||||
onChange={e => handleInputChange("expiryDate", e.target.value)}
|
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"
|
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
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
id="phoneNumber"
|
id="phoneNumber"
|
||||||
value={mnpData.phoneNumber}
|
value={mnpData.phoneNumber ?? ""}
|
||||||
onChange={e => handleInputChange("phoneNumber", e.target.value)}
|
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"
|
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"
|
placeholder="090-1234-5678"
|
||||||
@ -133,7 +121,7 @@ export function MnpForm({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="mvnoAccountNumber"
|
id="mvnoAccountNumber"
|
||||||
value={mnpData.mvnoAccountNumber}
|
value={mnpData.mvnoAccountNumber ?? ""}
|
||||||
onChange={e => handleInputChange("mvnoAccountNumber", e.target.value)}
|
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"
|
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"
|
placeholder="Your current carrier account number"
|
||||||
@ -151,7 +139,7 @@ export function MnpForm({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="portingLastName"
|
id="portingLastName"
|
||||||
value={mnpData.portingLastName}
|
value={mnpData.portingLastName ?? ""}
|
||||||
onChange={e => handleInputChange("portingLastName", e.target.value)}
|
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"
|
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"
|
placeholder="Tanaka"
|
||||||
@ -172,7 +160,7 @@ export function MnpForm({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="portingFirstName"
|
id="portingFirstName"
|
||||||
value={mnpData.portingFirstName}
|
value={mnpData.portingFirstName ?? ""}
|
||||||
onChange={e => handleInputChange("portingFirstName", e.target.value)}
|
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"
|
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"
|
placeholder="Taro"
|
||||||
@ -193,7 +181,7 @@ export function MnpForm({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="portingLastNameKatakana"
|
id="portingLastNameKatakana"
|
||||||
value={mnpData.portingLastNameKatakana}
|
value={mnpData.portingLastNameKatakana ?? ""}
|
||||||
onChange={e => handleInputChange("portingLastNameKatakana", e.target.value)}
|
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"
|
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="タナカ"
|
placeholder="タナカ"
|
||||||
@ -214,7 +202,7 @@ export function MnpForm({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="portingFirstNameKatakana"
|
id="portingFirstNameKatakana"
|
||||||
value={mnpData.portingFirstNameKatakana}
|
value={mnpData.portingFirstNameKatakana ?? ""}
|
||||||
onChange={e => handleInputChange("portingFirstNameKatakana", e.target.value)}
|
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"
|
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="タロウ"
|
placeholder="タロウ"
|
||||||
@ -234,10 +222,8 @@ export function MnpForm({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="portingGender"
|
id="portingGender"
|
||||||
value={mnpData.portingGender}
|
value={mnpData.portingGender ?? ""}
|
||||||
onChange={e =>
|
onChange={e => handleInputChange("portingGender", e.target.value)}
|
||||||
handleInputChange("portingGender", e.target.value as MnpData["portingGender"])
|
|
||||||
}
|
|
||||||
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"
|
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>
|
<option value="">Select gender</option>
|
||||||
@ -261,7 +247,7 @@ export function MnpForm({
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
id="portingDateOfBirth"
|
id="portingDateOfBirth"
|
||||||
value={mnpData.portingDateOfBirth}
|
value={mnpData.portingDateOfBirth ?? ""}
|
||||||
onChange={e => handleInputChange("portingDateOfBirth", e.target.value)}
|
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"
|
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 === "Immediate" || activationTypeParam === "Scheduled"
|
||||||
? activationTypeParam
|
? activationTypeParam
|
||||||
: null;
|
: null;
|
||||||
const scheduledAt = params.get("scheduledAt");
|
const scheduledAt = params.get("scheduledAt") ?? params.get("scheduledDate");
|
||||||
const addonSkus = params.getAll("addonSku");
|
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
|
// Optional detailed MNP fields if present
|
||||||
const mnp = {
|
const mnp = {
|
||||||
reservationNumber: params.get("reservationNumber") || undefined,
|
reservationNumber:
|
||||||
expiryDate: params.get("expiryDate") || undefined,
|
params.get("mnpNumber") ??
|
||||||
phoneNumber: params.get("phoneNumber") || undefined,
|
params.get("reservationNumber") ??
|
||||||
mvnoAccountNumber: params.get("mvnoAccountNumber") || undefined,
|
params.get("mnp_reservationNumber") ??
|
||||||
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) ||
|
|
||||||
undefined,
|
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;
|
} as const;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -6,12 +6,12 @@ import { useSimCatalog, useSimPlan, useSimConfigureParams } from ".";
|
|||||||
import { useZodForm } from "@customer-portal/validation";
|
import { useZodForm } from "@customer-portal/validation";
|
||||||
import {
|
import {
|
||||||
simConfigureFormSchema,
|
simConfigureFormSchema,
|
||||||
simConfigureFormToRequest,
|
|
||||||
type SimConfigureFormData,
|
type SimConfigureFormData,
|
||||||
type SimType,
|
type SimCardType,
|
||||||
type ActivationType,
|
type ActivationType,
|
||||||
type MnpData,
|
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";
|
import type { SimCatalogProduct, SimActivationFeeCatalogItem } from "@customer-portal/domain/catalog";
|
||||||
|
|
||||||
export type UseSimConfigureResult = {
|
export type UseSimConfigureResult = {
|
||||||
@ -31,8 +31,8 @@ export type UseSimConfigureResult = {
|
|||||||
validate: () => boolean;
|
validate: () => boolean;
|
||||||
|
|
||||||
// Convenience getters for specific fields
|
// Convenience getters for specific fields
|
||||||
simType: SimType;
|
simType: SimCardType;
|
||||||
setSimType: (value: SimType) => void;
|
setSimType: (value: SimCardType) => void;
|
||||||
eid: string;
|
eid: string;
|
||||||
setEid: (value: string) => void;
|
setEid: (value: string) => void;
|
||||||
selectedAddons: string[];
|
selectedAddons: string[];
|
||||||
@ -63,6 +63,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { data: simData, isLoading: simLoading } = useSimCatalog();
|
const { data: simData, isLoading: simLoading } = useSimCatalog();
|
||||||
const { plan: selectedPlan } = useSimPlan(planId);
|
const { plan: selectedPlan } = useSimPlan(planId);
|
||||||
|
const configureParams = useSimConfigureParams();
|
||||||
|
|
||||||
// Step orchestration state
|
// Step orchestration state
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
@ -93,13 +94,10 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
const { values, errors, setValue, validate } = useZodForm<SimConfigureFormData>({
|
const { values, errors, setValue, validate } = useZodForm<SimConfigureFormData>({
|
||||||
schema: simConfigureFormSchema,
|
schema: simConfigureFormSchema,
|
||||||
initialValues,
|
initialValues,
|
||||||
onSubmit: data => {
|
|
||||||
simConfigureFormToRequest(data);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convenience setters that update the Zod form
|
// 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 setEid = useCallback((value: string) => setValue("eid", value), [setValue]);
|
||||||
const setSelectedAddons = useCallback(
|
const setSelectedAddons = useCallback(
|
||||||
(value: SimConfigureFormData["selectedAddons"]) => setValue("selectedAddons", value),
|
(value: SimConfigureFormData["selectedAddons"]) => setValue("selectedAddons", value),
|
||||||
@ -124,16 +122,70 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Set initial values from URL params or defaults
|
// 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 =
|
const initialActivationType =
|
||||||
(searchParams.get("activationType") as ActivationType) || "Immediate";
|
(configureParams.activationType as ActivationType | null) ??
|
||||||
|
(searchParams.get("activationType") as ActivationType) ??
|
||||||
|
"Immediate";
|
||||||
|
|
||||||
setSimType(initialSimType);
|
setSimType(initialSimType);
|
||||||
setEid(searchParams.get("eid") || "");
|
setEid(configureParams.eid ?? searchParams.get("eid") ?? "");
|
||||||
setSelectedAddons(searchParams.get("addons")?.split(",").filter(Boolean) || []);
|
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);
|
setActivationType(initialActivationType);
|
||||||
setScheduledActivationDate(searchParams.get("scheduledDate") || "");
|
const scheduledAt =
|
||||||
setWantsMnp(searchParams.get("wantsMnp") === "true");
|
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,
|
simLoading,
|
||||||
simData,
|
simData,
|
||||||
selectedPlan,
|
|
||||||
searchParams,
|
searchParams,
|
||||||
|
configureParams,
|
||||||
setSimType,
|
setSimType,
|
||||||
setEid,
|
setEid,
|
||||||
setSelectedAddons,
|
setSelectedAddons,
|
||||||
setActivationType,
|
setActivationType,
|
||||||
setScheduledActivationDate,
|
setScheduledActivationDate,
|
||||||
setWantsMnp,
|
setWantsMnp,
|
||||||
|
setMnpData,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Step transition handler (memoized)
|
// Step transition handler (memoized)
|
||||||
@ -227,15 +280,57 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
|||||||
params.set("activationType", values.activationType);
|
params.set("activationType", values.activationType);
|
||||||
if (values.scheduledActivationDate) {
|
if (values.scheduledActivationDate) {
|
||||||
params.set("scheduledDate", values.scheduledActivationDate);
|
params.set("scheduledDate", values.scheduledActivationDate);
|
||||||
|
params.set(
|
||||||
|
"scheduledAt",
|
||||||
|
values.scheduledActivationDate.replace(/-/g, "")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
params.delete("scheduledDate");
|
||||||
|
params.delete("scheduledAt");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.wantsMnp) {
|
const simConfig = buildSimOrderConfigurations(values);
|
||||||
params.set("wantsMnp", "true");
|
params.set("simConfig", JSON.stringify(simConfig));
|
||||||
if (values.mnpData) {
|
|
||||||
Object.entries(values.mnpData).forEach(([key, value]) => {
|
if (simConfig.scheduledAt && !values.scheduledActivationDate) {
|
||||||
if (value) params.set(`mnp_${key}`, value.toString());
|
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 { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit";
|
||||||
import type { AsyncState } from "@customer-portal/domain/toolkit";
|
import type { AsyncState } from "@customer-portal/domain/toolkit";
|
||||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
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
|
// Use domain Address type
|
||||||
import type { Address } from "@customer-portal/domain/customer";
|
import type { Address } from "@customer-portal/domain/customer";
|
||||||
@ -84,6 +89,25 @@ export function useCheckout() {
|
|||||||
return obj;
|
return obj;
|
||||||
}, [params]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
@ -226,20 +250,20 @@ export function useCheckout() {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
const totals = calculateTotals(items);
|
const totals = calculateTotals(items);
|
||||||
|
const configuration =
|
||||||
|
orderType === ORDER_TYPE.SIM && simConfig ? simConfig : ({} as OrderConfigurations);
|
||||||
setCheckoutState(
|
setCheckoutState(
|
||||||
createSuccessState({
|
createSuccessState({
|
||||||
items,
|
items,
|
||||||
totals,
|
totals,
|
||||||
configuration: {} as OrderConfigurations,
|
configuration,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setCheckoutState(
|
const reason =
|
||||||
createErrorState(
|
error instanceof Error ? error.message : "Failed to load checkout data";
|
||||||
error instanceof Error ? error.message : "Failed to load checkout data"
|
setCheckoutState(createErrorState(new Error(reason)));
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@ -247,7 +271,7 @@ export function useCheckout() {
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [orderType, params, selections]);
|
}, [orderType, params, selections, simConfig]);
|
||||||
|
|
||||||
const handleSubmitOrder = useCallback(async () => {
|
const handleSubmitOrder = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -266,59 +290,90 @@ export function useCheckout() {
|
|||||||
throw new Error("No products selected for order. Please go back and select products.");
|
throw new Error("No products selected for order. Please go back and select products.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const configurations: OrderConfigurations = {
|
let configurationAccumulator: Partial<OrderConfigurations> = {};
|
||||||
...(selections.accessMode ? { accessMode: selections.accessMode as OrderConfigurations["accessMode"] } : {}),
|
|
||||||
...(selections.activationType
|
if (orderType === ORDER_TYPE.SIM) {
|
||||||
? { activationType: selections.activationType as OrderConfigurations["activationType"] }
|
if (simConfig) {
|
||||||
: {}),
|
configurationAccumulator = { ...simConfig };
|
||||||
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
|
} else {
|
||||||
...(selections.simType ? { simType: selections.simType as OrderConfigurations["simType"] } : {}),
|
configurationAccumulator = {
|
||||||
...(selections.eid ? { eid: selections.eid } : {}),
|
...(selections.simType
|
||||||
...(selections.isMnp ? { isMnp: selections.isMnp } : {}),
|
? { simType: selections.simType as OrderConfigurations["simType"] }
|
||||||
...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}),
|
: {}),
|
||||||
...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}),
|
...(selections.activationType
|
||||||
...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}),
|
? { activationType: selections.activationType as OrderConfigurations["activationType"] }
|
||||||
...(selections.mvnoAccountNumber ? { mvnoAccountNumber: selections.mvnoAccountNumber } : {}),
|
: {}),
|
||||||
...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}),
|
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
|
||||||
...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}),
|
...(selections.eid ? { eid: selections.eid } : {}),
|
||||||
...(selections.portingLastNameKatakana
|
...(selections.isMnp ? { isMnp: selections.isMnp } : {}),
|
||||||
? { portingLastNameKatakana: selections.portingLastNameKatakana }
|
...(selections.reservationNumber ? { mnpNumber: selections.reservationNumber } : {}),
|
||||||
: {}),
|
...(selections.expiryDate ? { mnpExpiry: selections.expiryDate } : {}),
|
||||||
...(selections.portingFirstNameKatakana
|
...(selections.phoneNumber ? { mnpPhone: selections.phoneNumber } : {}),
|
||||||
? { portingFirstNameKatakana: selections.portingFirstNameKatakana }
|
...(selections.mvnoAccountNumber
|
||||||
: {}),
|
? { mvnoAccountNumber: selections.mvnoAccountNumber }
|
||||||
...(selections.portingGender
|
: {}),
|
||||||
? {
|
...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}),
|
||||||
portingGender: selections.portingGender as OrderConfigurations["portingGender"],
|
...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}),
|
||||||
}
|
...(selections.portingLastNameKatakana
|
||||||
: {}),
|
? { portingLastNameKatakana: selections.portingLastNameKatakana }
|
||||||
...(selections.portingDateOfBirth ? { portingDateOfBirth: selections.portingDateOfBirth } : {}),
|
: {}),
|
||||||
};
|
...(selections.portingFirstNameKatakana
|
||||||
|
? { portingFirstNameKatakana: selections.portingFirstNameKatakana }
|
||||||
|
: {}),
|
||||||
|
...(selections.portingGender
|
||||||
|
? {
|
||||||
|
portingGender: selections.portingGender as OrderConfigurations["portingGender"],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(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) {
|
if (confirmedAddress) {
|
||||||
configurations.address = {
|
configurationAccumulator.address = {
|
||||||
street: confirmedAddress.street ?? undefined,
|
street: confirmedAddress.address1 ?? undefined,
|
||||||
streetLine2: confirmedAddress.streetLine2 ?? undefined,
|
streetLine2: confirmedAddress.address2 ?? undefined,
|
||||||
city: confirmedAddress.city ?? undefined,
|
city: confirmedAddress.city ?? undefined,
|
||||||
state: confirmedAddress.state ?? undefined,
|
state: confirmedAddress.state ?? undefined,
|
||||||
postalCode: confirmedAddress.postalCode ?? undefined,
|
postalCode: confirmedAddress.postcode ?? undefined,
|
||||||
country: confirmedAddress.country ?? undefined,
|
country: confirmedAddress.country ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasConfiguration = Object.keys(configurationAccumulator).length > 0;
|
||||||
|
const configurations = hasConfiguration
|
||||||
|
? orderConfigurationsSchema.parse(configurationAccumulator)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const orderData = {
|
const orderData = {
|
||||||
orderType,
|
orderType,
|
||||||
skus: uniqueSkus,
|
skus: uniqueSkus,
|
||||||
...(Object.keys(configurations).length > 0 && { configurations }),
|
...(configurations ? { configurations } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (orderType === "SIM") {
|
if (orderType === ORDER_TYPE.SIM) {
|
||||||
if (!selections.eid && selections.simType === "eSIM") {
|
if (!configurations) {
|
||||||
|
throw new Error("SIM configuration is incomplete. Please restart the SIM configuration flow.");
|
||||||
|
}
|
||||||
|
if (configurations?.simType === "eSIM" && !configurations.eid) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"EID is required for eSIM activation. Please go back and provide your EID."
|
"EID is required for eSIM activation. Please go back and provide your EID."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!selections.phoneNumber && !selections.mnpPhone) {
|
if (!configurations?.mnpPhone) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Phone number is required for SIM activation. Please go back and provide a phone number."
|
"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`);
|
router.push(`/orders/${response.sfOrderId}?status=success`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let errorMessage = "Order submission failed";
|
let errorMessage = "Order submission failed";
|
||||||
if (error instanceof Error) errorMessage = error.message;
|
if (error instanceof Error) errorMessage = error.message;
|
||||||
setCheckoutState(createErrorState(errorMessage));
|
setCheckoutState(createErrorState(new Error(errorMessage)));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [checkoutState, confirmedAddress, orderType, selections, router]);
|
}, [checkoutState, confirmedAddress, orderType, selections, router, simConfig, activeSubs]);
|
||||||
|
|
||||||
const confirmAddress = useCallback((address?: Address) => {
|
const confirmAddress = useCallback((address?: Address) => {
|
||||||
setAddressConfirmed(true);
|
setAddressConfirmed(true);
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export function CheckoutContainer() {
|
|||||||
<div className="py-6">
|
<div className="py-6">
|
||||||
<AlertBanner variant="error" title="Unable to load checkout" elevated>
|
<AlertBanner variant="error" title="Unable to load checkout" elevated>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>{checkoutState.error}</span>
|
<span>{checkoutState.error.message}</span>
|
||||||
<Button variant="link" onClick={navigateBackToConfigure}>
|
<Button variant="link" onClick={navigateBackToConfigure}>
|
||||||
Go Back
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
createOrderRequest,
|
|
||||||
orderDetailsSchema,
|
orderDetailsSchema,
|
||||||
orderSummarySchema,
|
orderSummarySchema,
|
||||||
type CreateOrderRequest,
|
type CreateOrderRequest,
|
||||||
@ -13,11 +12,11 @@ import {
|
|||||||
} from "@/lib/api/response-helpers";
|
} from "@/lib/api/response-helpers";
|
||||||
|
|
||||||
async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> {
|
async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> {
|
||||||
const body = createOrderRequest({
|
const body: CreateOrderRequest = {
|
||||||
orderType: payload.orderType,
|
orderType: payload.orderType,
|
||||||
skus: payload.skus,
|
skus: payload.skus,
|
||||||
configurations: payload.configurations ?? undefined,
|
...(payload.configurations ? { configurations: payload.configurations } : {}),
|
||||||
});
|
};
|
||||||
const response = await apiClient.POST("/api/orders", { body });
|
const response = await apiClient.POST("/api/orders", { body });
|
||||||
const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>(
|
const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>(
|
||||||
response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }>
|
response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }>
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export function OrderDetailContainer() {
|
|||||||
? deriveOrderStatusDescriptor({
|
? deriveOrderStatusDescriptor({
|
||||||
status: data.status,
|
status: data.status,
|
||||||
activationStatus: data.activationStatus,
|
activationStatus: data.activationStatus,
|
||||||
scheduledAt: data.scheduledAt,
|
scheduledAt: data.activationScheduledAt,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@ -68,7 +68,7 @@ export function OrderDetailContainer() {
|
|||||||
const totals = calculateOrderTotals(
|
const totals = calculateOrderTotals(
|
||||||
data?.items?.map(item => ({
|
data?.items?.map(item => ({
|
||||||
totalPrice: item.totalPrice,
|
totalPrice: item.totalPrice,
|
||||||
billingCycle: item.product?.billingCycle,
|
billingCycle: item.billingCycle,
|
||||||
})),
|
})),
|
||||||
data?.totalAmount
|
data?.totalAmount
|
||||||
);
|
);
|
||||||
@ -176,7 +176,7 @@ export function OrderDetailContainer() {
|
|||||||
{data.items.map(item => {
|
{data.items.map(item => {
|
||||||
const productName = item.product?.name ?? "Product";
|
const productName = item.product?.name ?? "Product";
|
||||||
const sku = item.product?.sku ?? "N/A";
|
const sku = item.product?.sku ?? "N/A";
|
||||||
const billingCycle = item.product?.billingCycle ?? "";
|
const billingCycle = item.billingCycle ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -4,12 +4,10 @@ import React from "react";
|
|||||||
import { formatPlanShort } from "@/lib/utils";
|
import { formatPlanShort } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
DevicePhoneMobileIcon,
|
DevicePhoneMobileIcon,
|
||||||
WifiIcon,
|
|
||||||
SignalIcon,
|
|
||||||
ClockIcon,
|
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
|
ClockIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
@ -17,363 +15,206 @@ interface SimDetailsCardProps {
|
|||||||
simDetails: SimDetails;
|
simDetails: SimDetails;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
embedded?: boolean; // when true, render content without card container
|
embedded?: boolean;
|
||||||
showFeaturesSummary?: boolean; // show the right-side Service Features summary
|
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({
|
export function SimDetailsCard({
|
||||||
simDetails,
|
simDetails,
|
||||||
isLoading,
|
isLoading = false,
|
||||||
error,
|
error = null,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
showFeaturesSummary = true,
|
showFeaturesSummary = true,
|
||||||
}: SimDetailsCardProps) {
|
}: 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) {
|
if (isLoading) {
|
||||||
const Skeleton = (
|
return <LoadingCard embedded={embedded} />;
|
||||||
<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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <ErrorCard embedded={embedded} message={error} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Specialized, minimal eSIM details view
|
const planName = simDetails.planName || formatPlanShort(simDetails.planCode) || "SIM Plan";
|
||||||
if (simDetails.simType === "esim") {
|
const normalizedStatus = simDetails.status?.toLowerCase() ?? "unknown";
|
||||||
return (
|
const statusIcon = statusIconMap[normalizedStatus] ?? (
|
||||||
<div
|
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
|
||||||
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
|
);
|
||||||
>
|
const statusClass = statusBadgeClass[normalizedStatus] ?? "bg-gray-100 text-gray-800";
|
||||||
{/* Header */}
|
const containerClasses = embedded
|
||||||
<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">
|
: "bg-white shadow-lg rounded-xl border border-gray-100";
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${embedded ? "" : "bg-white shadow rounded-lg"}`}>
|
<div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}>
|
||||||
{/* Header */}
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="rounded-xl bg-blue-50 p-3">
|
||||||
<div className="flex items-center">
|
<DevicePhoneMobileIcon className="h-7 w-7 text-blue-600" />
|
||||||
<div className="text-2xl mr-3">
|
|
||||||
<DevicePhoneMobileIcon className="h-8 w-8 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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
<div>
|
||||||
{getStatusIcon(simDetails.status)}
|
<h3 className="text-xl font-semibold text-gray-900">{planName}</h3>
|
||||||
<span
|
<p className="text-sm text-gray-600">Account #{simDetails.account}</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Content */}
|
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className={`${embedded ? "" : "px-6 py-4"}`}>
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<section className="bg-gray-50 rounded-lg p-4">
|
||||||
{/* SIM Information */}
|
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
|
||||||
SIM Information
|
SIM Information
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-3">
|
<dl className="space-y-2 text-sm text-gray-700">
|
||||||
<div>
|
<div className="flex justify-between">
|
||||||
<label className="text-xs text-gray-500">Phone Number</label>
|
<dt className="font-medium text-gray-600">Phone Number</dt>
|
||||||
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
|
<dd className="font-semibold text-gray-900">{simDetails.msisdn}</dd>
|
||||||
|
</div>
|
||||||
|
<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>
|
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{simDetails.eid && (
|
{simDetails.eid && (
|
||||||
<div>
|
<div className="flex justify-between">
|
||||||
<label className="text-xs text-gray-500">EID (eSIM)</label>
|
<dt className="font-medium text-gray-600">EID</dt>
|
||||||
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p>
|
<dd className="font-mono text-gray-900 break-all">{simDetails.eid}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
{simDetails.imsi && (
|
<dt className="font-medium text-gray-600">Network Type</dt>
|
||||||
<div>
|
<dd className="font-semibold text-gray-900">{simDetails.networkType}</dd>
|
||||||
<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
|
|
||||||
</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">
|
|
||||||
{formatQuota(simDetails.remainingQuotaMb)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
</dl>
|
||||||
)}
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<p className="text-2xl font-bold text-green-600">
|
||||||
|
{formatQuota(simDetails.remainingQuotaMb)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Remaining allowance in current cycle</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Pending Operations */}
|
{showFeaturesSummary && (
|
||||||
{simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && (
|
<section className="space-y-3">
|
||||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
Service Features
|
||||||
Pending Operations
|
|
||||||
</h4>
|
</h4>
|
||||||
<div className="bg-blue-50 rounded-lg p-4">
|
<FeatureToggleRow label="Voice Mail" enabled={simDetails.voiceMailEnabled} />
|
||||||
{simDetails.pendingOperations.map((operation, index) => (
|
<FeatureToggleRow label="Call Waiting" enabled={simDetails.callWaitingEnabled} />
|
||||||
<div key={index} className="flex items-center text-sm">
|
<FeatureToggleRow
|
||||||
<ClockIcon className="h-4 w-4 text-blue-500 mr-2" />
|
label="International Roaming"
|
||||||
<span className="text-blue-800">
|
enabled={simDetails.internationalRoamingEnabled}
|
||||||
{operation.operation} scheduled for {formatDate(operation.scheduledDate)}
|
/>
|
||||||
</span>
|
</section>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { SimDetails };
|
||||||
|
|||||||
@ -6,11 +6,12 @@ import {
|
|||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { SimDetailsCard, type SimDetails } from "./SimDetailsCard";
|
import { SimDetailsCard } from "./SimDetailsCard";
|
||||||
import { DataUsageChart, type SimUsage } from "./DataUsageChart";
|
import { DataUsageChart, type SimUsage } from "./DataUsageChart";
|
||||||
import { SimActions } from "./SimActions";
|
import { SimActions } from "./SimActions";
|
||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
import { SimFeatureToggles } from "./SimFeatureToggles";
|
import { SimFeatureToggles } from "./SimFeatureToggles";
|
||||||
|
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
interface SimManagementSectionProps {
|
interface SimManagementSectionProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
@ -137,6 +138,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actionSimType: "esim" | "physical" =
|
||||||
|
simInfo.details.simType.toLowerCase() === "esim" ? "esim" : "physical";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="sim-management" className="space-y-8">
|
<div id="sim-management" className="space-y-8">
|
||||||
{/* SIM Details and Usage - Main Content */}
|
{/* 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">
|
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
|
||||||
<SimActions
|
<SimActions
|
||||||
subscriptionId={subscriptionId}
|
subscriptionId={subscriptionId}
|
||||||
simType={simInfo.details.simType}
|
simType={actionSimType}
|
||||||
status={simInfo.details.status}
|
status={simInfo.details.status}
|
||||||
currentPlanCode={simInfo.details.planCode}
|
currentPlanCode={simInfo.details.planCode}
|
||||||
onTopUpSuccess={handleActionSuccess}
|
onTopUpSuccess={handleActionSuccess}
|
||||||
|
|||||||
@ -148,7 +148,7 @@ export function SimCancelContainer() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||||
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
||||||
<InfoRow label="Start Date" value={details?.startDate || "—"} />
|
<InfoRow label="Activated" value={details?.activatedAt || "—"} />
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Cancellation Month
|
Cancellation Month
|
||||||
|
|||||||
@ -14,11 +14,6 @@ interface UseSubscriptionsOptions {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptySubscriptionList: SubscriptionList = {
|
|
||||||
subscriptions: [],
|
|
||||||
totalCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const emptyStats = {
|
const emptyStats = {
|
||||||
total: 0,
|
total: 0,
|
||||||
active: 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
|
* Hook to fetch all subscriptions
|
||||||
*/
|
*/
|
||||||
@ -62,9 +44,7 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
|||||||
"/api/subscriptions",
|
"/api/subscriptions",
|
||||||
status ? { params: { query: { status } } } : undefined
|
status ? { params: { query: { status } } } : undefined
|
||||||
);
|
);
|
||||||
return toSubscriptionList(
|
return getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions");
|
||||||
getDataOrThrow<SubscriptionList>(response, "Failed to load subscriptions")
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
gcTime: 10 * 60 * 1000,
|
gcTime: 10 * 60 * 1000,
|
||||||
|
|||||||
@ -146,7 +146,7 @@ export function SimCancelContainer() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||||
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
||||||
<InfoRow label="Start Date" value={details?.startDate || "—"} />
|
<InfoRow label="Activated" value={details?.activatedAt || "—"} />
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Cancellation Month
|
Cancellation Month
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
export { createClient, resolveBaseUrl } from "./runtime/client";
|
export { createClient, resolveBaseUrl } from "./runtime/client";
|
||||||
export type { ApiClient, AuthHeaderResolver, CreateClientOptions, QueryParams, PathParams } 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
|
// Re-export API helpers
|
||||||
export * from "./response-helpers";
|
export * from "./response-helpers";
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export type HttpMethod =
|
|||||||
| "HEAD"
|
| "HEAD"
|
||||||
| "OPTIONS";
|
| "OPTIONS";
|
||||||
|
|
||||||
type PathParams = Record<string, string | number>;
|
export type PathParams = Record<string, string | number>;
|
||||||
export type QueryPrimitive = string | number | boolean;
|
export type QueryPrimitive = string | number | boolean;
|
||||||
export type QueryParams = Record<
|
export type QueryParams = Record<
|
||||||
string,
|
string,
|
||||||
@ -383,8 +383,12 @@ export function createClient(options: CreateClientOptions = {}): ApiClient {
|
|||||||
|
|
||||||
const parsedBody = await parseResponseBody(response);
|
const parsedBody = await parseResponseBody(response);
|
||||||
|
|
||||||
|
if (parsedBody === undefined || parsedBody === null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
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 OrderCreationType,
|
||||||
type OrderStatus,
|
type OrderStatus,
|
||||||
type OrderType,
|
type OrderType,
|
||||||
|
type OrderTypeValue,
|
||||||
type UserMapping,
|
type UserMapping,
|
||||||
// Constants
|
// Constants
|
||||||
ORDER_TYPE,
|
ORDER_TYPE,
|
||||||
@ -29,6 +30,10 @@ export * from "./validation";
|
|||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
export {
|
||||||
|
buildSimOrderConfigurations,
|
||||||
|
type BuildSimOrderConfigurationsOptions,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@ -30,6 +30,11 @@ export type {
|
|||||||
SimCancelRequest,
|
SimCancelRequest,
|
||||||
SimTopUpHistoryRequest,
|
SimTopUpHistoryRequest,
|
||||||
SimFeaturesUpdateRequest,
|
SimFeaturesUpdateRequest,
|
||||||
|
SimReissueRequest,
|
||||||
|
SimConfigureFormData,
|
||||||
|
SimCardType,
|
||||||
|
ActivationType,
|
||||||
|
MnpData,
|
||||||
// Activation types
|
// Activation types
|
||||||
SimOrderActivationRequest,
|
SimOrderActivationRequest,
|
||||||
SimOrderActivationMnp,
|
SimOrderActivationMnp,
|
||||||
|
|||||||
@ -111,6 +111,95 @@ export const simReissueRequestSchema = z.object({
|
|||||||
.optional(),
|
.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
|
// SIM Order Activation Schemas
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -134,9 +223,9 @@ export const simOrderActivationAddonsSchema = z.object({
|
|||||||
|
|
||||||
export const simOrderActivationRequestSchema = z.object({
|
export const simOrderActivationRequestSchema = z.object({
|
||||||
planSku: z.string().min(1, "Plan SKU is required"),
|
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(),
|
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(),
|
scheduledAt: z.string().regex(/^\d{8}$/, "Scheduled date must be in YYYYMMDD format").optional(),
|
||||||
addons: simOrderActivationAddonsSchema.optional(),
|
addons: simOrderActivationAddonsSchema.optional(),
|
||||||
mnp: simOrderActivationMnpSchema.optional(),
|
mnp: simOrderActivationMnpSchema.optional(),
|
||||||
@ -202,3 +291,51 @@ export type SimCancelRequest = z.infer<typeof simCancelRequestSchema>;
|
|||||||
export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>;
|
export type SimTopUpHistoryRequest = z.infer<typeof simTopUpHistoryRequestSchema>;
|
||||||
export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>;
|
export type SimFeaturesUpdateRequest = z.infer<typeof simFeaturesUpdateRequestSchema>;
|
||||||
export type SimReissueRequest = z.infer<typeof simReissueRequestSchema>;
|
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
|
* Toolkit - Currency Formatting
|
||||||
*
|
*
|
||||||
* Simple currency formatting. Currency code comes from user's WHMCS profile.
|
* Our product currently operates in Japanese Yen only, but we keep a single
|
||||||
* Typically JPY for this application.
|
* 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 = {
|
||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
export function formatCurrency(
|
export function formatCurrency(
|
||||||
amount: number,
|
amount: number,
|
||||||
currencyCode: string,
|
currencyOrOptions?: string | LegacyOptions,
|
||||||
currencyPrefix: string
|
symbolOrOptions?: string | LegacyOptions
|
||||||
): string {
|
): string {
|
||||||
// Determine fraction digits based on currency
|
const { locale, symbol, showSymbol, fractionDigits } = normalizeOptions(
|
||||||
const fractionDigits = currencyCode === "JPY" ? 0 : 2;
|
currencyOrOptions,
|
||||||
|
symbolOrOptions
|
||||||
// Format the number with appropriate decimal places
|
);
|
||||||
const formattedAmount = amount.toLocaleString("en-US", {
|
|
||||||
|
const formatted = amount.toLocaleString(locale, {
|
||||||
minimumFractionDigits: fractionDigits,
|
minimumFractionDigits: fractionDigits,
|
||||||
maximumFractionDigits: fractionDigits,
|
maximumFractionDigits: fractionDigits,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add currency prefix
|
return showSymbol ? `${symbol}${formatted}` : formatted;
|
||||||
return `${currencyPrefix}${formattedAmount}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a currency string to a number
|
|
||||||
*/
|
|
||||||
export function parseCurrency(value: string): number | null {
|
export function parseCurrency(value: string): number | null {
|
||||||
// Remove currency symbols, commas, and whitespace
|
|
||||||
const cleaned = value.replace(/[¥$€,\s]/g, "");
|
const cleaned = value.replace(/[¥$€,\s]/g, "");
|
||||||
const parsed = Number.parseFloat(cleaned);
|
const parsed = Number.parseFloat(cleaned);
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user