barsa 0f6bae840f feat: add eligibility check flow with form, OTP, and success steps
- Implemented FormStep component for user input (name, email, address).
- Created OtpStep component for OTP verification.
- Developed SuccessStep component to display success messages based on account creation.
- Introduced eligibility-check.store for managing state throughout the eligibility check process.
- Added commitlint configuration for standardized commit messages.
- Configured knip for workspace management and project structure.
2026-01-15 11:28:25 +09:00

399 lines
12 KiB
TypeScript

import {
orderConfigurationsSchema,
orderSelectionsSchema,
type OrderConfigurations,
type OrderSelections,
type OrderItemSummary,
type OrderDisplayItem,
type OrderDisplayItemCategory,
type OrderDisplayItemCharge,
type OrderDisplayItemChargeKind,
} from "./schema.js";
import { ORDER_TYPE } from "./contract.js";
import type { CheckoutTotals } from "./contract.js";
import type { SimConfigureFormData } from "../sim/index.js";
import type { WhmcsOrderItem } from "./providers/whmcs/raw.types.js";
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, "");
};
type BillingCycle = WhmcsOrderItem["billingCycle"];
const BILLING_CYCLE_ALIASES: Record<string, BillingCycle> = {
monthly: "monthly",
month: "monthly",
onetime: "onetime",
once: "onetime",
singlepayment: "onetime",
annual: "annually",
annually: "annually",
yearly: "annually",
year: "annually",
quarterly: "quarterly",
quarter: "quarterly",
qtr: "quarterly",
semiannual: "semiannually",
semiannually: "semiannually",
semiannualy: "semiannually",
semiannualpayment: "semiannually",
semiannualbilling: "semiannually",
biannual: "semiannually",
biannually: "semiannually",
biennial: "biennially",
biennially: "biennially",
triennial: "triennially",
triennially: "triennially",
free: "free",
};
const normalizeBillingCycleKey = (value: string): string =>
value
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "");
const DEFAULT_BILLING_CYCLE: BillingCycle = "monthly";
export interface NormalizeBillingCycleOptions {
defaultValue?: BillingCycle;
}
/**
* Normalize arbitrary billing cycle strings to the canonical WHMCS values.
* Keeps mapping logic in the domain so both BFF and UI stay in sync.
*/
export function normalizeBillingCycle(
value: unknown,
options: NormalizeBillingCycleOptions = {}
): BillingCycle {
if (typeof value !== "string") {
return options.defaultValue ?? DEFAULT_BILLING_CYCLE;
}
const directKey = normalizeBillingCycleKey(value);
const matched = BILLING_CYCLE_ALIASES[directKey];
if (matched) {
return matched;
}
return options.defaultValue ?? DEFAULT_BILLING_CYCLE;
}
/**
* 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);
}
export function normalizeOrderSelections(value: unknown): OrderSelections {
return orderSelectionsSchema.parse(value);
}
export type OrderStatusTone = "success" | "info" | "warning" | "neutral";
export type OrderStatusState = "active" | "review" | "scheduled" | "activating" | "processing";
export interface OrderStatusDescriptor {
label: string;
state: OrderStatusState;
tone: OrderStatusTone;
description: string;
nextAction?: string | undefined;
timeline?: string | undefined;
scheduledDate?: string | undefined;
}
export interface OrderStatusInput {
status?: string;
activationStatus?: string;
scheduledAt?: string;
}
/**
* Convert backend activation status into a UI-ready descriptor so messaging stays consistent.
*/
export function deriveOrderStatusDescriptor({
status,
activationStatus,
scheduledAt,
}: OrderStatusInput): OrderStatusDescriptor {
if (activationStatus === "Activated") {
return {
label: "Service Active",
state: "active",
tone: "success",
description: "Your service is active and ready to use",
timeline: "Service activated successfully",
};
}
if (status === "Draft" || status === "Pending Review") {
return {
label: "Under Review",
state: "review",
tone: "info",
description: "Our team is reviewing your order details",
nextAction: "We will contact you within 1 business day with next steps",
timeline: "Review typically takes 1 business day",
};
}
if (activationStatus === "Scheduled") {
const scheduledDate = formatScheduledDate(scheduledAt);
return {
label: "Installation Scheduled",
state: "scheduled",
tone: "warning",
description: "Your installation has been scheduled",
nextAction: scheduledDate
? `Installation scheduled for ${scheduledDate}`
: "Installation will be scheduled shortly",
timeline: "Please be available during the scheduled time",
scheduledDate,
};
}
if (activationStatus === "Activating") {
return {
label: "Setting Up Service",
state: "activating",
tone: "info",
description: "We're configuring your service",
nextAction: "Installation team will contact you to schedule",
timeline: "Setup typically takes 3-5 business days",
};
}
return {
label: status || "Processing",
state: "processing",
tone: "neutral",
description: "Your order is being processed",
timeline: "We will update you as progress is made",
};
}
export type OrderServiceCategory = "internet" | "sim" | "vpn" | "default";
/**
* Normalize order type into a UI category identifier.
*/
export function getOrderServiceCategory(orderType?: string): OrderServiceCategory {
switch (orderType) {
case ORDER_TYPE.INTERNET:
return "internet";
case ORDER_TYPE.SIM:
return "sim";
case ORDER_TYPE.VPN:
return "vpn";
default:
return "default";
}
}
export interface OrderTotalsInputItem {
totalPrice?: number | null | undefined;
billingCycle?: string | null | undefined;
}
/**
* Aggregate order item totals by billing cadence using shared normalization rules.
*/
export function calculateOrderTotals(
items?: OrderTotalsInputItem[] | null,
fallbackTotal?: number | null
): CheckoutTotals {
let monthlyTotal = 0;
let oneTimeTotal = 0;
if (Array.isArray(items) && items.length > 0) {
for (const item of items) {
const total = typeof item?.totalPrice === "number" ? item.totalPrice : 0;
const billingCycle = normalizeBillingCycle(item?.billingCycle);
switch (billingCycle) {
case "monthly":
monthlyTotal += total;
break;
case "onetime":
case "free":
oneTimeTotal += total;
break;
default:
monthlyTotal += total;
}
}
} else if (typeof fallbackTotal === "number") {
monthlyTotal = fallbackTotal;
}
return {
monthlyTotal,
oneTimeTotal,
};
}
/**
* Format scheduled activation dates consistently for display.
*/
export function formatScheduledDate(scheduledAt?: string | null): string | undefined {
if (!scheduledAt) return undefined;
const date = new Date(scheduledAt);
if (Number.isNaN(date.getTime())) return undefined;
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
});
}
/**
* Categorize order item by itemClass
*/
export function categorizeOrderItem(itemClass?: string | null): OrderDisplayItemCategory {
const normalized = (itemClass ?? "").toLowerCase();
if (normalized.includes("service")) return "service";
if (normalized.includes("installation")) return "installation";
if (normalized.includes("activation")) return "activation";
if (normalized.includes("add-on") || normalized.includes("addon")) return "addon";
return "other";
}
/**
* Build display items from order item summaries
*/
export function buildOrderDisplayItems(
items: OrderItemSummary[] | null | undefined
): OrderDisplayItem[] {
if (!Array.isArray(items) || items.length === 0) {
return [];
}
return items.map((item, index) => {
const category = categorizeOrderItem(item.itemClass);
const charges = aggregateCharges([item]);
return {
id: item.productId || item.sku || `order-item-${index}`,
name: item.productName || item.name || "Service item",
quantity: item.quantity ?? undefined,
status: item.status ?? undefined,
primaryCategory: category,
categories: [category],
charges,
included: charges.every(charge => charge.amount <= 0),
sourceItems: [item],
isBundle: Boolean(item.isBundledAddon),
};
});
}
function aggregateCharges(items: OrderItemSummary[]): OrderDisplayItemCharge[] {
const accumulator = new Map<
string,
OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }
>();
const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = {
monthly: 0,
"one-time": 1,
other: 2,
};
for (const item of items) {
const amount = Number(item.totalPrice ?? item.unitPrice ?? 0);
const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined);
let kind: OrderDisplayItemChargeKind = "other";
let key = "other";
let label = item.billingCycle?.trim() || "Billing";
let suffix: string | undefined;
if (normalizedCycle === "monthly") {
kind = "monthly";
key = "monthly";
label = "Monthly";
suffix = "/ month";
} else if (normalizedCycle === "onetime" || normalizedCycle === "free") {
kind = "one-time";
key = "one-time";
label = "One-time";
} else if (typeof item.billingCycle === "string" && item.billingCycle.length > 0) {
key = `other:${item.billingCycle.toLowerCase()}`;
}
const existing = accumulator.get(key);
if (existing) {
existing.amount += amount;
} else {
accumulator.set(key, { kind, amount, label, suffix });
}
}
return [...accumulator.values()]
.map(({ kind, amount, label, suffix }) => ({ kind, amount, label, suffix }))
.sort((a, b) => {
const orderDiff = CHARGE_ORDER[a.kind] - CHARGE_ORDER[b.kind];
if (orderDiff !== 0) return orderDiff;
return a.label.localeCompare(b.label);
});
}