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) {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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<{
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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."
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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.");
|
||||||
|
|||||||
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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.");
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user