Refactor authentication and user profile management to improve type consistency and error handling. Update type definitions for requests and responses across various components, ensuring alignment with shared domain contracts. Enhance state management in AppShell and streamline user data hydration logic for better maintainability.

This commit is contained in:
barsa 2025-09-25 18:04:47 +09:00
parent ec69e3dcbb
commit b6d0aa1eb0
20 changed files with 248 additions and 150 deletions

View File

@ -75,17 +75,19 @@ export function AppShell({ children }: AppShellProps) {
if (!prof) { if (!prof) {
return; return;
} }
useAuthStore.setState(state => ({ useAuthStore.setState(state =>
...state, state.user
user: state.user
? { ? {
...state.user, ...state,
firstName: prof.firstName || state.user.firstName, user: {
lastName: prof.lastName || state.user.lastName, ...state.user,
phone: prof.phone || state.user.phone, firstName: prof.firstName || state.user.firstName,
lastName: prof.lastName || state.user.lastName,
phone: prof.phone || state.user.phone,
},
} }
: prof, : state
})); );
} catch { } catch {
// best-effort profile hydration; ignore errors // best-effort profile hydration; ignore errors
} }

View File

@ -7,7 +7,7 @@ import { useWhmcsLink } from "@/features/auth/hooks";
import { import {
linkWhmcsRequestSchema, linkWhmcsRequestSchema,
type LinkWhmcsFormData, type LinkWhmcsFormData,
type LinkWhmcsRequestData, type LinkWhmcsRequestInput,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
@ -23,7 +23,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
async (formData: LinkWhmcsFormData) => { async (formData: LinkWhmcsFormData) => {
clearError(); clearError();
try { try {
const payload: LinkWhmcsRequestData = { const payload: LinkWhmcsRequestInput = {
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
}; };

View File

@ -9,7 +9,7 @@ import { useState, useCallback, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { ErrorMessage } from "@/components/atoms"; import { ErrorMessage } from "@/components/atoms";
import { useSignup } from "../../hooks/use-auth"; import { useSignup } from "../../hooks/use-auth";
import { signupFormSchema, signupFormToRequest, type SignupRequest } from "@customer-portal/domain"; import { signupFormSchema, signupFormToRequest, type SignupRequestInput } from "@customer-portal/domain";
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
import { z } from "zod"; import { z } from "zod";
@ -56,20 +56,13 @@ export function SignupForm({
const handleSignup = useCallback( const handleSignup = useCallback(
async ({ async ({
confirmPassword: _confirm, confirmPassword: _confirm,
acceptTerms, ...formData
marketingConsent, }: SignupFormValues) => {
...formData clearError();
}: SignupFormValues) => { try {
clearError(); const request: SignupRequestInput = signupFormToRequest(formData);
try { await signup(request);
const baseRequest = signupFormToRequest(formData);
const request: SignupRequest = {
...baseRequest,
acceptTerms,
marketingConsent,
};
await signup(request);
onSuccess?.(); onSuccess?.();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Signup failed"; const message = err instanceof Error ? err.message : "Signup failed";

View File

@ -9,7 +9,7 @@ import { useCallback, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useAuthStore } from "../services/auth.store"; import { useAuthStore } from "../services/auth.store";
import { getPostLoginRedirect } from "@/features/auth/utils/route-protection"; import { getPostLoginRedirect } from "@/features/auth/utils/route-protection";
import type { SignupRequest, LoginRequest } from "@customer-portal/domain"; import type { SignupRequestInput, LoginRequestInput } from "@customer-portal/domain";
/** /**
* Main authentication hook * Main authentication hook
@ -21,7 +21,7 @@ export function useAuth() {
// Enhanced login with redirect handling // Enhanced login with redirect handling
const login = useCallback( const login = useCallback(
async (credentials: LoginRequest) => { async (credentials: LoginRequestInput) => {
await store.login(credentials); await store.login(credentials);
const redirectTo = getPostLoginRedirect(searchParams); const redirectTo = getPostLoginRedirect(searchParams);
router.push(redirectTo); router.push(redirectTo);
@ -31,7 +31,7 @@ export function useAuth() {
// Enhanced signup with redirect handling // Enhanced signup with redirect handling
const signup = useCallback( const signup = useCallback(
async (data: SignupRequest) => { async (data: SignupRequestInput) => {
await store.signup(data); await store.signup(data);
const redirectTo = getPostLoginRedirect(searchParams); const redirectTo = getPostLoginRedirect(searchParams);
router.push(redirectTo); router.push(redirectTo);

View File

@ -10,10 +10,10 @@ import { getErrorInfo, handleAuthError } from "@/lib/utils/error-handling";
import logger from "@customer-portal/logging"; import logger from "@customer-portal/logging";
import type { import type {
AuthTokens, AuthTokens,
UserProfile, AuthenticatedUser,
LinkWhmcsRequestData, LinkWhmcsRequestInput,
LoginRequest, LoginRequestInput,
SignupRequest, SignupRequestInput,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
import { authResponseSchema } from "@customer-portal/domain/validation"; import { authResponseSchema } from "@customer-portal/domain/validation";
@ -28,7 +28,7 @@ const withAuthHeaders = (accessToken?: string) =>
interface AuthState { interface AuthState {
// State // State
user: UserProfile | null; user: AuthenticatedUser | null;
tokens: AuthTokens | null; tokens: AuthTokens | null;
isAuthenticated: boolean; isAuthenticated: boolean;
loading: boolean; loading: boolean;
@ -37,15 +37,15 @@ interface AuthState {
hasCheckedAuth: boolean; hasCheckedAuth: boolean;
// Actions // Actions
login: (credentials: LoginRequest) => Promise<void>; login: (credentials: LoginRequestInput) => Promise<void>;
signup: (data: SignupRequest) => Promise<void>; signup: (data: SignupRequestInput) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
requestPasswordReset: (email: string) => Promise<void>; requestPasswordReset: (email: string) => Promise<void>;
resetPassword: (token: string, password: string) => Promise<void>; resetPassword: (token: string, password: string) => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>; changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>; checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>;
linkWhmcs: ( linkWhmcs: (
request: LinkWhmcsRequestData request: LinkWhmcsRequestInput
) => Promise<{ needsPasswordSet: boolean; email: string }>; ) => Promise<{ needsPasswordSet: boolean; email: string }>;
setPassword: (email: string, password: string) => Promise<void>; setPassword: (email: string, password: string) => Promise<void>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
@ -70,7 +70,7 @@ export const useAuthStore = create<AuthState>()(
hasCheckedAuth: false, hasCheckedAuth: false,
// Actions // Actions
login: async (credentials: LoginRequest) => { login: async (credentials: LoginRequestInput) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
// Use shared API client with consistent configuration // Use shared API client with consistent configuration
@ -99,7 +99,7 @@ export const useAuthStore = create<AuthState>()(
} }
}, },
signup: async (data: SignupRequest) => { signup: async (data: SignupRequestInput) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST("/auth/signup", { body: data }); const response = await apiClient.POST("/auth/signup", { body: data });
@ -248,7 +248,7 @@ export const useAuthStore = create<AuthState>()(
} }
}, },
linkWhmcs: async ({ email, password }: LinkWhmcsRequestData) => { linkWhmcs: async ({ email, password }: LinkWhmcsRequestInput) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST("/auth/link-whmcs", { const response = await apiClient.POST("/auth/link-whmcs", {
@ -305,11 +305,11 @@ export const useAuthStore = create<AuthState>()(
if (!tokens?.accessToken) return; if (!tokens?.accessToken) return;
try { try {
const response = await apiClient.GET<UserProfile>("/me", { const response = await apiClient.GET<AuthenticatedUser>("/me", {
...withAuthHeaders(tokens.accessToken), ...withAuthHeaders(tokens.accessToken),
}); });
const profile = getNullableData<UserProfile>(response); const profile = getNullableData<AuthenticatedUser>(response);
if (!profile) { if (!profile) {
// Token might be expired, try to refresh // Token might be expired, try to refresh
await get().refreshTokens(); await get().refreshTokens();

View File

@ -11,14 +11,16 @@ import { AnimatedCard } from "@/components/molecules";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
// Use consolidated domain types // Align with shared catalog contracts
import type { OrderItemRequest, OrderTotals as DomainOrderTotals } from "@customer-portal/domain"; import type { CatalogProductBase } from "@customer-portal/domain";
// Enhanced OrderItem for UI - properly extends unified types instead of redefining everything // Enhanced order item representation for UI summary
export interface OrderItem extends Omit<OrderItemRequest, "id"> { export type OrderItem = CatalogProductBase & {
id?: string; // Optional for UI purposes (OrderItemRequest.id is required) id?: string;
description?: string; quantity?: number;
} autoAdded?: boolean;
itemClass?: string;
};
export interface OrderConfiguration { export interface OrderConfiguration {
label: string; label: string;
@ -26,8 +28,10 @@ export interface OrderConfiguration {
important?: boolean; important?: boolean;
} }
// Extend domain OrderTotals with UI-specific fields // Totals summary for UI; base fields mirror API aggregates
export interface OrderTotals extends DomainOrderTotals { export interface OrderTotals {
monthlyTotal: number;
oneTimeTotal: number;
annualTotal?: number; annualTotal?: number;
discountAmount?: number; discountAmount?: number;
taxAmount?: number; taxAmount?: number;

View File

@ -1,5 +1,5 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
import type { CatalogOrderItem } from "@customer-portal/domain"; import type { CatalogProductBase } from "@customer-portal/domain";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
@ -11,8 +11,8 @@ interface OrderSummaryProps {
}; };
// Selected items // Selected items
selectedAddons?: CatalogOrderItem[]; selectedAddons?: CatalogProductBase[];
activationFees?: CatalogOrderItem[]; activationFees?: CatalogProductBase[];
// Configuration details // Configuration details
configDetails?: Array<{ configDetails?: Array<{

View File

@ -14,7 +14,7 @@ import { InstallationStep } from "./steps/InstallationStep";
import { AddonsStep } from "./steps/AddonsStep"; import { AddonsStep } from "./steps/AddonsStep";
import { ReviewOrderStep } from "./steps/ReviewOrderStep"; import { ReviewOrderStep } from "./steps/ReviewOrderStep";
import { useConfigureState } from "./hooks/useConfigureState"; import { useConfigureState } from "./hooks/useConfigureState";
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; import { getMonthlyPrice, getOneTimePrice } from "../../../utils/pricing";
interface Props { interface Props {
plan: InternetPlanCatalogItem | null; plan: InternetPlanCatalogItem | null;
@ -29,7 +29,7 @@ const STEPS = [
{ number: 2, title: "Installation", description: "Installation method" }, { number: 2, title: "Installation", description: "Installation method" },
{ number: 3, title: "Add-ons", description: "Optional services" }, { number: 3, title: "Add-ons", description: "Optional services" },
{ number: 4, title: "Review", description: "Order summary" }, { number: 4, title: "Review", description: "Order summary" },
]; ] as const;
export function InternetConfigureContainer({ export function InternetConfigureContainer({
plan, plan,
@ -56,6 +56,11 @@ export function InternetConfigureContainer({
const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddonSkus(newSelectedSkus); const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddonSkus(newSelectedSkus);
const progressSteps = STEPS.map(step => ({
...step,
completed: currentStep > step.number,
}));
if (loading) { if (loading) {
return <ConfigureLoadingSkeleton />; return <ConfigureLoadingSkeleton />;
} }
@ -86,7 +91,7 @@ export function InternetConfigureContainer({
{/* Progress Steps */} {/* Progress Steps */}
<div className="mb-8"> <div className="mb-8">
<ProgressSteps steps={STEPS} currentStep={currentStep} /> <ProgressSteps steps={progressSteps} currentStep={currentStep} />
</div> </div>
{/* Step Content */} {/* Step Content */}
@ -106,7 +111,6 @@ export function InternetConfigureContainer({
installations={installations} installations={installations}
selectedInstallation={selectedInstallation} selectedInstallation={selectedInstallation}
setSelectedInstallationSku={setSelectedInstallationSku} setSelectedInstallationSku={setSelectedInstallationSku}
selectedInstallationType={selectedInstallationType}
isTransitioning={isTransitioning} isTransitioning={isTransitioning}
onBack={() => transitionToStep(1)} onBack={() => transitionToStep(1)}
onNext={() => canProceedFromStep(2) && transitionToStep(3)} onNext={() => canProceedFromStep(2) && transitionToStep(3)}

View File

@ -11,7 +11,6 @@ interface Props {
installations: InternetInstallationCatalogItem[]; installations: InternetInstallationCatalogItem[];
selectedInstallation: InternetInstallationCatalogItem | null; selectedInstallation: InternetInstallationCatalogItem | null;
setSelectedInstallationSku: (sku: string | null) => void; setSelectedInstallationSku: (sku: string | null) => void;
selectedInstallationType: string | null;
isTransitioning: boolean; isTransitioning: boolean;
onBack: () => void; onBack: () => void;
onNext: () => void; onNext: () => void;
@ -21,7 +20,6 @@ export function InstallationStep({
installations, installations,
selectedInstallation, selectedInstallation,
setSelectedInstallationSku, setSelectedInstallationSku,
selectedInstallationType,
isTransitioning, isTransitioning,
onBack, onBack,
onNext, onNext,
@ -43,9 +41,10 @@ export function InstallationStep({
<InstallationOptions <InstallationOptions
installations={installations} installations={installations}
selectedInstallation={selectedInstallation} selectedInstallationSku={selectedInstallation?.sku ?? null}
setSelectedInstallationSku={setSelectedInstallationSku} onInstallationSelect={installation =>
selectedInstallationType={selectedInstallationType} setSelectedInstallationSku(installation ? installation.sku : null)
}
/> />
<div className="flex justify-between mt-6"> <div className="flex justify-between mt-6">

View File

@ -92,10 +92,10 @@ function SilverPlanConfiguration({
details="Traditional connection method with username/password authentication. Compatible with most routers and ISPs." details="Traditional connection method with username/password authentication. Compatible with most routers and ISPs."
/> />
<ModeSelectionCard <ModeSelectionCard
mode="IPoE" mode="IPoE-BYOR"
selectedMode={mode} selectedMode={mode}
onSelect={setMode} onSelect={setMode}
title="IPoE" title="IPoE (BYOR)"
description="IP over Ethernet" description="IP over Ethernet"
details="Modern connection method with automatic configuration. Simplified setup with faster connection times." details="Modern connection method with automatic configuration. Simplified setup with faster connection times."
/> />

View File

@ -3,10 +3,10 @@
import { AnimatedCard } from "@/components/molecules"; import { AnimatedCard } from "@/components/molecules";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { CurrencyYenIcon } from "@heroicons/react/24/outline"; import { CurrencyYenIcon } from "@heroicons/react/24/outline";
import type { VpnPlan } from "@customer-portal/domain"; import type { VpnCatalogProduct } from "@customer-portal/domain";
interface VpnPlanCardProps { interface VpnPlanCardProps {
plan: VpnPlan; plan: VpnCatalogProduct;
} }
export function VpnPlanCard({ plan }: VpnPlanCardProps) { export function VpnPlanCard({ plan }: VpnPlanCardProps) {

View File

@ -118,7 +118,7 @@ export function SimPlansContainer() {
); );
} }
const plansByType: PlansByType = simPlans.reduce( const plansByType = simPlans.reduce<PlansByType>(
(acc, plan) => { (acc, plan) => {
const planType = plan.simPlanType || "DataOnly"; const planType = plan.simPlanType || "DataOnly";
if (planType === "DataOnly") acc.DataOnly.push(plan); if (planType === "DataOnly") acc.DataOnly.push(plan);

View File

@ -6,32 +6,34 @@ import { catalogService } from "@/features/catalog/services/catalog.service";
import { ordersService } from "@/features/orders/services/orders.service"; import { ordersService } from "@/features/orders/services/orders.service";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import type { import { getMonthlyPrice, getOneTimePrice } from "@/features/catalog/utils/pricing";
InternetPlanCatalogItem, import type { CatalogProductBase } from "@customer-portal/domain";
InternetInstallationCatalogItem, import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain";
InternetAddonCatalogItem, import type { AsyncState } from "@customer-portal/domain";
SimCatalogProduct,
SimActivationFeeCatalogItem,
VpnCatalogProduct,
} from "@customer-portal/domain";
import {
buildInternetOrderItems,
buildSimOrderItems,
calculateOrderTotals,
extractOrderSKUs,
createLoadingState,
createSuccessState,
createErrorState,
} from "@customer-portal/domain";
import type { AsyncState, CheckoutCart, CatalogOrderItem } from "@customer-portal/domain";
// Type alias for convenience
type OrderItem = CatalogOrderItem;
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
// Use domain Address type // Use domain Address type
import type { Address } from "@customer-portal/domain"; import type { Address } from "@customer-portal/domain";
type CheckoutItemType = "plan" | "installation" | "addon" | "activation" | "vpn";
interface CheckoutItem extends CatalogProductBase {
quantity: number;
itemType: CheckoutItemType;
autoAdded?: boolean;
}
interface CheckoutTotals {
monthlyTotal: number;
oneTimeTotal: number;
}
interface CheckoutCart {
items: CheckoutItem[];
totals: CheckoutTotals;
configuration: Record<string, unknown>;
}
export function useCheckout() { export function useCheckout() {
const params = useSearchParams(); const params = useSearchParams();
const router = useRouter(); const router = useRouter();
@ -84,76 +86,149 @@ export function useCheckout() {
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
const collectAddonRefs = () => {
const refs = new Set<string>();
params.getAll("addonSku").forEach(sku => {
if (sku) refs.add(sku);
});
if (selections.addonSku) refs.add(selections.addonSku);
if (selections.addons) {
selections.addons
.split(",")
.map(value => value.trim())
.filter(Boolean)
.forEach(value => refs.add(value));
}
return Array.from(refs);
};
const calculateTotals = (items: CheckoutItem[]): CheckoutTotals =>
items.reduce<CheckoutTotals>(
(acc, item) => {
acc.monthlyTotal += getMonthlyPrice(item) * item.quantity;
acc.oneTimeTotal += getOneTimePrice(item) * item.quantity;
return acc;
},
{ monthlyTotal: 0, oneTimeTotal: 0 }
);
void (async () => { void (async () => {
try { try {
setCheckoutState(createLoadingState()); setCheckoutState(createLoadingState());
if (!selections.plan) { const planRef =
selections.plan ??
selections.planId ??
selections.planSku ??
selections.planIdSku ??
null;
if (!planRef) {
throw new Error("No plan selected. Please go back and select a plan."); throw new Error("No plan selected. Please go back and select a plan.");
} }
let items: OrderItem[] = []; const addonRefs = collectAddonRefs();
const items: CheckoutItem[] = [];
if (orderType === "Internet") { if (orderType === "Internet") {
const [plans, addons, installations] = await Promise.all([ const { plans, addons, installations } = await catalogService.getInternetCatalog();
catalogService.getInternetPlans(),
catalogService.getInternetAddons(),
catalogService.getInternetInstallations(),
]);
const plan = plans.find(p => p.sku === selections.plan); const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
if (!plan) { if (!plan) {
throw new Error( throw new Error(
`Internet plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.` `Internet plan not found for reference: ${planRef}. Please go back and select a valid plan.`
); );
} }
const addonSkus: string[] = []; items.push({ ...plan, quantity: 1, itemType: "plan" });
const urlParams = new URLSearchParams(window.location.search);
urlParams.getAll("addonSku").forEach(sku => {
if (sku && !addonSkus.includes(sku)) addonSkus.push(sku);
});
items = buildInternetOrderItems(plan, addons, installations, { if (selections.installationSku) {
installationSku: selections.installationSku, const installation =
addonSkus: addonSkus.length > 0 ? addonSkus : undefined, installations.find(
inst =>
inst.sku === selections.installationSku || inst.id === selections.installationSku
) ?? null;
if (!installation) {
throw new Error(
`Installation option not found for reference: ${selections.installationSku}. Please reselect your installation method.`
);
}
items.push({ ...installation, quantity: 1, itemType: "installation" });
}
addonRefs.forEach(ref => {
const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null;
if (addon) {
items.push({ ...addon, quantity: 1, itemType: "addon" });
}
}); });
} else if (orderType === "SIM") { } else if (orderType === "SIM") {
const [plans, activationFees, addons] = await Promise.all([ const { plans, activationFees, addons } = await catalogService.getSimCatalog();
catalogService.getSimPlans(),
catalogService.getSimActivationFees(),
catalogService.getSimAddons(),
]);
const plan = plans.find(p => p.sku === selections.plan); const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
if (!plan) { if (!plan) {
throw new Error( throw new Error(
`SIM plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.` `SIM plan not found for reference: ${planRef}. Please go back and select a valid plan.`
); );
} }
const addonSkus: string[] = []; items.push({ ...plan, quantity: 1, itemType: "plan" });
if (selections.addonSku) addonSkus.push(selections.addonSku);
const urlParams = new URLSearchParams(window.location.search); addonRefs.forEach(ref => {
urlParams.getAll("addonSku").forEach(sku => { const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null;
if (sku && !addonSkus.includes(sku)) addonSkus.push(sku); if (addon) {
items.push({ ...addon, quantity: 1, itemType: "addon" });
}
}); });
items = buildSimOrderItems(plan, activationFees, addons, { const simType = selections.simType ?? "eSIM";
addonSkus: addonSkus.length > 0 ? addonSkus : undefined, const activation =
}); activationFees.find(fee => {
const feeSimType =
(fee as unknown as { simType?: string }).simType ||
((fee.catalogMetadata as { simType?: string } | undefined)?.simType ?? undefined);
return feeSimType
? feeSimType === simType
: fee.sku === selections.activationFeeSku || fee.id === selections.activationFeeSku;
}) ?? null;
if (activation) {
items.push({ ...activation, quantity: 1, itemType: "activation" });
}
} else if (orderType === "VPN") {
const { plans, activationFees } = await catalogService.getVpnCatalog();
const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
if (!plan) {
throw new Error(
`VPN plan not found for reference: ${planRef}. Please go back and select a valid plan.`
);
}
items.push({ ...plan, quantity: 1, itemType: "vpn" });
const activation =
activationFees.find(
fee => fee.sku === selections.activationSku || fee.id === selections.activationSku
) ?? null;
if (activation) {
items.push({ ...activation, quantity: 1, itemType: "activation" });
}
} else {
throw new Error("Unsupported order type. Please begin checkout from the catalog.");
} }
if (mounted) { if (!mounted) return;
const totals = calculateOrderTotals(items);
setCheckoutState( const totals = calculateTotals(items);
createSuccessState({ setCheckoutState(
items, createSuccessState({
totals, items,
configuration: {}, totals,
}) configuration: {},
); })
} );
} catch (error) { } catch (error) {
if (mounted) { if (mounted) {
setCheckoutState( setCheckoutState(
@ -164,10 +239,11 @@ export function useCheckout() {
} }
} }
})(); })();
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [orderType, selections]); }, [orderType, params, selections]);
const handleSubmitOrder = useCallback(async () => { const handleSubmitOrder = useCallback(async () => {
try { try {
@ -175,8 +251,14 @@ export function useCheckout() {
if (checkoutState.status !== "success") { if (checkoutState.status !== "success") {
throw new Error("Checkout data not loaded"); throw new Error("Checkout data not loaded");
} }
const skus = extractOrderSKUs(checkoutState.data.items); const uniqueSkus = Array.from(
if (!skus || skus.length === 0) { new Set(
checkoutState.data.items
.map(item => item.sku)
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
)
);
if (uniqueSkus.length === 0) {
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.");
} }
@ -207,7 +289,7 @@ export function useCheckout() {
const orderData = { const orderData = {
orderType, orderType,
skus, skus: uniqueSkus,
...(Object.keys(configurations).length > 0 && { configurations }), ...(Object.keys(configurations).length > 0 && { configurations }),
}; };

View File

@ -63,7 +63,7 @@ export function OrdersListContainer() {
useEffect(() => { useEffect(() => {
const fetchOrders = async () => { const fetchOrders = async () => {
try { try {
const list = await ordersService.getMyOrders<OrderSummary>(); const list = await ordersService.getMyOrders<OrderSummary[]>();
setOrders(list); setOrders(list);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to load orders"); setError(e instanceof Error ? e.message : "Failed to load orders");

View File

@ -30,7 +30,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
export function SimCancelContainer() { export function SimCancelContainer() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const subscriptionId = parseInt(params.id as string); const subscriptionId = params.id as string;
const [step, setStep] = useState<Step>(1); const [step, setStep] = useState<Step>(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -94,12 +94,17 @@ export function SimCancelContainer() {
const emailsMatch = !emailProvided || email.trim() === email2.trim(); const emailsMatch = !emailProvided || email.trim() === email2.trim();
const canProceedStep3 = const canProceedStep3 =
acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch; acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
const runDate = cancelMonth ? `${cancelMonth}01` : undefined; const runDate = cancelMonth ? `${cancelMonth}01` : null;
const submit = async () => { const submit = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setMessage(null); setMessage(null);
if (!runDate) {
setError("Please select a cancellation month before submitting.");
setLoading(false);
return;
}
try { try {
await simActionsService.cancel(subscriptionId, { scheduledAt: runDate }); await simActionsService.cancel(subscriptionId, { scheduledAt: runDate });
setMessage("Cancellation request submitted. You will receive a confirmation email."); setMessage("Cancellation request submitted. You will receive a confirmation email.");

View File

@ -7,6 +7,7 @@ export interface TopUpRequest {
export interface ChangePlanRequest { export interface ChangePlanRequest {
newPlanCode: string; newPlanCode: string;
assignGlobalIp: boolean; assignGlobalIp: boolean;
scheduledAt?: string;
} }
export interface CancelRequest { export interface CancelRequest {
@ -41,9 +42,12 @@ export const simActionsService = {
}, },
async getSimInfo<T, E = unknown>(subscriptionId: string): Promise<SimInfo<T, E> | null> { async getSimInfo<T, E = unknown>(subscriptionId: string): Promise<SimInfo<T, E> | null> {
const response = await apiClient.GET("/api/subscriptions/{subscriptionId}/sim/info", { const response = await apiClient.GET<SimInfo<T, E> | null>(
params: { path: { subscriptionId } }, "/api/subscriptions/{subscriptionId}/sim/info",
}); {
return getDataOrDefault(response, null); params: { path: { subscriptionId } },
}
);
return getDataOrDefault<SimInfo<T, E> | null>(response, null);
}, },
}; };

View File

@ -30,7 +30,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
export function SimCancelContainer() { export function SimCancelContainer() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const subscriptionId = parseInt(params.id as string); const subscriptionId = params.id as string;
const [step, setStep] = useState<Step>(1); const [step, setStep] = useState<Step>(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -92,12 +92,17 @@ export function SimCancelContainer() {
const emailsMatch = !emailProvided || email.trim() === email2.trim(); const emailsMatch = !emailProvided || email.trim() === email2.trim();
const canProceedStep3 = const canProceedStep3 =
acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch; acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
const runDate = cancelMonth ? `${cancelMonth}01` : undefined; const runDate = cancelMonth ? `${cancelMonth}01` : null;
const submit = async () => { const submit = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
setMessage(null); setMessage(null);
if (!runDate) {
setError("Please select a cancellation month before submitting.");
setLoading(false);
return;
}
try { try {
await simActionsService.cancel(subscriptionId, { scheduledAt: runDate }); await simActionsService.cancel(subscriptionId, { scheduledAt: runDate });
setMessage("Cancellation request submitted. You will receive a confirmation email."); setMessage("Cancellation request submitted. You will receive a confirmation email.");

View File

@ -20,7 +20,7 @@ const PLAN_LABELS: Record<PlanCode, string> = {
export function SimChangePlanContainer() { export function SimChangePlanContainer() {
const params = useParams(); const params = useParams();
const subscriptionId = parseInt(params.id as string); const subscriptionId = params.id as string;
const [currentPlanCode] = useState<string>(""); const [currentPlanCode] = useState<string>("");
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>(""); const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
const [assignGlobalIp, setAssignGlobalIp] = useState(false); const [assignGlobalIp, setAssignGlobalIp] = useState(false);

View File

@ -11,7 +11,7 @@ import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
export function SimTopUpContainer() { export function SimTopUpContainer() {
const params = useParams(); const params = useParams();
const subscriptionId = parseInt(params.id as string); const subscriptionId = params.id as string;
const [gbAmount, setGbAmount] = useState<string>("1"); const [gbAmount, setGbAmount] = useState<string>("1");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);

View File

@ -211,14 +211,14 @@ function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo
"message" in payload && "message" in payload &&
typeof (payload as { message?: unknown }).message === "string" typeof (payload as { message?: unknown }).message === "string"
) { ) {
const payloadWithMessage = payload as { code?: unknown; message: string };
const candidateCode = payloadWithMessage.code;
const code = const code =
typeof (payload as { code?: unknown }).code === "string" typeof candidateCode === "string" ? candidateCode : httpStatusCodeToLabel(status);
? (payload as { code: string }).code
: httpStatusCodeToLabel(status);
return { return {
code, code,
message: (payload as { message: string }).message, message: payloadWithMessage.message,
shouldLogout: shouldLogoutForError(code) || status === 401, shouldLogout: shouldLogoutForError(code) || status === 401,
shouldRetry: typeof status === "number" ? status >= 500 : shouldRetryForError(code), shouldRetry: typeof status === "number" ? status >= 500 : shouldRetryForError(code),
}; };