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:
parent
ec69e3dcbb
commit
b6d0aa1eb0
@ -75,17 +75,19 @@ export function AppShell({ children }: AppShellProps) {
|
||||
if (!prof) {
|
||||
return;
|
||||
}
|
||||
useAuthStore.setState(state => ({
|
||||
...state,
|
||||
user: state.user
|
||||
useAuthStore.setState(state =>
|
||||
state.user
|
||||
? {
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
firstName: prof.firstName || state.user.firstName,
|
||||
lastName: prof.lastName || state.user.lastName,
|
||||
phone: prof.phone || state.user.phone,
|
||||
},
|
||||
}
|
||||
: prof,
|
||||
}));
|
||||
: state
|
||||
);
|
||||
} catch {
|
||||
// best-effort profile hydration; ignore errors
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import { useWhmcsLink } from "@/features/auth/hooks";
|
||||
import {
|
||||
linkWhmcsRequestSchema,
|
||||
type LinkWhmcsFormData,
|
||||
type LinkWhmcsRequestData,
|
||||
type LinkWhmcsRequestInput,
|
||||
} from "@customer-portal/domain";
|
||||
import { useZodForm } from "@customer-portal/validation";
|
||||
|
||||
@ -23,7 +23,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
|
||||
async (formData: LinkWhmcsFormData) => {
|
||||
clearError();
|
||||
try {
|
||||
const payload: LinkWhmcsRequestData = {
|
||||
const payload: LinkWhmcsRequestInput = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
@ -9,7 +9,7 @@ import { useState, useCallback, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { ErrorMessage } from "@/components/atoms";
|
||||
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 { z } from "zod";
|
||||
|
||||
@ -57,18 +57,11 @@ export function SignupForm({
|
||||
const handleSignup = useCallback(
|
||||
async ({
|
||||
confirmPassword: _confirm,
|
||||
acceptTerms,
|
||||
marketingConsent,
|
||||
...formData
|
||||
}: SignupFormValues) => {
|
||||
clearError();
|
||||
try {
|
||||
const baseRequest = signupFormToRequest(formData);
|
||||
const request: SignupRequest = {
|
||||
...baseRequest,
|
||||
acceptTerms,
|
||||
marketingConsent,
|
||||
};
|
||||
const request: SignupRequestInput = signupFormToRequest(formData);
|
||||
await signup(request);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
|
||||
@ -9,7 +9,7 @@ import { useCallback, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuthStore } from "../services/auth.store";
|
||||
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
|
||||
@ -21,7 +21,7 @@ export function useAuth() {
|
||||
|
||||
// Enhanced login with redirect handling
|
||||
const login = useCallback(
|
||||
async (credentials: LoginRequest) => {
|
||||
async (credentials: LoginRequestInput) => {
|
||||
await store.login(credentials);
|
||||
const redirectTo = getPostLoginRedirect(searchParams);
|
||||
router.push(redirectTo);
|
||||
@ -31,7 +31,7 @@ export function useAuth() {
|
||||
|
||||
// Enhanced signup with redirect handling
|
||||
const signup = useCallback(
|
||||
async (data: SignupRequest) => {
|
||||
async (data: SignupRequestInput) => {
|
||||
await store.signup(data);
|
||||
const redirectTo = getPostLoginRedirect(searchParams);
|
||||
router.push(redirectTo);
|
||||
|
||||
@ -10,10 +10,10 @@ import { getErrorInfo, handleAuthError } from "@/lib/utils/error-handling";
|
||||
import logger from "@customer-portal/logging";
|
||||
import type {
|
||||
AuthTokens,
|
||||
UserProfile,
|
||||
LinkWhmcsRequestData,
|
||||
LoginRequest,
|
||||
SignupRequest,
|
||||
AuthenticatedUser,
|
||||
LinkWhmcsRequestInput,
|
||||
LoginRequestInput,
|
||||
SignupRequestInput,
|
||||
} from "@customer-portal/domain";
|
||||
import { authResponseSchema } from "@customer-portal/domain/validation";
|
||||
|
||||
@ -28,7 +28,7 @@ const withAuthHeaders = (accessToken?: string) =>
|
||||
|
||||
interface AuthState {
|
||||
// State
|
||||
user: UserProfile | null;
|
||||
user: AuthenticatedUser | null;
|
||||
tokens: AuthTokens | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
@ -37,15 +37,15 @@ interface AuthState {
|
||||
hasCheckedAuth: boolean;
|
||||
|
||||
// Actions
|
||||
login: (credentials: LoginRequest) => Promise<void>;
|
||||
signup: (data: SignupRequest) => Promise<void>;
|
||||
login: (credentials: LoginRequestInput) => Promise<void>;
|
||||
signup: (data: SignupRequestInput) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
requestPasswordReset: (email: string) => Promise<void>;
|
||||
resetPassword: (token: string, password: string) => Promise<void>;
|
||||
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
||||
checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>;
|
||||
linkWhmcs: (
|
||||
request: LinkWhmcsRequestData
|
||||
request: LinkWhmcsRequestInput
|
||||
) => Promise<{ needsPasswordSet: boolean; email: string }>;
|
||||
setPassword: (email: string, password: string) => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
@ -70,7 +70,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
hasCheckedAuth: false,
|
||||
|
||||
// Actions
|
||||
login: async (credentials: LoginRequest) => {
|
||||
login: async (credentials: LoginRequestInput) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
// 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 });
|
||||
try {
|
||||
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 });
|
||||
try {
|
||||
const response = await apiClient.POST("/auth/link-whmcs", {
|
||||
@ -305,11 +305,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
if (!tokens?.accessToken) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.GET<UserProfile>("/me", {
|
||||
const response = await apiClient.GET<AuthenticatedUser>("/me", {
|
||||
...withAuthHeaders(tokens.accessToken),
|
||||
});
|
||||
|
||||
const profile = getNullableData<UserProfile>(response);
|
||||
const profile = getNullableData<AuthenticatedUser>(response);
|
||||
if (!profile) {
|
||||
// Token might be expired, try to refresh
|
||||
await get().refreshTokens();
|
||||
|
||||
@ -11,14 +11,16 @@ import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Use consolidated domain types
|
||||
import type { OrderItemRequest, OrderTotals as DomainOrderTotals } from "@customer-portal/domain";
|
||||
// Align with shared catalog contracts
|
||||
import type { CatalogProductBase } from "@customer-portal/domain";
|
||||
|
||||
// Enhanced OrderItem for UI - properly extends unified types instead of redefining everything
|
||||
export interface OrderItem extends Omit<OrderItemRequest, "id"> {
|
||||
id?: string; // Optional for UI purposes (OrderItemRequest.id is required)
|
||||
description?: string;
|
||||
}
|
||||
// Enhanced order item representation for UI summary
|
||||
export type OrderItem = CatalogProductBase & {
|
||||
id?: string;
|
||||
quantity?: number;
|
||||
autoAdded?: boolean;
|
||||
itemClass?: string;
|
||||
};
|
||||
|
||||
export interface OrderConfiguration {
|
||||
label: string;
|
||||
@ -26,8 +28,10 @@ export interface OrderConfiguration {
|
||||
important?: boolean;
|
||||
}
|
||||
|
||||
// Extend domain OrderTotals with UI-specific fields
|
||||
export interface OrderTotals extends DomainOrderTotals {
|
||||
// Totals summary for UI; base fields mirror API aggregates
|
||||
export interface OrderTotals {
|
||||
monthlyTotal: number;
|
||||
oneTimeTotal: number;
|
||||
annualTotal?: number;
|
||||
discountAmount?: number;
|
||||
taxAmount?: number;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
|
||||
|
||||
@ -11,8 +11,8 @@ interface OrderSummaryProps {
|
||||
};
|
||||
|
||||
// Selected items
|
||||
selectedAddons?: CatalogOrderItem[];
|
||||
activationFees?: CatalogOrderItem[];
|
||||
selectedAddons?: CatalogProductBase[];
|
||||
activationFees?: CatalogProductBase[];
|
||||
|
||||
// Configuration details
|
||||
configDetails?: Array<{
|
||||
|
||||
@ -14,7 +14,7 @@ import { InstallationStep } from "./steps/InstallationStep";
|
||||
import { AddonsStep } from "./steps/AddonsStep";
|
||||
import { ReviewOrderStep } from "./steps/ReviewOrderStep";
|
||||
import { useConfigureState } from "./hooks/useConfigureState";
|
||||
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
|
||||
import { getMonthlyPrice, getOneTimePrice } from "../../../utils/pricing";
|
||||
|
||||
interface Props {
|
||||
plan: InternetPlanCatalogItem | null;
|
||||
@ -29,7 +29,7 @@ const STEPS = [
|
||||
{ number: 2, title: "Installation", description: "Installation method" },
|
||||
{ number: 3, title: "Add-ons", description: "Optional services" },
|
||||
{ number: 4, title: "Review", description: "Order summary" },
|
||||
];
|
||||
] as const;
|
||||
|
||||
export function InternetConfigureContainer({
|
||||
plan,
|
||||
@ -56,6 +56,11 @@ export function InternetConfigureContainer({
|
||||
|
||||
const handleAddonSelection = (newSelectedSkus: string[]) => setSelectedAddonSkus(newSelectedSkus);
|
||||
|
||||
const progressSteps = STEPS.map(step => ({
|
||||
...step,
|
||||
completed: currentStep > step.number,
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return <ConfigureLoadingSkeleton />;
|
||||
}
|
||||
@ -86,7 +91,7 @@ export function InternetConfigureContainer({
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="mb-8">
|
||||
<ProgressSteps steps={STEPS} currentStep={currentStep} />
|
||||
<ProgressSteps steps={progressSteps} currentStep={currentStep} />
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
@ -106,7 +111,6 @@ export function InternetConfigureContainer({
|
||||
installations={installations}
|
||||
selectedInstallation={selectedInstallation}
|
||||
setSelectedInstallationSku={setSelectedInstallationSku}
|
||||
selectedInstallationType={selectedInstallationType}
|
||||
isTransitioning={isTransitioning}
|
||||
onBack={() => transitionToStep(1)}
|
||||
onNext={() => canProceedFromStep(2) && transitionToStep(3)}
|
||||
|
||||
@ -11,7 +11,6 @@ interface Props {
|
||||
installations: InternetInstallationCatalogItem[];
|
||||
selectedInstallation: InternetInstallationCatalogItem | null;
|
||||
setSelectedInstallationSku: (sku: string | null) => void;
|
||||
selectedInstallationType: string | null;
|
||||
isTransitioning: boolean;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
@ -21,7 +20,6 @@ export function InstallationStep({
|
||||
installations,
|
||||
selectedInstallation,
|
||||
setSelectedInstallationSku,
|
||||
selectedInstallationType,
|
||||
isTransitioning,
|
||||
onBack,
|
||||
onNext,
|
||||
@ -43,9 +41,10 @@ export function InstallationStep({
|
||||
|
||||
<InstallationOptions
|
||||
installations={installations}
|
||||
selectedInstallation={selectedInstallation}
|
||||
setSelectedInstallationSku={setSelectedInstallationSku}
|
||||
selectedInstallationType={selectedInstallationType}
|
||||
selectedInstallationSku={selectedInstallation?.sku ?? null}
|
||||
onInstallationSelect={installation =>
|
||||
setSelectedInstallationSku(installation ? installation.sku : null)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
|
||||
@ -92,10 +92,10 @@ function SilverPlanConfiguration({
|
||||
details="Traditional connection method with username/password authentication. Compatible with most routers and ISPs."
|
||||
/>
|
||||
<ModeSelectionCard
|
||||
mode="IPoE"
|
||||
mode="IPoE-BYOR"
|
||||
selectedMode={mode}
|
||||
onSelect={setMode}
|
||||
title="IPoE"
|
||||
title="IPoE (BYOR)"
|
||||
description="IP over Ethernet"
|
||||
details="Modern connection method with automatic configuration. Simplified setup with faster connection times."
|
||||
/>
|
||||
|
||||
@ -3,10 +3,10 @@
|
||||
import { AnimatedCard } from "@/components/molecules";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { CurrencyYenIcon } from "@heroicons/react/24/outline";
|
||||
import type { VpnPlan } from "@customer-portal/domain";
|
||||
import type { VpnCatalogProduct } from "@customer-portal/domain";
|
||||
|
||||
interface VpnPlanCardProps {
|
||||
plan: VpnPlan;
|
||||
plan: VpnCatalogProduct;
|
||||
}
|
||||
|
||||
export function VpnPlanCard({ plan }: VpnPlanCardProps) {
|
||||
|
||||
@ -118,7 +118,7 @@ export function SimPlansContainer() {
|
||||
);
|
||||
}
|
||||
|
||||
const plansByType: PlansByType = simPlans.reduce(
|
||||
const plansByType = simPlans.reduce<PlansByType>(
|
||||
(acc, plan) => {
|
||||
const planType = plan.simPlanType || "DataOnly";
|
||||
if (planType === "DataOnly") acc.DataOnly.push(plan);
|
||||
|
||||
@ -6,32 +6,34 @@ import { catalogService } from "@/features/catalog/services/catalog.service";
|
||||
import { ordersService } from "@/features/orders/services/orders.service";
|
||||
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||
import type {
|
||||
InternetPlanCatalogItem,
|
||||
InternetInstallationCatalogItem,
|
||||
InternetAddonCatalogItem,
|
||||
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 { getMonthlyPrice, getOneTimePrice } from "@/features/catalog/utils/pricing";
|
||||
import type { CatalogProductBase } from "@customer-portal/domain";
|
||||
import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain";
|
||||
import type { AsyncState } from "@customer-portal/domain";
|
||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
|
||||
// Use domain Address type
|
||||
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() {
|
||||
const params = useSearchParams();
|
||||
const router = useRouter();
|
||||
@ -84,68 +86,142 @@ export function useCheckout() {
|
||||
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
try {
|
||||
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.");
|
||||
}
|
||||
|
||||
let items: OrderItem[] = [];
|
||||
const addonRefs = collectAddonRefs();
|
||||
const items: CheckoutItem[] = [];
|
||||
|
||||
if (orderType === "Internet") {
|
||||
const [plans, addons, installations] = await Promise.all([
|
||||
catalogService.getInternetPlans(),
|
||||
catalogService.getInternetAddons(),
|
||||
catalogService.getInternetInstallations(),
|
||||
]);
|
||||
const { plans, addons, installations } = await catalogService.getInternetCatalog();
|
||||
|
||||
const plan = plans.find(p => p.sku === selections.plan);
|
||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
|
||||
if (!plan) {
|
||||
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[] = [];
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.getAll("addonSku").forEach(sku => {
|
||||
if (sku && !addonSkus.includes(sku)) addonSkus.push(sku);
|
||||
});
|
||||
items.push({ ...plan, quantity: 1, itemType: "plan" });
|
||||
|
||||
items = buildInternetOrderItems(plan, addons, installations, {
|
||||
installationSku: selections.installationSku,
|
||||
addonSkus: addonSkus.length > 0 ? addonSkus : undefined,
|
||||
if (selections.installationSku) {
|
||||
const installation =
|
||||
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") {
|
||||
const [plans, activationFees, addons] = await Promise.all([
|
||||
catalogService.getSimPlans(),
|
||||
catalogService.getSimActivationFees(),
|
||||
catalogService.getSimAddons(),
|
||||
]);
|
||||
const { plans, activationFees, addons } = await catalogService.getSimCatalog();
|
||||
|
||||
const plan = plans.find(p => p.sku === selections.plan);
|
||||
const plan = plans.find(p => p.sku === planRef || p.id === planRef) ?? null;
|
||||
if (!plan) {
|
||||
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[] = [];
|
||||
if (selections.addonSku) addonSkus.push(selections.addonSku);
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.getAll("addonSku").forEach(sku => {
|
||||
if (sku && !addonSkus.includes(sku)) addonSkus.push(sku);
|
||||
items.push({ ...plan, quantity: 1, itemType: "plan" });
|
||||
|
||||
addonRefs.forEach(ref => {
|
||||
const addon = addons.find(a => a.sku === ref || a.id === ref) ?? null;
|
||||
if (addon) {
|
||||
items.push({ ...addon, quantity: 1, itemType: "addon" });
|
||||
}
|
||||
});
|
||||
|
||||
items = buildSimOrderItems(plan, activationFees, addons, {
|
||||
addonSkus: addonSkus.length > 0 ? addonSkus : undefined,
|
||||
});
|
||||
const simType = selections.simType ?? "eSIM";
|
||||
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.`
|
||||
);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
const totals = calculateOrderTotals(items);
|
||||
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) return;
|
||||
|
||||
const totals = calculateTotals(items);
|
||||
setCheckoutState(
|
||||
createSuccessState({
|
||||
items,
|
||||
@ -153,7 +229,6 @@ export function useCheckout() {
|
||||
configuration: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
setCheckoutState(
|
||||
@ -164,10 +239,11 @@ export function useCheckout() {
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [orderType, selections]);
|
||||
}, [orderType, params, selections]);
|
||||
|
||||
const handleSubmitOrder = useCallback(async () => {
|
||||
try {
|
||||
@ -175,8 +251,14 @@ export function useCheckout() {
|
||||
if (checkoutState.status !== "success") {
|
||||
throw new Error("Checkout data not loaded");
|
||||
}
|
||||
const skus = extractOrderSKUs(checkoutState.data.items);
|
||||
if (!skus || skus.length === 0) {
|
||||
const uniqueSkus = Array.from(
|
||||
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.");
|
||||
}
|
||||
|
||||
@ -207,7 +289,7 @@ export function useCheckout() {
|
||||
|
||||
const orderData = {
|
||||
orderType,
|
||||
skus,
|
||||
skus: uniqueSkus,
|
||||
...(Object.keys(configurations).length > 0 && { configurations }),
|
||||
};
|
||||
|
||||
|
||||
@ -63,7 +63,7 @@ export function OrdersListContainer() {
|
||||
useEffect(() => {
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const list = await ordersService.getMyOrders<OrderSummary>();
|
||||
const list = await ordersService.getMyOrders<OrderSummary[]>();
|
||||
setOrders(list);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load orders");
|
||||
|
||||
@ -30,7 +30,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
export function SimCancelContainer() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const subscriptionId = parseInt(params.id as string);
|
||||
const subscriptionId = params.id as string;
|
||||
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -94,12 +94,17 @@ export function SimCancelContainer() {
|
||||
const emailsMatch = !emailProvided || email.trim() === email2.trim();
|
||||
const canProceedStep3 =
|
||||
acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
|
||||
const runDate = cancelMonth ? `${cancelMonth}01` : undefined;
|
||||
const runDate = cancelMonth ? `${cancelMonth}01` : null;
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
if (!runDate) {
|
||||
setError("Please select a cancellation month before submitting.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await simActionsService.cancel(subscriptionId, { scheduledAt: runDate });
|
||||
setMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
|
||||
@ -7,6 +7,7 @@ export interface TopUpRequest {
|
||||
export interface ChangePlanRequest {
|
||||
newPlanCode: string;
|
||||
assignGlobalIp: boolean;
|
||||
scheduledAt?: string;
|
||||
}
|
||||
|
||||
export interface CancelRequest {
|
||||
@ -41,9 +42,12 @@ export const simActionsService = {
|
||||
},
|
||||
|
||||
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>(
|
||||
"/api/subscriptions/{subscriptionId}/sim/info",
|
||||
{
|
||||
params: { path: { subscriptionId } },
|
||||
});
|
||||
return getDataOrDefault(response, null);
|
||||
}
|
||||
);
|
||||
return getDataOrDefault<SimInfo<T, E> | null>(response, null);
|
||||
},
|
||||
};
|
||||
|
||||
@ -30,7 +30,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
export function SimCancelContainer() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const subscriptionId = parseInt(params.id as string);
|
||||
const subscriptionId = params.id as string;
|
||||
|
||||
const [step, setStep] = useState<Step>(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -92,12 +92,17 @@ export function SimCancelContainer() {
|
||||
const emailsMatch = !emailProvided || email.trim() === email2.trim();
|
||||
const canProceedStep3 =
|
||||
acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch;
|
||||
const runDate = cancelMonth ? `${cancelMonth}01` : undefined;
|
||||
const runDate = cancelMonth ? `${cancelMonth}01` : null;
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
if (!runDate) {
|
||||
setError("Please select a cancellation month before submitting.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await simActionsService.cancel(subscriptionId, { scheduledAt: runDate });
|
||||
setMessage("Cancellation request submitted. You will receive a confirmation email.");
|
||||
|
||||
@ -20,7 +20,7 @@ const PLAN_LABELS: Record<PlanCode, string> = {
|
||||
|
||||
export function SimChangePlanContainer() {
|
||||
const params = useParams();
|
||||
const subscriptionId = parseInt(params.id as string);
|
||||
const subscriptionId = params.id as string;
|
||||
const [currentPlanCode] = useState<string>("");
|
||||
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
|
||||
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
|
||||
|
||||
@ -11,7 +11,7 @@ import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export function SimTopUpContainer() {
|
||||
const params = useParams();
|
||||
const subscriptionId = parseInt(params.id as string);
|
||||
const subscriptionId = params.id as string;
|
||||
const [gbAmount, setGbAmount] = useState<string>("1");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
@ -211,14 +211,14 @@ function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo
|
||||
"message" in payload &&
|
||||
typeof (payload as { message?: unknown }).message === "string"
|
||||
) {
|
||||
const payloadWithMessage = payload as { code?: unknown; message: string };
|
||||
const candidateCode = payloadWithMessage.code;
|
||||
const code =
|
||||
typeof (payload as { code?: unknown }).code === "string"
|
||||
? (payload as { code: string }).code
|
||||
: httpStatusCodeToLabel(status);
|
||||
typeof candidateCode === "string" ? candidateCode : httpStatusCodeToLabel(status);
|
||||
|
||||
return {
|
||||
code,
|
||||
message: (payload as { message: string }).message,
|
||||
message: payloadWithMessage.message,
|
||||
shouldLogout: shouldLogoutForError(code) || status === 401,
|
||||
shouldRetry: typeof status === "number" ? status >= 500 : shouldRetryForError(code),
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user