diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx index 6cac9082..04285018 100644 --- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx +++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx @@ -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, - firstName: prof.firstName || state.user.firstName, - lastName: prof.lastName || state.user.lastName, - phone: prof.phone || state.user.phone, + ...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 } diff --git a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx index 63056f5d..2eb34c95 100644 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx +++ b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx @@ -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, }; diff --git a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx index 8856d43a..f7926499 100644 --- a/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx +++ b/apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx @@ -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"; @@ -56,20 +56,13 @@ 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, - }; - await signup(request); + confirmPassword: _confirm, + ...formData + }: SignupFormValues) => { + clearError(); + try { + const request: SignupRequestInput = signupFormToRequest(formData); + await signup(request); onSuccess?.(); } catch (err) { const message = err instanceof Error ? err.message : "Signup failed"; diff --git a/apps/portal/src/features/auth/hooks/use-auth.ts b/apps/portal/src/features/auth/hooks/use-auth.ts index 027330a3..8a9f8aca 100644 --- a/apps/portal/src/features/auth/hooks/use-auth.ts +++ b/apps/portal/src/features/auth/hooks/use-auth.ts @@ -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); diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 045efc64..3064d22a 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -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; - signup: (data: SignupRequest) => Promise; + login: (credentials: LoginRequestInput) => Promise; + signup: (data: SignupRequestInput) => Promise; logout: () => Promise; requestPasswordReset: (email: string) => Promise; resetPassword: (token: string, password: string) => Promise; changePassword: (currentPassword: string, newPassword: string) => Promise; checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean }>; linkWhmcs: ( - request: LinkWhmcsRequestData + request: LinkWhmcsRequestInput ) => Promise<{ needsPasswordSet: boolean; email: string }>; setPassword: (email: string, password: string) => Promise; refreshUser: () => Promise; @@ -70,7 +70,7 @@ export const useAuthStore = create()( 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()( } }, - 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()( } }, - 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()( if (!tokens?.accessToken) return; try { - const response = await apiClient.GET("/me", { + const response = await apiClient.GET("/me", { ...withAuthHeaders(tokens.accessToken), }); - const profile = getNullableData(response); + const profile = getNullableData(response); if (!profile) { // Token might be expired, try to refresh await get().refreshTokens(); diff --git a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx index 78c01cc8..1abf7e1b 100644 --- a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx @@ -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 { - 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; diff --git a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx index 8577b105..c86a901e 100644 --- a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx @@ -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<{ diff --git a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx index 1fb4fe96..bafda7a0 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/InternetConfigureContainer.tsx @@ -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 ; } @@ -86,7 +91,7 @@ export function InternetConfigureContainer({ {/* Progress Steps */}
- +
{/* 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)} diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx b/apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx index 0264bf04..312ed846 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/steps/InstallationStep.tsx @@ -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({ + setSelectedInstallationSku(installation ? installation.sku : null) + } />
diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx b/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx index 15619218..d7c8d7b7 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx @@ -92,10 +92,10 @@ function SilverPlanConfiguration({ details="Traditional connection method with username/password authentication. Compatible with most routers and ISPs." /> diff --git a/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx b/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx index 2365a55c..643388a1 100644 --- a/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/vpn/VpnPlanCard.tsx @@ -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) { diff --git a/apps/portal/src/features/catalog/views/SimPlans.tsx b/apps/portal/src/features/catalog/views/SimPlans.tsx index a5ecf821..2d53f3ac 100644 --- a/apps/portal/src/features/catalog/views/SimPlans.tsx +++ b/apps/portal/src/features/catalog/views/SimPlans.tsx @@ -118,7 +118,7 @@ export function SimPlansContainer() { ); } - const plansByType: PlansByType = simPlans.reduce( + const plansByType = simPlans.reduce( (acc, plan) => { const planType = plan.simPlanType || "DataOnly"; if (planType === "DataOnly") acc.DataOnly.push(plan); diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index 4e160d51..b670a4a5 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -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; +} + export function useCheckout() { const params = useSearchParams(); const router = useRouter(); @@ -84,76 +86,149 @@ export function useCheckout() { useEffect(() => { let mounted = true; + + const collectAddonRefs = () => { + const refs = new Set(); + 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( + (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.` + ); + } + + 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) { - const totals = calculateOrderTotals(items); - setCheckoutState( - createSuccessState({ - items, - totals, - configuration: {}, - }) - ); - } + if (!mounted) return; + + const totals = calculateTotals(items); + setCheckoutState( + createSuccessState({ + items, + totals, + 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 }), }; diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx index dda9bb18..86635038 100644 --- a/apps/portal/src/features/orders/views/OrdersList.tsx +++ b/apps/portal/src/features/orders/views/OrdersList.tsx @@ -63,7 +63,7 @@ export function OrdersListContainer() { useEffect(() => { const fetchOrders = async () => { try { - const list = await ordersService.getMyOrders(); + const list = await ordersService.getMyOrders(); setOrders(list); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load orders"); diff --git a/apps/portal/src/features/subscriptions/containers/SimCancel.tsx b/apps/portal/src/features/subscriptions/containers/SimCancel.tsx index e94f94e8..0ba7a37b 100644 --- a/apps/portal/src/features/subscriptions/containers/SimCancel.tsx +++ b/apps/portal/src/features/subscriptions/containers/SimCancel.tsx @@ -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(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."); diff --git a/apps/portal/src/features/subscriptions/services/sim-actions.service.ts b/apps/portal/src/features/subscriptions/services/sim-actions.service.ts index 7bab0cdf..6cd7c1eb 100644 --- a/apps/portal/src/features/subscriptions/services/sim-actions.service.ts +++ b/apps/portal/src/features/subscriptions/services/sim-actions.service.ts @@ -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(subscriptionId: string): Promise | null> { - const response = await apiClient.GET("/api/subscriptions/{subscriptionId}/sim/info", { - params: { path: { subscriptionId } }, - }); - return getDataOrDefault(response, null); + const response = await apiClient.GET | null>( + "/api/subscriptions/{subscriptionId}/sim/info", + { + params: { path: { subscriptionId } }, + } + ); + return getDataOrDefault | null>(response, null); }, }; diff --git a/apps/portal/src/features/subscriptions/views/SimCancel.tsx b/apps/portal/src/features/subscriptions/views/SimCancel.tsx index faea2af2..64a925b3 100644 --- a/apps/portal/src/features/subscriptions/views/SimCancel.tsx +++ b/apps/portal/src/features/subscriptions/views/SimCancel.tsx @@ -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(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."); diff --git a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx index f3237515..e9a53e4b 100644 --- a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx +++ b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx @@ -20,7 +20,7 @@ const PLAN_LABELS: Record = { export function SimChangePlanContainer() { const params = useParams(); - const subscriptionId = parseInt(params.id as string); + const subscriptionId = params.id as string; const [currentPlanCode] = useState(""); const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>(""); const [assignGlobalIp, setAssignGlobalIp] = useState(false); diff --git a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx index 9c4d75b5..36a38190 100644 --- a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx +++ b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx @@ -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("1"); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); diff --git a/apps/portal/src/lib/utils/error-handling.ts b/apps/portal/src/lib/utils/error-handling.ts index 9101db1c..c7f628ce 100644 --- a/apps/portal/src/lib/utils/error-handling.ts +++ b/apps/portal/src/lib/utils/error-handling.ts @@ -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), };