diff --git a/apps/portal/src/components/common/FormField/FormField.tsx b/apps/portal/src/components/common/FormField/FormField.tsx index c3d926ff..62ce1835 100644 --- a/apps/portal/src/components/common/FormField/FormField.tsx +++ b/apps/portal/src/components/common/FormField/FormField.tsx @@ -13,14 +13,27 @@ interface FormFieldProps error?: string; helperText?: string; required?: boolean; - labelProps?: Omit; + labelProps?: Omit; fieldId?: string; children?: React.ReactNode; + containerClassName?: string; + inputClassName?: string; } const FormField = forwardRef( ( - { label, error, helperText, required, labelProps, fieldId, className, children, ...inputProps }, + { + label, + error, + helperText, + required, + labelProps, + fieldId, + containerClassName, + inputClassName, + children, + ...inputProps + }, ref ) => { const generatedId = useId(); @@ -29,40 +42,47 @@ const FormField = forwardRef( const helperTextId = helperText ? `${id}-helper` : undefined; return ( -
+
{label && ( )} {children ? ( - isValidElement(children) ? ( - cloneElement( - children as React.ReactElement, - { + isValidElement(children) + ? cloneElement(children, { id, "aria-invalid": error ? "true" : undefined, "aria-describedby": cn(errorId, helperTextId) || undefined, - } as any - ) - ) : ( - children - ) + } as Record) + : children ) : ( { - const { children: _children, dangerouslySetInnerHTML: _dangerouslySetInnerHTML, ...rest } = - inputProps as Record; + const { className, ...rest } = inputProps; return rest; })()} /> diff --git a/apps/portal/src/components/guards/profile-completion-guard.tsx b/apps/portal/src/components/guards/profile-completion-guard.tsx deleted file mode 100644 index 292a6069..00000000 --- a/apps/portal/src/components/guards/profile-completion-guard.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useProfileCompletion } from "@/features/account/hooks"; -import { MapPinIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; - -interface ProfileCompletionGuardProps { - children: React.ReactNode; - requireComplete?: boolean; - showBanner?: boolean; -} - -export function ProfileCompletionGuard({ - children, - requireComplete = false, - showBanner = true, -}: ProfileCompletionGuardProps) { - const { isComplete, loading, redirectToCompletion } = useProfileCompletion(); - - useEffect(() => { - // If profile completion is required and profile is incomplete, redirect - if (!loading && requireComplete && !isComplete) { - redirectToCompletion(); - } - }, [loading, requireComplete, isComplete, redirectToCompletion]); - - // Show loading state - if (loading) { - return ( -
-
- Loading... -
- ); - } - - // If requiring complete profile and it's not complete, show loading (will redirect) - if (requireComplete && !isComplete) { - return ( -
-
- Redirecting to complete profile... -
- ); - } - - // Show banner if profile is incomplete and banner is enabled - if (!isComplete && showBanner) { - return ( -
-
-
-
- -
-
-

Complete Your Profile

-

- Some features may be limited until you complete your profile information. -

- -
-
-
- {children} -
- ); - } - - // Profile is complete or banner is disabled, show children - return <>{children}; -} diff --git a/apps/portal/src/components/layout/AppShell/AppShell.tsx b/apps/portal/src/components/layout/AppShell/AppShell.tsx index eede195b..3637d75c 100644 --- a/apps/portal/src/components/layout/AppShell/AppShell.tsx +++ b/apps/portal/src/components/layout/AppShell/AppShell.tsx @@ -21,7 +21,8 @@ export function AppShell({ children }: AppShellProps) { const { hydrated, hasCheckedAuth, loading } = useAuthStore(); const pathname = usePathname(); const router = useRouter(); - const { data: activeSubscriptions } = useActiveSubscriptions(); + const activeSubscriptionsQuery = useActiveSubscriptions(); + const activeSubscriptions = activeSubscriptionsQuery.data ?? []; // Initialize with a stable default to avoid hydration mismatch const [expandedItems, setExpandedItems] = useState([]); @@ -71,6 +72,9 @@ export function AppShell({ children }: AppShellProps) { void (async () => { try { const prof = await accountService.getProfile(); + if (!prof) { + return; + } useAuthStore.setState(state => ({ ...state, user: state.user @@ -80,7 +84,7 @@ export function AppShell({ children }: AppShellProps) { lastName: prof.lastName || state.user.lastName, phone: prof.phone || state.user.phone, } - : (prof as unknown as typeof state.user), + : prof, })); } catch { // best-effort profile hydration; ignore errors diff --git a/apps/portal/src/components/layout/dashboard-layout.tsx b/apps/portal/src/components/layout/dashboard-layout.tsx index dd4f3994..d2ff3704 100644 --- a/apps/portal/src/components/layout/dashboard-layout.tsx +++ b/apps/portal/src/components/layout/dashboard-layout.tsx @@ -86,7 +86,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const { user, isAuthenticated, checkAuth } = useAuthStore(); const pathname = usePathname(); const router = useRouter(); - const { data: activeSubscriptions } = useActiveSubscriptions(); + const activeSubscriptionsQuery = useActiveSubscriptions(); + const activeSubscriptions = activeSubscriptionsQuery.data ?? []; // Initialize expanded items from localStorage or defaults const [expandedItems, setExpandedItems] = useState(() => { diff --git a/apps/portal/src/components/ui/button.tsx b/apps/portal/src/components/ui/button.tsx index 3fd41cfb..d9b3a966 100644 --- a/apps/portal/src/components/ui/button.tsx +++ b/apps/portal/src/components/ui/button.tsx @@ -1,7 +1,8 @@ -import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from "react"; +import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react"; import { forwardRef } from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/shared/utils"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; const buttonVariants = cva( "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", @@ -28,39 +29,76 @@ const buttonVariants = cva( } ); +interface ButtonExtras { + leftIcon?: ReactNode; + rightIcon?: ReactNode; + loading?: boolean; + isLoading?: boolean; + loadingText?: ReactNode; +} + type ButtonAsAnchorProps = { as: "a"; href: string; } & AnchorHTMLAttributes & - VariantProps; + VariantProps & + ButtonExtras; type ButtonAsButtonProps = { as?: "button"; } & ButtonHTMLAttributes & - VariantProps; + VariantProps & + ButtonExtras; export type ButtonProps = ButtonAsAnchorProps | ButtonAsButtonProps; const Button = forwardRef((props, ref) => { + const { + leftIcon, + rightIcon, + loading: loadingProp, + isLoading, + loadingText, + children, + ...rest + } = props; + + const loading = loadingProp ?? isLoading ?? false; + if (props.as === "a") { - const { className, variant, size, as: _as, href, ...anchorProps } = props; + const { className, variant, size, as: _as, href, ...anchorProps } = rest as ButtonAsAnchorProps; return ( } + aria-busy={loading || undefined} {...anchorProps} - /> + > + + {loading ? : leftIcon} + {loading ? loadingText ?? children : children} + {!loading && rightIcon ? {rightIcon} : null} + + ); } - const { className, variant, size, as: _as, ...buttonProps } = props; + const { className, variant, size, as: _as, disabled, ...buttonProps } = rest as ButtonAsButtonProps; return ( ); }); Button.displayName = "Button"; diff --git a/apps/portal/src/components/ui/index.ts b/apps/portal/src/components/ui/index.ts index 56f0b519..399c2c9f 100644 --- a/apps/portal/src/components/ui/index.ts +++ b/apps/portal/src/components/ui/index.ts @@ -7,17 +7,17 @@ export { Button, buttonVariants } from "./button"; export type { ButtonProps } from "./button"; -export { Input, inputVariants } from "./input"; +export { Input } from "./input"; export type { InputProps } from "./input"; -export { Label, labelVariants } from "./label"; +export { Label } from "./label"; export type { LabelProps } from "./label"; export { ErrorMessage, errorMessageVariants } from "./error-message"; export type { ErrorMessageProps } from "./error-message"; // Status and Feedback Components -export { StatusPill, statusPillVariants } from "./status-pill"; +export { StatusPill } from "./status-pill"; export type { StatusPillProps } from "./status-pill"; export { Badge, badgeVariants } from "./badge"; diff --git a/apps/portal/src/components/ui/input.tsx b/apps/portal/src/components/ui/input.tsx index 75ff1b44..3a2ac525 100644 --- a/apps/portal/src/components/ui/input.tsx +++ b/apps/portal/src/components/ui/input.tsx @@ -1,22 +1,31 @@ -import type { InputHTMLAttributes } from "react"; +import type { InputHTMLAttributes, ReactNode } from "react"; import { forwardRef } from "react"; import { cn } from "@/shared/utils"; -export type InputProps = InputHTMLAttributes; +export interface InputProps extends InputHTMLAttributes { + error?: ReactNode; +} -const Input = forwardRef(({ className, type, ...props }, ref) => { - return ( - - ); -}); +const Input = forwardRef( + ({ className, type, error, ...props }, ref) => { + const isInvalid = Boolean(error); + + return ( + + ); + } +); Input.displayName = "Input"; export { Input }; diff --git a/apps/portal/src/components/ui/status-pill.tsx b/apps/portal/src/components/ui/status-pill.tsx index eadbb8c3..d931fbdb 100644 --- a/apps/portal/src/components/ui/status-pill.tsx +++ b/apps/portal/src/components/ui/status-pill.tsx @@ -1,18 +1,15 @@ -import type { HTMLAttributes } from "react"; +import { forwardRef, type HTMLAttributes, type ReactNode } from "react"; +import { cn } from "@/shared/utils"; -type Variant = "success" | "warning" | "info" | "neutral" | "error"; - -interface StatusPillProps extends HTMLAttributes { +export type StatusPillProps = HTMLAttributes & { label: string; - variant?: Variant; -} + variant?: "success" | "warning" | "info" | "neutral" | "error"; + size?: "sm" | "md" | "lg"; + icon?: ReactNode; +}; -export function StatusPill({ - label, - variant = "neutral", - className = "", - ...rest -}: StatusPillProps) { +export const StatusPill = forwardRef( + ({ label, variant = "neutral", size = "md", icon, className, ...rest }, ref) => { const tone = variant === "success" ? "bg-green-50 text-green-700 ring-green-600/20" @@ -24,12 +21,29 @@ export function StatusPill({ ? "bg-red-50 text-red-700 ring-red-600/20" : "bg-gray-50 text-gray-700 ring-gray-400/30"; - return ( - - {label} - - ); -} + const sizing = + size === "sm" + ? "px-2 py-0.5 text-xs" + : size === "lg" + ? "px-4 py-1.5 text-sm" + : "px-3 py-1 text-xs"; + + return ( + + {icon ? {icon} : null} + {label} + + ); + } +); + +StatusPill.displayName = "StatusPill"; diff --git a/apps/portal/src/components/ui/sub-card.tsx b/apps/portal/src/components/ui/sub-card.tsx index 71b62fd0..0675d8a7 100644 --- a/apps/portal/src/components/ui/sub-card.tsx +++ b/apps/portal/src/components/ui/sub-card.tsx @@ -1,30 +1,34 @@ -import type { ReactNode } from "react"; +import { forwardRef, type ReactNode } from "react"; -interface SubCardProps { +export interface SubCardProps { title?: string; icon?: ReactNode; right?: ReactNode; - header?: ReactNode; // Optional custom header content (overrides title/icon/right layout) - footer?: ReactNode; // Optional footer section separated by a subtle divider + header?: ReactNode; + footer?: ReactNode; children: ReactNode; className?: string; headerClassName?: string; bodyClassName?: string; } -export function SubCard({ - title, - icon, - right, - header, - footer, - children, - className = "", - headerClassName = "", - bodyClassName = "", -}: SubCardProps) { - return ( +export const SubCard = forwardRef( + ( + { + title, + icon, + right, + header, + footer, + children, + className = "", + headerClassName = "", + bodyClassName = "", + }, + ref + ) => (
{header ? ( @@ -47,5 +51,6 @@ export function SubCard({
) : null}
- ); -} + ) +); +SubCard.displayName = "SubCard"; diff --git a/apps/portal/src/core/api/client.ts b/apps/portal/src/core/api/client.ts index 0ebc92b0..2c249099 100644 --- a/apps/portal/src/core/api/client.ts +++ b/apps/portal/src/core/api/client.ts @@ -1,4 +1,4 @@ - +/** * Core API client configuration * Wraps the shared generated client to inject portal-specific behavior like auth headers. */ @@ -7,16 +7,16 @@ import { createClient as createOpenApiClient, type ApiClient as GeneratedApiClient, type AuthHeaderResolver, + resolveBaseUrl, } from "@customer-portal/api-client"; import { env } from "../config/env"; -const baseUrl = env.NEXT_PUBLIC_API_BASE; - let authHeaderResolver: AuthHeaderResolver | undefined; const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.(); -export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, { +export const apiClient: GeneratedApiClient = createOpenApiClient({ + baseUrl: resolveBaseUrl(env.NEXT_PUBLIC_API_BASE), getAuthHeader: resolveAuthHeader, }); @@ -24,4 +24,4 @@ export const configureApiClientAuth = (resolver?: AuthHeaderResolver) => { authHeaderResolver = resolver; }; -export type ApiClient = GeneratedApiClient; \ No newline at end of file +export type ApiClient = GeneratedApiClient; diff --git a/apps/portal/src/core/api/index.ts b/apps/portal/src/core/api/index.ts index 6289dcd7..f977cc93 100644 --- a/apps/portal/src/core/api/index.ts +++ b/apps/portal/src/core/api/index.ts @@ -3,3 +3,4 @@ export { queryKeys } from "./query-keys"; export type { ApiClient } from "./client"; export type { AuthHeaderResolver } from "@customer-portal/api-client"; +export type { InvoiceQueryParams } from "./types"; diff --git a/apps/portal/src/core/api/query-keys.ts b/apps/portal/src/core/api/query-keys.ts index 738be29d..8ac097bd 100644 --- a/apps/portal/src/core/api/query-keys.ts +++ b/apps/portal/src/core/api/query-keys.ts @@ -3,56 +3,75 @@ * Centralized query key factory for consistent caching */ +import type { InvoiceQueryParams } from "./types"; + +const createScope = (scope: TScope) => ({ + all: scope, + extend: (...parts: TParts) => + [...scope, ...parts] as const, +}); + +const authScope = createScope(["auth"] as const); +const dashboardScope = createScope(["dashboard"] as const); +const billingScope = createScope(["billing"] as const); +const subscriptionsScope = createScope(["subscriptions"] as const); +const catalogScope = createScope(["catalog"] as const); + +const auth = { + all: authScope.all, + profile: () => authScope.extend("profile"), +}; + +const dashboard = { + all: dashboardScope.all, + summary: () => dashboardScope.extend("summary"), + stats: () => dashboardScope.extend("stats"), + activity: (filters?: readonly string[]) => dashboardScope.extend("activity", filters ?? []), + nextInvoice: () => dashboardScope.extend("next-invoice"), +}; + +const billing = { + all: billingScope.all, + invoices: (params?: InvoiceQueryParams) => billingScope.extend("invoices", params ?? {}), + invoice: (id: string) => billingScope.extend("invoice", id), + paymentMethods: () => billingScope.extend("payment-methods"), + gateways: () => billingScope.extend("gateways"), +}; + +const subscriptions = { + all: subscriptionsScope.all, + list: (params?: { status?: string }) => subscriptionsScope.extend("list", params ?? {}), + active: () => subscriptionsScope.extend("active"), + detail: (id: string | number) => subscriptionsScope.extend("detail", String(id)), + invoices: (subscriptionId: number, params?: { page?: number; limit?: number }) => + subscriptionsScope.extend("invoices", subscriptionId, params ?? {}), + stats: () => subscriptionsScope.extend("stats"), +}; + +const catalog = { + all: catalogScope.all, + products: (type: string, params?: Record) => + catalogScope.extend("products", type, params ?? {}), + internet: { + all: catalogScope.extend("internet"), + combined: () => catalogScope.extend("internet", "combined"), + }, + sim: { + all: catalogScope.extend("sim"), + combined: () => catalogScope.extend("sim", "combined"), + }, + vpn: { + all: catalogScope.extend("vpn"), + combined: () => catalogScope.extend("vpn", "combined"), + }, +}; + export const queryKeys = { - // Auth queries - auth: { - all: ['auth'] as const, - profile: () => [...queryKeys.auth.all, 'profile'] as const, - }, - - // Dashboard queries - dashboard: { - all: ['dashboard'] as const, - summary: () => [...queryKeys.dashboard.all, 'summary'] as const, - }, - - // Billing queries - billing: { - all: ['billing'] as const, - invoices: (params?: Record) => - [...queryKeys.billing.all, 'invoices', params ?? {}] as const, - invoice: (id: string) => [...queryKeys.billing.all, 'invoice', id] as const, - paymentMethods: () => [...queryKeys.billing.all, 'paymentMethods'] as const, - gateways: () => [...queryKeys.billing.all, 'gateways'] as const, - }, - - // Subscription queries - subscriptions: { - all: ['subscriptions'] as const, - list: (params?: Record) => - [...queryKeys.subscriptions.all, 'list', params ?? {}] as const, - detail: (id: string) => [...queryKeys.subscriptions.all, 'detail', id] as const, - invoices: (subscriptionId: number, params?: Record) => - [...queryKeys.subscriptions.all, 'invoices', subscriptionId, params ?? {}] as const, - stats: () => [...queryKeys.subscriptions.all, 'stats'] as const, - }, - - // Catalog queries - catalog: { - all: ['catalog'] as const, - products: (type: string, params?: Record) => - [...queryKeys.catalog.all, 'products', type, params ?? {}] as const, - internet: { - all: [...queryKeys.catalog.all, 'internet'] as const, - combined: () => [...queryKeys.catalog.internet.all, 'combined'] as const, - }, - sim: { - all: [...queryKeys.catalog.all, 'sim'] as const, - combined: () => [...queryKeys.catalog.sim.all, 'combined'] as const, - }, - vpn: { - all: [...queryKeys.catalog.all, 'vpn'] as const, - combined: () => [...queryKeys.catalog.vpn.all, 'combined'] as const, - }, - }, + auth, + dashboard, + billing, + subscriptions, + catalog, } as const; + +export type QueryKeys = typeof queryKeys; diff --git a/apps/portal/src/core/api/response-helpers.ts b/apps/portal/src/core/api/response-helpers.ts new file mode 100644 index 00000000..7ffef935 --- /dev/null +++ b/apps/portal/src/core/api/response-helpers.ts @@ -0,0 +1,24 @@ +const coerceData = (container: { data?: unknown }): T | null | undefined => + container.data as T | null | undefined; + +export const getDataOrThrow = ( + container: { data?: unknown }, + message = "API response did not include data" +): T => { + const data = coerceData(container); + if (data === null || typeof data === "undefined") { + throw new Error(message); + } + + return data; +}; + +export const getDataOrDefault = (container: { data?: unknown }, fallback: T): T => { + const data = coerceData(container); + return typeof data === "undefined" || data === null ? fallback : data; +}; + +export const getNullableData = (container: { data?: unknown }): T | null => { + const data = coerceData(container); + return typeof data === "undefined" ? null : data; +}; diff --git a/apps/portal/src/core/api/types.ts b/apps/portal/src/core/api/types.ts new file mode 100644 index 00000000..fc45de92 --- /dev/null +++ b/apps/portal/src/core/api/types.ts @@ -0,0 +1,8 @@ +export interface InvoiceQueryParams { + page?: number; + limit?: number; + status?: string; + search?: string; + sort?: string; + [key: string]: unknown; +} diff --git a/apps/portal/src/core/config/env.ts b/apps/portal/src/core/config/env.ts index 5fc4855a..38aa6e30 100644 --- a/apps/portal/src/core/config/env.ts +++ b/apps/portal/src/core/config/env.ts @@ -1,7 +1,7 @@ import { z } from "zod"; const EnvSchema = z.object({ - NEXT_PUBLIC_API_BASE: z.string().url().default("http://localhost:4000/api"), + NEXT_PUBLIC_API_BASE: z.string().url().default("http://localhost:4000"), NEXT_PUBLIC_APP_NAME: z.string().default("Assist Solutions Portal"), NEXT_PUBLIC_APP_VERSION: z.string().default("0.1.0"), }); diff --git a/apps/portal/src/core/validation/index.ts b/apps/portal/src/core/validation/index.ts new file mode 100644 index 00000000..1262893d --- /dev/null +++ b/apps/portal/src/core/validation/index.ts @@ -0,0 +1,67 @@ +import { useMemo } from "react"; + +export type ValidationRule = (value: string) => string | null; + +const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export const validationRules = { + required: (message = "This field is required"): ValidationRule => value => + value && value.toString().trim().length > 0 ? null : message, + email: (message = "Enter a valid email address"): ValidationRule => value => + !value || emailRegex.test(value.toString()) ? null : message, + minLength: (length: number, message?: string): ValidationRule => value => + value && value.toString().length >= length + ? null + : message ?? `Must be at least ${length} characters`, + pattern: (pattern: RegExp, message = "Invalid value"): ValidationRule => value => + !value || pattern.test(value.toString()) ? null : message, +}; + +export interface FieldValidationResult { + isValid: boolean; + errors: string[]; +} + +export const validateField = ( + value: string, + rules: ValidationRule[] +): FieldValidationResult => { + const errors = rules + .map(rule => rule(value)) + .filter((error): error is string => Boolean(error)); + + return { + isValid: errors.length === 0, + errors, + }; +}; + +export const validateForm = ( + values: Record, + config: Record +) => { + const fieldErrors: Record = {}; + let isValid = true; + + for (const [field, fieldRules] of Object.entries(config)) { + const { isValid: fieldValid, errors } = validateField(values[field] ?? "", fieldRules); + if (!fieldValid) { + fieldErrors[field] = errors[0]; + isValid = false; + } + } + + return { isValid, errors: fieldErrors }; +}; + +export const useFormValidation = () => + useMemo( + () => ({ + validationRules, + validateField, + validateForm, + }), + [] + ); + +export type ValidationRules = typeof validationRules; diff --git a/apps/portal/src/features/account/hooks/index.ts b/apps/portal/src/features/account/hooks/index.ts index 1d7f23b5..c770b90b 100644 --- a/apps/portal/src/features/account/hooks/index.ts +++ b/apps/portal/src/features/account/hooks/index.ts @@ -1,4 +1,3 @@ -export { useProfileCompletion } from "./useProfileCompletion"; export { useProfileData } from "./useProfileData"; export { useProfileEdit } from "./useProfileEdit"; export { useAddressEdit } from "./useAddressEdit"; diff --git a/apps/portal/src/features/account/hooks/useProfileCompletion.ts b/apps/portal/src/features/account/hooks/useProfileCompletion.ts deleted file mode 100644 index e456a0f5..00000000 --- a/apps/portal/src/features/account/hooks/useProfileCompletion.ts +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { apiClient } from "@/core/api"; - -interface Address { - street: string | null; - streetLine2: string | null; - city: string | null; - state: string | null; - postalCode: string | null; - country: string | null; -} - -interface BillingInfo { - company: string | null; - email: string; - phone: string | null; - address: Address; - isComplete: boolean; -} - -interface ProfileCompletionStatus { - isComplete: boolean; - loading: boolean; - redirectToCompletion: () => void; -} - -export function useProfileCompletion(): ProfileCompletionStatus { - const [isComplete, setIsComplete] = useState(true); // Default to true to avoid flash - const [loading, setLoading] = useState(true); - const router = useRouter(); - - useEffect(() => { - const checkProfileCompletion = async () => { - try { - const response = await apiClient.GET("/me/billing"); - const billingInfo = response.data; - setIsComplete(billingInfo.isComplete); - } catch (error) { - console.error("Failed to check profile completion:", error); - // On error, assume incomplete to be safe - setIsComplete(false); - } finally { - setLoading(false); - } - }; - - void checkProfileCompletion(); - }, []); - - const redirectToCompletion = () => { - router.push("/account/profile?complete=true"); - }; - - return { - isComplete, - loading, - redirectToCompletion, - }; -} diff --git a/apps/portal/src/features/account/services/account.service.ts b/apps/portal/src/features/account/services/account.service.ts index c5ddbce4..9fb279b4 100644 --- a/apps/portal/src/features/account/services/account.service.ts +++ b/apps/portal/src/features/account/services/account.service.ts @@ -1,13 +1,7 @@ import { apiClient } from "@/core/api"; +import { getDataOrThrow, getNullableData } from "@/core/api/response-helpers"; import type { Address, AuthUser } from "@customer-portal/domain"; -const ensureData = (data: T | undefined): T => { - if (typeof data === "undefined") { - throw new Error("No data returned from server"); - } - return data; -}; - type ProfileUpdateInput = { firstName?: string; lastName?: string; @@ -16,22 +10,22 @@ type ProfileUpdateInput = { export const accountService = { async getProfile() { - const response = await apiClient.GET('/me'); - return ensureData(response.data); + const response = await apiClient.GET('/api/me'); + return getNullableData(response); }, async updateProfile(update: ProfileUpdateInput) { - const response = await apiClient.PATCH('/me', { body: update }); - return ensureData(response.data); + const response = await apiClient.PATCH('/api/me', { body: update }); + return getDataOrThrow(response, "Failed to update profile"); }, async getAddress() { - const response = await apiClient.GET('/me/address'); - return ensureData
(response.data); + const response = await apiClient.GET('/api/me/address'); + return getNullableData
(response); }, async updateAddress(address: Address) { - const response = await apiClient.PATCH('/me/address', { body: address }); - return ensureData
(response.data); + const response = await apiClient.PATCH('/api/me/address', { body: address }); + return getDataOrThrow
(response, "Failed to update address"); }, }; diff --git a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx index 9bca4275..37f1d673 100644 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx +++ b/apps/portal/src/features/auth/components/LinkWhmcsForm/LinkWhmcsForm.tsx @@ -103,10 +103,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr } try { - const result = await linkWhmcs({ - email: formData.email.trim(), - password: formData.password, - }); + const result = await linkWhmcs(formData.email.trim(), formData.password); onTransferred?.({ needsPasswordSet: result.needsPasswordSet, email: formData.email.trim() }); } catch (err) { diff --git a/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx b/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx index 0246caa5..cdf3afe3 100644 --- a/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx +++ b/apps/portal/src/features/auth/components/PasswordResetForm/PasswordResetForm.tsx @@ -10,7 +10,7 @@ import Link from "next/link"; import { Button, Input, ErrorMessage } from "@/components/ui"; import { FormField } from "@/components/common/FormField"; import { usePasswordReset } from "../../hooks/use-auth"; -import { useFormValidation, commonValidationSchemas, z } from "@customer-portal/domain"; +import { useFormValidation } from "@/core/validation"; // removed unused type imports interface PasswordResetFormProps { @@ -47,6 +47,7 @@ export function PasswordResetForm({ className = "", }: PasswordResetFormProps) { const { requestPasswordReset, resetPassword, loading, error, clearError } = usePasswordReset(); + const { validationRules, validateField } = useFormValidation(); const [requestData, setRequestData] = useState({ email: "", diff --git a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx index e8a25074..d12cf323 100644 --- a/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx +++ b/apps/portal/src/features/auth/components/SessionTimeoutWarning.tsx @@ -27,7 +27,7 @@ export function SessionTimeoutWarning({ const expiryTime = Date.parse(tokens.expiresAt); if (Number.isNaN(expiryTime)) { - logger.warn("Invalid expiresAt on auth tokens", { expiresAt: tokens.expiresAt }); + logger.warn({ expiresAt: tokens.expiresAt }, "Invalid expiresAt on auth tokens"); expiryRef.current = null; setShowWarning(false); setTimeLeft(0); @@ -133,4 +133,3 @@ export function SessionTimeoutWarning({ ); } - diff --git a/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx b/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx index d09780a8..1965982e 100644 --- a/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx +++ b/apps/portal/src/features/auth/components/SetPasswordForm/SetPasswordForm.tsx @@ -10,7 +10,7 @@ import Link from "next/link"; import { Button, Input, ErrorMessage } from "@/components/ui"; import { FormField } from "@/components/common/FormField"; import { useWhmcsLink } from "../../hooks/use-auth"; -import { useFormValidation, commonValidationSchemas, z } from "@customer-portal/domain"; +import { useFormValidation } from "@/core/validation"; interface SetPasswordFormProps { email?: string; @@ -41,6 +41,7 @@ export function SetPasswordForm({ className = "", }: SetPasswordFormProps) { const { setPassword, loading, error, clearError } = useWhmcsLink(); + const { validationRules, validateField } = useFormValidation(); const [formData, setFormData] = useState({ email: initialEmail, diff --git a/apps/portal/src/features/auth/index.ts b/apps/portal/src/features/auth/index.ts index 2720e9a5..5c7e097a 100644 --- a/apps/portal/src/features/auth/index.ts +++ b/apps/portal/src/features/auth/index.ts @@ -4,16 +4,3 @@ */ export { useAuthStore } from "./services/auth.store"; -export type { - SignupData, - LoginData, - LinkWhmcsData, - SetPasswordData, - RequestPasswordResetData, - ResetPasswordData, - ChangePasswordData, - CheckPasswordNeededData, - CheckPasswordNeededResponse, - AuthResponse, - LinkResponse, -} from "./api"; diff --git a/apps/portal/src/features/auth/services/auth.store.ts b/apps/portal/src/features/auth/services/auth.store.ts index 5f9c47d6..94c13dfa 100644 --- a/apps/portal/src/features/auth/services/auth.store.ts +++ b/apps/portal/src/features/auth/services/auth.store.ts @@ -6,6 +6,7 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import { apiClient, configureApiClientAuth } from "@/core/api"; +import { getDataOrDefault, getDataOrThrow } from "@/core/api/response-helpers"; import type { AuthTokens, SignupRequest, @@ -36,6 +37,21 @@ type RawAuthTokens = | (Omit & { expiresAt: string | number | Date }) | LegacyAuthTokens; +interface AuthPayload { + user: AuthUser; + tokens: RawAuthTokens; +} + +interface PasswordCheckPayload { + needsPasswordSet: boolean; + userExists: boolean; + email?: string; +} + +interface LinkWhmcsPayload { + needsPasswordSet: boolean; +} + const toIsoString = (value: string | number | Date | null | undefined) => { if (value instanceof Date) { @@ -169,18 +185,17 @@ export const useAuthStore = create()( login: async (credentials: LoginRequest) => { set({ loading: true, error: null }); try { - const response = await apiClient.POST('/auth/login', { body: credentials }); - if (response.data) { - const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens); - set({ - user: response.data.user, - tokens, - isAuthenticated: true, - loading: false, - error: null, - persistSession: Boolean(credentials.rememberMe), - }); - } + const response = await apiClient.POST('/api/auth/login', { body: credentials }); + const payload = getDataOrThrow(response, 'Failed to login'); + const tokens = normalizeAuthTokens(payload.tokens); + set({ + user: payload.user, + tokens, + isAuthenticated: true, + loading: false, + error: null, + persistSession: Boolean(credentials.rememberMe), + }); } catch (error) { const authError = error as AuthError; set({ @@ -198,17 +213,16 @@ export const useAuthStore = create()( signup: async (data: SignupRequest) => { set({ loading: true, error: null }); try { - const response = await apiClient.POST('/auth/signup', { body: data }); - if (response.data) { - const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens); - set({ - user: response.data.user, - tokens, - isAuthenticated: true, - loading: false, - error: null, - }); - } + const response = await apiClient.POST('/api/auth/signup', { body: data }); + const payload = getDataOrThrow(response, 'Failed to sign up'); + const tokens = normalizeAuthTokens(payload.tokens); + set({ + user: payload.user, + tokens, + isAuthenticated: true, + loading: false, + error: null, + }); } catch (error) { const authError = error as AuthError; set({ @@ -228,7 +242,7 @@ export const useAuthStore = create()( // Call logout API if we have tokens if (tokens?.accessToken) { try { - await apiClient.POST('/auth/logout'); + await apiClient.POST('/api/auth/logout'); } catch (error) { console.warn("Logout API call failed:", error); // Continue with local logout even if API call fails @@ -247,7 +261,7 @@ export const useAuthStore = create()( requestPasswordReset: async (data: ForgotPasswordRequest) => { set({ loading: true, error: null }); try { - await apiClient.POST('/auth/request-password-reset', { body: data }); + await apiClient.POST('/api/auth/request-password-reset', { body: data }); set({ loading: false }); } catch (error) { const authError = error as AuthError; @@ -259,11 +273,14 @@ export const useAuthStore = create()( resetPassword: async (data: ResetPasswordRequest) => { set({ loading: true, error: null }); try { - const response = await apiClient.POST('/auth/reset-password', { body: data }); - const { user, tokens } = response.data!; - const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens); + const response = await apiClient.POST('/api/auth/reset-password', { body: data }); + const payload = getDataOrThrow( + response, + 'Failed to reset password' + ); + const normalizedTokens = normalizeAuthTokens(payload.tokens); set({ - user, + user: payload.user, tokens: normalizedTokens, isAuthenticated: true, loading: false, @@ -290,11 +307,14 @@ export const useAuthStore = create()( set({ loading: true, error: null }); try { - const response = await apiClient.POST('/auth/change-password', { body: data }); - const { user, tokens: newTokens } = response.data!; - const normalizedTokens = normalizeAuthTokens(newTokens as RawAuthTokens); + const response = await apiClient.POST('/api/auth/change-password', { body: data }); + const payload = getDataOrThrow( + response, + 'Failed to change password' + ); + const normalizedTokens = normalizeAuthTokens(payload.tokens); set({ - user, + user: payload.user, tokens: normalizedTokens, loading: false, error: null, @@ -309,8 +329,11 @@ export const useAuthStore = create()( checkPasswordNeeded: async (email: string) => { set({ loading: true, error: null }); try { - const response = await apiClient.POST('/auth/check-password-needed', { body: { email } }); - const result = response.data!; + const response = await apiClient.POST('/api/auth/check-password-needed', { body: { email } }); + const result = getDataOrThrow( + response, + 'Failed to check password requirements' + ); set({ loading: false }); return result; } catch (error) { @@ -323,8 +346,11 @@ export const useAuthStore = create()( linkWhmcs: async (email: string, password: string) => { set({ loading: true, error: null }); try { - const response = await apiClient.POST('/auth/link-whmcs', { body: { email, password } }); - const result = response.data!; + const response = await apiClient.POST('/api/auth/link-whmcs', { body: { email, password } }); + const result = getDataOrThrow( + response, + 'Failed to link WHMCS account' + ); set({ loading: false }); return result; } catch (error) { @@ -337,11 +363,11 @@ export const useAuthStore = create()( setPassword: async (email: string, password: string) => { set({ loading: true, error: null }); try { - const response = await apiClient.POST('/auth/set-password', { body: { email, password } }); - const { user, tokens } = response.data!; - const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens); + const response = await apiClient.POST('/api/auth/set-password', { body: { email, password } }); + const payload = getDataOrThrow(response, 'Failed to set password'); + const normalizedTokens = normalizeAuthTokens(payload.tokens); set({ - user, + user: payload.user, tokens: normalizedTokens, isAuthenticated: true, loading: false, @@ -377,8 +403,8 @@ export const useAuthStore = create()( set({ loading: true }); try { - const response = await apiClient.GET('/me'); - const user = response.data ?? null; + const response = await apiClient.GET('/api/me'); + const user = getDataOrDefault(response, null); if (user) { set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); } else { diff --git a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx index 99371631..31d9cf5d 100644 --- a/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx +++ b/apps/portal/src/features/billing/components/BillingSummary/BillingSummary.tsx @@ -44,13 +44,11 @@ const BillingSummary = forwardRef( ); } - const formatAmount = (amount: number) => { - return formatCurrency(amount, { + const formatAmount = (amount: number) => + formatCurrency(amount, { currency: summary.currency, - currencySymbol: summary.currencySymbol, locale: getCurrencyLocale(summary.currency), }); - }; const summaryItems = [ { diff --git a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx index c0164786..3c89c972 100644 --- a/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx +++ b/apps/portal/src/features/billing/components/InvoiceTable/InvoiceTable.tsx @@ -89,7 +89,6 @@ export function InvoiceTable({ {formatCurrency(invoice.total, { currency: invoice.currency, - currencySymbol: invoice.currencySymbol, locale: getCurrencyLocale(invoice.currency), })} diff --git a/apps/portal/src/features/billing/components/PaymentMethodCard.tsx b/apps/portal/src/features/billing/components/PaymentMethodCard.tsx new file mode 100644 index 00000000..65df310c --- /dev/null +++ b/apps/portal/src/features/billing/components/PaymentMethodCard.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { CreditCardIcon, BanknotesIcon, DevicePhoneMobileIcon } from "@heroicons/react/24/outline"; +import type { PaymentMethod } from "@customer-portal/domain"; +import { StatusPill } from "@/components/ui/status-pill"; +import { cn } from "@/shared/utils"; +import type { ReactNode } from "react"; + +interface PaymentMethodCardProps { + paymentMethod: PaymentMethod; + className?: string; + showActions?: boolean; + actionSlot?: ReactNode; +} + +const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => { + if (type === "BankAccount" || type === "RemoteBankAccount") { + return ; + } + if (brand?.toLowerCase().includes("mobile")) { + return ; + } + return ; +}; + +const formatDescription = (method: PaymentMethod) => { + if (method.cardBrand && method.lastFour) { + return `${method.cardBrand.toUpperCase()} •••• ${method.lastFour}`; + } + if (method.type === "BankAccount" && method.lastFour) { + return `Bank Account •••• ${method.lastFour}`; + } + return method.description; +}; + +const formatExpiry = (expiryDate?: string) => { + if (!expiryDate) return null; + return `Expires ${expiryDate}`; +}; + +export function PaymentMethodCard({ + paymentMethod, + className, + showActions = false, + actionSlot, +}: PaymentMethodCardProps) { + const description = formatDescription(paymentMethod); + const expiry = formatExpiry(paymentMethod.expiryDate); + const icon = getMethodIcon(paymentMethod.type, paymentMethod.cardBrand ?? paymentMethod.ccType); + + return ( +
+
+
{icon}
+
+
+

{description}

+ {paymentMethod.isDefault ? ( + + ) : null} +
+
+ {paymentMethod.gatewayDisplayName || paymentMethod.gatewayName || paymentMethod.type} +
+ {expiry ?
{expiry}
: null} +
+
+ + {showActions && actionSlot ?
{actionSlot}
: null} +
+ ); +} + +export type { PaymentMethodCardProps }; diff --git a/apps/portal/src/features/billing/components/index.ts b/apps/portal/src/features/billing/components/index.ts index 6002e64e..6c171e15 100644 --- a/apps/portal/src/features/billing/components/index.ts +++ b/apps/portal/src/features/billing/components/index.ts @@ -1,2 +1,3 @@ export * from "./InvoiceStatusBadge"; export * from "./InvoiceItemRow"; +export * from "./PaymentMethodCard"; diff --git a/apps/portal/src/features/billing/hooks/useBilling.ts b/apps/portal/src/features/billing/hooks/useBilling.ts index bbc14840..28519f1e 100644 --- a/apps/portal/src/features/billing/hooks/useBilling.ts +++ b/apps/portal/src/features/billing/hooks/useBilling.ts @@ -9,13 +9,12 @@ import { type UseQueryResult, } from "@tanstack/react-query"; import { apiClient, queryKeys } from "@/core/api"; +import { getDataOrDefault, getDataOrThrow } from "@/core/api/response-helpers"; +import type { InvoiceQueryParams } from "@/core/api/types"; import type { Invoice, InvoiceList, - InvoiceQueryParams, InvoiceSsoLink, - InvoicePaymentLink, - PaymentGatewayList, PaymentMethodList, } from "@customer-portal/domain"; @@ -52,15 +51,6 @@ type PaymentMethodsQueryOptions = Omit< "queryKey" | "queryFn" >; -type PaymentLinkMutationOptions = UseMutationOptions< - InvoicePaymentLink, - Error, - { - invoiceId: number; - paymentMethodId?: number; - gatewayName?: string; - } ->; type SsoLinkMutationOptions = UseMutationOptions< InvoiceSsoLink, @@ -69,26 +59,18 @@ type SsoLinkMutationOptions = UseMutationOptions< >; async function fetchInvoices(params?: InvoiceQueryParams): Promise { - const response = await apiClient.GET("/invoices", params ? { params: { query: params } } : undefined); - return response.data ?? emptyInvoiceList; + const response = await apiClient.GET("/api/invoices", params ? { params: { query: params } } : undefined); + return getDataOrDefault(response, emptyInvoiceList); } async function fetchInvoice(id: string): Promise { - const response = await apiClient.GET("/invoices/{id}", { params: { path: { id } } }); - if (!response.data) { - throw new Error("Invoice not found"); - } - return response.data; + const response = await apiClient.GET("/api/invoices/{id}", { params: { path: { id } } }); + return getDataOrThrow(response, "Invoice not found"); } async function fetchPaymentMethods(): Promise { - const response = await apiClient.GET("/invoices/payment-methods"); - return response.data ?? emptyPaymentMethods; -} - -async function fetchPaymentGateways(): Promise { - const response = await apiClient.GET("/invoices/payment-gateways"); - return response.data ?? { gateways: [], totalCount: 0 }; + const response = await apiClient.GET("/api/invoices/payment-methods"); + return getDataOrDefault(response, emptyPaymentMethods); } export function useInvoices( @@ -124,54 +106,6 @@ export function usePaymentMethods( }); } -export function usePaymentGateways( - options?: Omit< - UseQueryOptions< - PaymentGatewayList, - Error, - PaymentGatewayList, - ReturnType - >, - "queryKey" | "queryFn" - > -): UseQueryResult { - return useQuery({ - queryKey: queryKeys.billing.gateways(), - queryFn: fetchPaymentGateways, - ...options, - }); -} - -export function useCreateInvoicePaymentLink( - options?: PaymentLinkMutationOptions -): UseMutationResult< - InvoicePaymentLink, - Error, - { - invoiceId: number; - paymentMethodId?: number; - gatewayName?: string; - } -> { - return useMutation({ - mutationFn: async ({ invoiceId, paymentMethodId, gatewayName }) => { - const response = await apiClient.POST("/invoices/{id}/payment-link", { - params: { - path: { id: invoiceId }, - query: { - paymentMethodId, - gatewayName, - }, - }, - }); - if (!response.data) { - throw new Error("Failed to create payment link"); - } - return response.data; - }, - ...options, - }); -} export function useCreateInvoiceSsoLink( options?: SsoLinkMutationOptions @@ -182,16 +116,13 @@ export function useCreateInvoiceSsoLink( > { return useMutation({ mutationFn: async ({ invoiceId, target }) => { - const response = await apiClient.POST("/invoices/{id}/sso-link", { + const response = await apiClient.POST("/api/invoices/{id}/sso-link", { params: { path: { id: invoiceId }, query: target ? { target } : undefined, }, }); - if (!response.data) { - throw new Error("Failed to create SSO link"); - } - return response.data; + return getDataOrThrow(response, "Failed to create SSO link"); }, ...options, }); diff --git a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts index 1e80c656..c966d936 100644 --- a/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts +++ b/apps/portal/src/features/billing/hooks/usePaymentRefresh.ts @@ -29,7 +29,7 @@ export function usePaymentRefresh({ setToast({ visible: true, text: "Refreshing payment methods...", tone: "info" }); try { try { - await apiClient.POST("/invoices/payment-methods/refresh"); + await apiClient.POST("/api/invoices/payment-methods/refresh"); } catch (err) { // Soft-fail cache refresh, still attempt refetch console.warn("Payment methods cache refresh failed:", err); diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx index c291d8f3..2c45fe64 100644 --- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx +++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx @@ -11,6 +11,7 @@ import { PageLayout } from "@/components/layout/PageLayout"; import { CreditCardIcon } from "@heroicons/react/24/outline"; import { logger } from "@customer-portal/logging"; import { apiClient } from "@/core/api"; +import { getDataOrThrow } from "@/core/api/response-helpers"; import { openSsoLink } from "@/features/billing/utils/sso"; import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks"; import { @@ -27,9 +28,11 @@ export function InvoiceDetailContainer() { const [loadingPayment, setLoadingPayment] = useState(false); const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false); - const invoiceId = parseInt(params.id as string); + const rawInvoiceParam = params.id; + const invoiceIdParam = Array.isArray(rawInvoiceParam) ? rawInvoiceParam[0] : rawInvoiceParam; + const invoiceId = Number.parseInt(invoiceIdParam ?? "", 10); const createSsoLinkMutation = useCreateInvoiceSsoLink(); - const { data: invoice, isLoading, error } = useInvoice(invoiceId); + const { data: invoice, isLoading, error } = useInvoice(invoiceIdParam ?? ""); const handleCreateSsoLink = (target: "view" | "download" | "pay" = "view") => { void (async () => { @@ -53,10 +56,13 @@ export function InvoiceDetailContainer() { void (async () => { setLoadingPaymentMethods(true); try { - const response = await apiClient.POST('/auth/sso-link', { - body: { path: "index.php?rp=/account/paymentmethods" } + const response = await apiClient.POST('/api/auth/sso-link', { + body: { path: "index.php?rp=/account/paymentmethods" }, }); - const sso = response.data!; + const sso = getDataOrThrow<{ url: string }>( + response, + 'Failed to create payment methods SSO link' + ); openSsoLink(sso.url, { newTab: true }); } catch (err) { logger.error(err, "Failed to create payment methods SSO link"); @@ -180,5 +186,3 @@ export function InvoiceDetailContainer() { } export default InvoiceDetailContainer; - - diff --git a/apps/portal/src/features/billing/views/PaymentMethods.tsx b/apps/portal/src/features/billing/views/PaymentMethods.tsx index 8d14908b..824d4375 100644 --- a/apps/portal/src/features/billing/views/PaymentMethods.tsx +++ b/apps/portal/src/features/billing/views/PaymentMethods.tsx @@ -6,8 +6,9 @@ import { ErrorBoundary } from "@/components/common"; import { SubCard } from "@/components/ui/sub-card"; import { useSession } from "@/features/auth/hooks"; import { useAuthStore } from "@/features/auth/services/auth.store"; -// ApiClientError import removed - using generic error handling + import { apiClient } from "@/core/api"; +import { getDataOrThrow } from "@/core/api/response-helpers"; import { openSsoLink } from "@/features/billing/utils/sso"; import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; import { PaymentMethodCard, usePaymentMethods } from "@/features/billing"; @@ -46,25 +47,32 @@ export function PaymentMethodsContainer() { }); const openPaymentMethods = async () => { - try { - setIsLoading(true); - setError(null); - if (!isAuthenticated) { - setError("Please log in to access payment methods."); - setIsLoading(false); - return; - } - const response = await apiClient.POST('/auth/sso-link', { - body: { path: "index.php?rp=/account/paymentmethods" } - }); - const sso = response.data!; - openSsoLink(sso.url, { newTab: true }); + setIsLoading(true); + setError(null); + + if (!isAuthenticated) { + setError("Please log in to access payment methods."); setIsLoading(false); - } catch (error) { - logger.error(error, "Failed to open payment methods"); - if (error && typeof error === 'object' && 'status' in error && (error as any).status === 401) + return; + } + + try { + const response = await apiClient.POST('/api/auth/sso-link', { + body: { path: "index.php?rp=/account/paymentmethods" }, + }); + const sso = getDataOrThrow<{ url: string }>( + response, + 'Failed to open payment methods portal' + ); + openSsoLink(sso.url, { newTab: true }); + } catch (err) { + logger.error(err, "Failed to open payment methods"); + if (err && typeof err === 'object' && 'status' in err && (err as any).status === 401) { setError("Authentication failed. Please log in again."); - else setError("Unable to access payment methods. Please try again later."); + } else { + setError("Unable to access payment methods. Please try again later."); + } + } finally { setIsLoading(false); } }; @@ -73,16 +81,22 @@ export function PaymentMethodsContainer() { // Placeholder hook for future logic when returning from WHMCS }, [isAuthenticated]); - if (error || paymentMethodsError) { - const errorObj = - error || (paymentMethodsError instanceof Error ? paymentMethodsError : new Error("Unexpected error")); + const combinedError = error + ? new Error(error) + : paymentMethodsError instanceof Error + ? paymentMethodsError + : paymentMethodsError + ? new Error(String(paymentMethodsError)) + : null; + + if (combinedError) { return ( } title="Payment Methods" description="Manage your saved payment methods and billing information" > - + <> @@ -97,9 +111,9 @@ export function PaymentMethodsContainer() { > {/* Simplified: remove verbose banner; controls exist via buttons */}
@@ -147,7 +161,6 @@ export function PaymentMethodsContainer() { key={paymentMethod.id} paymentMethod={paymentMethod} className="focus:outline-none focus:ring-2 focus:ring-blue-500" - showActions={false} /> ))}
@@ -207,4 +220,3 @@ export function PaymentMethodsContainer() { } export default PaymentMethodsContainer; - diff --git a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx index daabcd46..1769a38d 100644 --- a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx @@ -24,7 +24,7 @@ interface BillingInfo { company: string | null; email: string; phone: string | null; - address: Address; + address: Address | null; isComplete: boolean; } @@ -54,32 +54,33 @@ export function AddressConfirmation({ const fetchBillingInfo = useCallback(async () => { try { setLoading(true); - const data = (await accountService.getAddress()) || null; + const data = await accountService.getAddress(); + const isComplete = !!( + data && + data.street && + data.city && + data.state && + data.postalCode && + data.country + ); + setBillingInfo({ company: null, email: "", phone: null, address: data, - isComplete: !!( - data && - data.street && - data.city && - data.state && - data.postalCode && - data.country - ), + isComplete, }); - // Since address is required at signup, it should always be complete - // But we still need verification for Internet orders if (requiresAddressVerification) { - // For Internet orders, don't auto-confirm - require explicit verification setAddressConfirmed(false); - onAddressIncomplete(); // Keep disabled until explicitly confirmed - } else { - // For other order types, auto-confirm since address exists from signup - if (data) onAddressConfirmed(data); + onAddressIncomplete(); + } else if (isComplete && data) { + onAddressConfirmed(data); setAddressConfirmed(true); + } else { + onAddressIncomplete(); + setAddressConfirmed(false); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to load address"); @@ -99,7 +100,7 @@ export function AddressConfirmation({ setEditing(true); setEditedAddress( - billingInfo?.address || { + billingInfo?.address ?? { street: "", streetLine2: "", city: "", @@ -134,17 +135,17 @@ export function AddressConfirmation({ try { setError(null); - // Build minimal PATCH payload with only provided fields - const payload: Record = {}; - if (editedAddress.street) payload.street = editedAddress.street; - if (editedAddress.streetLine2) payload.streetLine2 = editedAddress.streetLine2; - if (editedAddress.city) payload.city = editedAddress.city; - if (editedAddress.state) payload.state = editedAddress.state; - if (editedAddress.postalCode) payload.postalCode = editedAddress.postalCode; - if (editedAddress.country) payload.country = editedAddress.country; + const sanitizedAddress: Address = { + street: editedAddress.street?.trim() || null, + streetLine2: editedAddress.streetLine2?.trim() || null, + city: editedAddress.city?.trim() || null, + state: editedAddress.state?.trim() || null, + postalCode: editedAddress.postalCode?.trim() || null, + country: editedAddress.country?.trim() || null, + }; // Persist to server (WHMCS via BFF) - const updatedAddress = await accountService.updateAddress(payload); + const updatedAddress = await accountService.updateAddress(sanitizedAddress); // Rebuild BillingInfo from updated address const updatedInfo: BillingInfo = { @@ -234,6 +235,8 @@ export function AddressConfirmation({ if (!billingInfo) return null; + const address = billingInfo.address; + return wrap( <>
@@ -379,18 +382,18 @@ export function AddressConfirmation({
) : (
- {billingInfo.address.street ? ( + {address?.street ? (
-

{billingInfo.address.street}

- {billingInfo.address.streetLine2 && ( -

{billingInfo.address.streetLine2}

- )} +

{address.street}

+ {address.streetLine2 ? ( +

{address.streetLine2}

+ ) : null}

- {billingInfo.address.city}, {billingInfo.address.state}{" "} - {billingInfo.address.postalCode} + {[address.city, address.state].filter(Boolean).join(", ")} + {address.postalCode ? ` ${address.postalCode}` : ""}

-

{billingInfo.address.country}

+

{address.country}

{/* Status message for Internet orders when pending */} diff --git a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx index de0fc4d3..eaab284f 100644 --- a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx @@ -9,6 +9,7 @@ import { } from "@heroicons/react/24/outline"; import { AnimatedCard } from "@/components/ui"; import { Button } from "@/components/ui/button"; +import { useRouter } from "next/navigation"; // Use consolidated domain types import type { @@ -105,6 +106,7 @@ export function EnhancedOrderSummary({ headerContent, footerContent, }: EnhancedOrderSummaryProps) { + const router = useRouter(); const sizeClasses = { compact: "p-4", standard: "p-6", @@ -335,11 +337,13 @@ export function EnhancedOrderSummary({
{backUrl ? ( - )} + ) : null}
) : ( -
- {/* Existing payment methods */} -
- {existingMethods.map(method => ( - - ))} -
- - {/* Add new payment method option */} - {allowNewMethod && onAddNewMethod && ( -
- + Add Another Method +
- )} + ) : null}
)} - {/* Custom content */} - {children &&
{children}
} + {children} - {/* Validation errors */} - {errors.length > 0 && ( - + {errors.length > 0 ? ( +
    - {errors.map((error, index) => ( -
  • {error}
  • + {errors.map((msg, index) => ( +
  • {msg}
  • ))}
+ ) : ( + selectedMethod && ( +
+ + Payment method selected +
+ ) )} - {/* Footer content */} - {footerContent &&
{footerContent}
} + {footerContent ? ( +
{footerContent}
+ ) : null}
); } diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index 863d3ac7..c16d41d0 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -1,44 +1,45 @@ import { apiClient } from "@/core/api"; +import { getDataOrDefault } from "@/core/api/response-helpers"; import type { InternetProduct, SimProduct, VpnProduct } from "@customer-portal/domain"; async function getInternetPlans(): Promise { - const response = await apiClient.GET("/catalog/internet/plans"); - return response.data ?? []; + const response = await apiClient.GET("/api/catalog/internet/plans"); + return getDataOrDefault(response, [] as InternetProduct[]); } async function getInternetInstallations(): Promise { - const response = await apiClient.GET("/catalog/internet/installations"); - return response.data ?? []; + const response = await apiClient.GET("/api/catalog/internet/installations"); + return getDataOrDefault(response, [] as InternetProduct[]); } async function getInternetAddons(): Promise { - const response = await apiClient.GET("/catalog/internet/addons"); - return response.data ?? []; + const response = await apiClient.GET("/api/catalog/internet/addons"); + return getDataOrDefault(response, [] as InternetProduct[]); } async function getSimPlans(): Promise { - const response = await apiClient.GET("/catalog/sim/plans"); - return response.data ?? []; + const response = await apiClient.GET("/api/catalog/sim/plans"); + return getDataOrDefault(response, [] as SimProduct[]); } async function getSimActivationFees(): Promise { - const response = await apiClient.GET("/catalog/sim/activation-fees"); - return response.data ?? []; + const response = await apiClient.GET("/api/catalog/sim/activation-fees"); + return getDataOrDefault(response, [] as SimProduct[]); } async function getSimAddons(): Promise { - const response = await apiClient.GET("/catalog/sim/addons"); - return response.data ?? []; + const response = await apiClient.GET("/api/catalog/sim/addons"); + return getDataOrDefault(response, [] as SimProduct[]); } async function getVpnPlans(): Promise { - const response = await apiClient.GET("/catalog/vpn/plans"); - return response.data ?? []; + const response = await apiClient.GET("/api/catalog/vpn/plans"); + return getDataOrDefault(response, [] as VpnProduct[]); } async function getVpnActivationFees(): Promise { - const response = await apiClient.GET("/catalog/vpn/activation-fees"); - return response.data ?? []; + const response = await apiClient.GET("/api/catalog/vpn/activation-fees"); + return getDataOrDefault(response, [] as VpnProduct[]); } export const catalogService = { diff --git a/apps/portal/src/features/dashboard/hooks/useDashboard.ts b/apps/portal/src/features/dashboard/hooks/useDashboard.ts deleted file mode 100644 index 2833ddc6..00000000 --- a/apps/portal/src/features/dashboard/hooks/useDashboard.ts +++ /dev/null @@ -1,27 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { apiClient, queryKeys } from "@/core/api"; - -/** - * Simplified Dashboard Hook - Just fetch formatted data from BFF - */ -export function useDashboard() { - // Get dashboard summary (BFF returns formatted, aggregated data) - const summaryQuery = useQuery({ - queryKey: queryKeys.dashboard.summary(), - queryFn: () => apiClient.GET('/dashboard/summary'), - staleTime: 2 * 60 * 1000, // 2 minutes (financial data should be fresh) - }); - - return { - // Dashboard summary with all stats pre-calculated by BFF - summary: summaryQuery.data?.data, - isLoading: summaryQuery.isLoading, - error: summaryQuery.error, - refetch: summaryQuery.refetch, - }; -} - -// Export the hook with the same name as before for compatibility -export const useDashboardSummary = useDashboard; diff --git a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts index 41e8f310..a27c6691 100644 --- a/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts +++ b/apps/portal/src/features/dashboard/hooks/useDashboardSummary.ts @@ -3,10 +3,14 @@ * Provides dashboard data with proper error handling, caching, and loading states */ -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth/services/auth.store"; -import { apiClient } from "@/core/api"; -import type { DashboardSummary, DashboardError } from "@customer-portal/domain"; +import { apiClient, queryKeys } from "@/core/api"; +import { getDataOrThrow } from "@/core/api/response-helpers"; +import type { + DashboardSummary, + DashboardError, +} from "@customer-portal/domain"; class DashboardDataError extends Error { constructor( @@ -19,14 +23,6 @@ class DashboardDataError extends Error { } } -// Query key factory for dashboard queries -export const dashboardQueryKeys = { - all: ["dashboard"] as const, - summary: () => [...dashboardQueryKeys.all, "summary"] as const, - stats: () => [...dashboardQueryKeys.all, "stats"] as const, - activity: (filters?: string[]) => [...dashboardQueryKeys.all, "activity", filters] as const, - nextInvoice: () => [...dashboardQueryKeys.all, "next-invoice"] as const, -}; /** * Hook for fetching dashboard summary data @@ -35,7 +31,7 @@ export function useDashboardSummary() { const { isAuthenticated, tokens } = useAuthStore(); return useQuery({ - queryKey: dashboardQueryKeys.summary(), + queryKey: queryKeys.dashboard.summary(), queryFn: async () => { if (!tokens?.accessToken) { throw new DashboardDataError( @@ -45,8 +41,11 @@ export function useDashboardSummary() { } try { - const response = await apiClient.GET('/dashboard/summary'); - return response.data!; + const response = await apiClient.GET('/api/dashboard/summary'); + return getDataOrThrow( + response, + "Dashboard summary response was empty" + ); } catch (error) { // Transform API errors to DashboardError format if (error instanceof Error) { @@ -77,97 +76,3 @@ export function useDashboardSummary() { }); } -/** - * Hook for refreshing dashboard data - */ -export function useRefreshDashboard() { - const queryClient = useQueryClient(); - const { isAuthenticated, tokens } = useAuthStore(); - - const refreshDashboard = async () => { - if (!isAuthenticated || !tokens?.accessToken) { - throw new Error("Authentication required"); - } - - // Invalidate and refetch dashboard queries - await queryClient.invalidateQueries({ - queryKey: dashboardQueryKeys.all, - }); - - // Optionally fetch fresh data immediately - return queryClient.fetchQuery({ - queryKey: dashboardQueryKeys.summary(), - queryFn: async () => { - const response = await apiClient.POST('/dashboard/refresh'); - return response.data!; - }, - }); - }; - - return { refreshDashboard }; -} - -/** - * Hook for fetching dashboard stats only (lightweight) - */ -export function useDashboardStats() { - const { isAuthenticated, tokens } = useAuthStore(); - - return useQuery({ - queryKey: dashboardQueryKeys.stats(), - queryFn: async () => { - if (!tokens?.accessToken) { - throw new Error("Authentication required"); - } - const response = await apiClient.GET('/dashboard/stats'); - return response.data!; - }, - staleTime: 1 * 60 * 1000, // 1 minute - gcTime: 3 * 60 * 1000, // 3 minutes - enabled: isAuthenticated && !!tokens?.accessToken, - }); -} - -/** - * Hook for fetching recent activity with filtering - */ -export function useDashboardActivity(filters?: string[], limit = 10) { - const { isAuthenticated, tokens } = useAuthStore(); - - return useQuery({ - queryKey: dashboardQueryKeys.activity(filters), - queryFn: async () => { - if (!tokens?.accessToken) { - throw new Error("Authentication required"); - } - const response = await apiClient.GET('/dashboard/activity', { - params: { query: { limit, filters: filters?.join(',') } } - }); - return response.data!; - }, - staleTime: 30 * 1000, // 30 seconds - gcTime: 2 * 60 * 1000, // 2 minutes - enabled: isAuthenticated && !!tokens?.accessToken, - }); -} - -/** - * Hook for fetching next invoice information - */ -export function useNextInvoice() { - const { isAuthenticated, tokens } = useAuthStore(); - - return useQuery({ - queryKey: dashboardQueryKeys.nextInvoice(), - queryFn: async () => { - if (!tokens?.accessToken) { - throw new Error("Authentication required"); - } - const response = await apiClient.GET('/dashboard/next-invoice'); - return response.data!; - }, - staleTime: 5 * 60 * 1000, // 5 minutes - gcTime: 10 * 60 * 1000, // 10 minutes - enabled: isAuthenticated && !!tokens?.accessToken, - }); -} diff --git a/apps/portal/src/features/orders/services/orders.service.ts b/apps/portal/src/features/orders/services/orders.service.ts index 961e9d07..6b45bc5b 100644 --- a/apps/portal/src/features/orders/services/orders.service.ts +++ b/apps/portal/src/features/orders/services/orders.service.ts @@ -7,7 +7,7 @@ export interface CreateOrderRequest { } async function createOrder(payload: CreateOrderRequest): Promise { - const response = await apiClient.POST("/orders", { body: payload }); + const response = await apiClient.POST("/api/orders", { body: payload }); if (!response.data) { throw new Error("Order creation failed"); } @@ -15,12 +15,12 @@ async function createOrder(payload: CreateOrderReques } async function getMyOrders(): Promise { - const response = await apiClient.GET("/orders/user"); + const response = await apiClient.GET("/api/orders/user"); return (response.data ?? []) as T; } async function getOrderById(orderId: string): Promise { - const response = await apiClient.GET("/orders/{sfOrderId}", { + const response = await apiClient.GET("/api/orders/{sfOrderId}", { params: { path: { sfOrderId: orderId } }, }); if (!response.data) { diff --git a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx index 06c4b157..364dc49e 100644 --- a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx +++ b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx @@ -42,7 +42,7 @@ export function ChangePlanModal({ } setLoading(true); try { - await apiClient.POST("/subscriptions/{id}/sim/change-plan", { + await apiClient.POST("/api/subscriptions/{id}/sim/change-plan", { params: { path: { id: subscriptionId } }, body: { newPlanCode, diff --git a/apps/portal/src/features/sim-management/components/SimActions.tsx b/apps/portal/src/features/sim-management/components/SimActions.tsx index 5b436065..17c11462 100644 --- a/apps/portal/src/features/sim-management/components/SimActions.tsx +++ b/apps/portal/src/features/sim-management/components/SimActions.tsx @@ -58,7 +58,7 @@ export function SimActions({ setError(null); try { - await apiClient.POST("/subscriptions/{id}/sim/reissue-esim", { + await apiClient.POST("/api/subscriptions/{id}/sim/reissue-esim", { params: { path: { id: subscriptionId } }, }); @@ -77,7 +77,7 @@ export function SimActions({ setError(null); try { - await apiClient.POST("/subscriptions/{id}/sim/cancel", { + await apiClient.POST("/api/subscriptions/{id}/sim/cancel", { params: { path: { id: subscriptionId } }, }); diff --git a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx index 6ad58384..5de5c9e7 100644 --- a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx +++ b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx @@ -75,7 +75,7 @@ export function SimFeatureToggles({ if (nt !== initial.nt) featurePayload.networkType = nt; if (Object.keys(featurePayload).length > 0) { - await apiClient.POST("/subscriptions/{id}/sim/features", { + await apiClient.POST("/api/subscriptions/{id}/sim/features", { params: { path: { id: subscriptionId } }, body: featurePayload, }); diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index 741dbf39..7d3c6ec7 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -30,7 +30,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro try { setError(null); - const response = await apiClient.GET("/subscriptions/{id}/sim", { + const response = await apiClient.GET("/api/subscriptions/{id}/sim", { params: { path: { id: subscriptionId } }, }); diff --git a/apps/portal/src/features/sim-management/components/TopUpModal.tsx b/apps/portal/src/features/sim-management/components/TopUpModal.tsx index da0b7bbc..ae005956 100644 --- a/apps/portal/src/features/sim-management/components/TopUpModal.tsx +++ b/apps/portal/src/features/sim-management/components/TopUpModal.tsx @@ -45,7 +45,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU quotaMb: getCurrentAmountMb(), }; - await apiClient.POST("/subscriptions/{id}/sim/top-up", { + await apiClient.POST("/api/subscriptions/{id}/sim/top-up", { params: { path: { id: subscriptionId } }, body: requestBody, }); diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionActions.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionActions.tsx deleted file mode 100644 index c03c8b15..00000000 --- a/apps/portal/src/features/subscriptions/components/SubscriptionActions.tsx +++ /dev/null @@ -1,307 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { - PauseIcon, - PlayIcon, - XMarkIcon, - ArrowUpIcon, - ArrowDownIcon, - DocumentTextIcon, - CreditCardIcon, - Cog6ToothIcon, - ExclamationTriangleIcon, -} from "@heroicons/react/24/outline"; -import { Button } from "@/components/ui/button"; -import { SubCard } from "@/components/ui/sub-card"; -import type { Subscription } from "@customer-portal/domain"; -import { cn } from "@/shared/utils"; -import { useSubscriptionAction } from "../hooks"; - -interface SubscriptionActionsProps { - subscription: Subscription; - onActionSuccess?: () => void; - className?: string; -} - -interface ActionButtonProps { - icon: React.ReactNode; - label: string; - description: string; - variant?: "default" | "destructive" | "outline" | "secondary"; - disabled?: boolean; - onClick: () => void; -} - -const ActionButton = ({ - icon, - label, - description, - variant = "outline", - disabled, - onClick, -}: ActionButtonProps) => ( -
-
{icon}
-
-

{label}

-

{description}

-
-
- -
-
-); - -export function SubscriptionActions({ - subscription, - onActionSuccess, - className, -}: SubscriptionActionsProps) { - const router = useRouter(); - const [loading, setLoading] = useState(null); - const subscriptionAction = useSubscriptionAction(); - - const isActive = subscription.status === "Active"; - const isSuspended = subscription.status === "Suspended"; - const isCancelled = subscription.status === "Cancelled" || subscription.status === "Terminated"; - const isPending = subscription.status === "Pending"; - - const isSimService = subscription.productName.toLowerCase().includes("sim"); - const isInternetService = - subscription.productName.toLowerCase().includes("internet") || - subscription.productName.toLowerCase().includes("broadband") || - subscription.productName.toLowerCase().includes("fiber"); - const isVpnService = subscription.productName.toLowerCase().includes("vpn"); - - const handleSuspend = async () => { - setLoading("suspend"); - try { - await subscriptionAction.mutateAsync({ - id: subscription.id, - action: "suspend", - }); - onActionSuccess?.(); - } catch (error) { - console.error("Failed to suspend subscription:", error); - } finally { - setLoading(null); - } - }; - - const handleResume = async () => { - setLoading("resume"); - try { - await subscriptionAction.mutateAsync({ - id: subscription.id, - action: "resume", - }); - onActionSuccess?.(); - } catch (error) { - console.error("Failed to resume subscription:", error); - } finally { - setLoading(null); - } - }; - - const handleCancel = async () => { - if ( - !confirm("Are you sure you want to cancel this subscription? This action cannot be undone.") - ) { - return; - } - - setLoading("cancel"); - try { - await subscriptionAction.mutateAsync({ - id: subscription.id, - action: "cancel", - }); - onActionSuccess?.(); - } catch (error) { - console.error("Failed to cancel subscription:", error); - } finally { - setLoading(null); - } - }; - - const handleUpgrade = () => { - router.push(`/catalog?upgrade=${subscription.id}`); - }; - - const handleDowngrade = () => { - router.push(`/catalog?downgrade=${subscription.id}`); - }; - - const handleViewInvoices = () => { - router.push(`/subscriptions/${subscription.id}#billing`); - }; - - const handleManagePayment = () => { - router.push("/billing/payments"); - }; - - const handleSimManagement = () => { - router.push(`/subscriptions/${subscription.id}#sim-management`); - }; - - const handleServiceSettings = () => { - router.push(`/subscriptions/${subscription.id}/settings`); - }; - - return ( - } - className={cn("", className)} - > -
- {/* Service Management Actions */} -
-

Service Management

-
- {/* SIM Management - Only for SIM services */} - {isSimService && isActive && ( - } - label="SIM Management" - description="Manage data usage, top-up, and SIM settings" - onClick={handleSimManagement} - /> - )} - - {/* Service Settings - Available for all active services */} - {isActive && ( - } - label="Service Settings" - description="Configure service-specific settings and preferences" - onClick={handleServiceSettings} - /> - )} - - {/* Suspend/Resume Actions */} - {isActive && ( - } - label="Suspend Service" - description="Temporarily suspend this service" - variant="outline" - onClick={() => { - void handleSuspend(); - }} - disabled={loading === "suspend"} - /> - )} - - {isSuspended && ( - } - label="Resume Service" - description="Resume suspended service" - variant="outline" - onClick={() => { - void handleResume(); - }} - disabled={loading === "resume"} - /> - )} -
-
- - {/* Plan Management Actions */} - {(isActive || isSuspended) && !subscription.cycle.includes("One-time") && ( -
-

Plan Management

-
- } - label="Upgrade Plan" - description="Upgrade to a higher tier plan with more features" - onClick={handleUpgrade} - /> - - } - label="Downgrade Plan" - description="Switch to a lower tier plan" - onClick={handleDowngrade} - /> -
-
- )} - - {/* Billing Actions */} -
-

Billing & Payment

-
- } - label="View Invoices" - description="View billing history and download invoices" - onClick={handleViewInvoices} - /> - - } - label="Manage Payment" - description="Update payment methods and billing information" - onClick={handleManagePayment} - /> -
-
- - {/* Cancellation Actions */} - {!isCancelled && !isPending && ( -
-

Cancellation

-
-
- -
-
Cancel Subscription
-

- Permanently cancel this subscription. This action cannot be undone and you will - lose access to the service. -

- -
-
-
-
- )} - - {/* Status Information */} - {(isCancelled || isPending) && ( -
-
- -
- {isCancelled ? "Subscription Cancelled" : "Subscription Pending"} -
-
-

- {isCancelled - ? "This subscription has been cancelled and is no longer active. No further actions are available." - : "This subscription is pending activation. Actions will be available once the subscription is active."} -

-
- )} -
-
- ); -} diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 55e0045f..62aa8ee0 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -3,8 +3,9 @@ * React hooks for subscription functionality using shared types */ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { apiClient, queryKeys } from "@/core/api"; +import { getDataOrDefault, getDataOrThrow, getNullableData } from "@/core/api/response-helpers"; import { useAuthSession } from "@/features/auth/services/auth.store"; import type { InvoiceList, Subscription, SubscriptionList } from "@customer-portal/domain"; @@ -57,13 +58,12 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) { const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ - queryKey: queryKeys.subscriptions.list(status ? { status } : {}), + queryKey: queryKeys.subscriptions.list(status ? { status } : undefined), queryFn: async () => { - const response = await apiClient.GET( - "/subscriptions", + const response = await apiClient.GET("/api/subscriptions", status ? { params: { query: { status } } } : undefined ); - return toSubscriptionList(response.data); + return toSubscriptionList(getNullableData(response)); }, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, @@ -78,10 +78,10 @@ export function useActiveSubscriptions() { const { isAuthenticated, hasValidToken } = useAuthSession(); return useQuery({ - queryKey: [...queryKeys.subscriptions.all, "active"] as const, + queryKey: queryKeys.subscriptions.active(), queryFn: async () => { - const response = await apiClient.GET("/subscriptions/active"); - return response.data ?? []; + const response = await apiClient.GET("/api/subscriptions/active"); + return getDataOrDefault(response, [] as Subscription[]); }, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, @@ -98,8 +98,8 @@ export function useSubscriptionStats() { return useQuery({ queryKey: queryKeys.subscriptions.stats(), queryFn: async () => { - const response = await apiClient.GET("/subscriptions/stats"); - return response.data ?? emptyStats; + const response = await apiClient.GET("/api/subscriptions/stats"); + return getDataOrDefault(response, emptyStats); }, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, @@ -116,13 +116,10 @@ export function useSubscription(subscriptionId: number) { return useQuery({ queryKey: queryKeys.subscriptions.detail(String(subscriptionId)), queryFn: async () => { - const response = await apiClient.GET("/subscriptions/{id}", { + const response = await apiClient.GET("/api/subscriptions/{id}", { params: { path: { id: subscriptionId } }, }); - if (!response.data) { - throw new Error("Subscription not found"); - } - return response.data; + return getDataOrThrow(response, "Subscription not found"); }, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, @@ -143,44 +140,22 @@ export function useSubscriptionInvoices( return useQuery({ queryKey: queryKeys.subscriptions.invoices(subscriptionId, { page, limit }), queryFn: async () => { - const response = await apiClient.GET("/subscriptions/{id}/invoices", { + const response = await apiClient.GET("/api/subscriptions/{id}/invoices", { params: { path: { id: subscriptionId }, query: { page, limit }, }, }); - if (!response.data) { - return { - ...emptyInvoiceList, - pagination: { - ...emptyInvoiceList.pagination, - page, - }, - }; - } - return response.data; + return getDataOrDefault(response, { + ...emptyInvoiceList, + pagination: { + ...emptyInvoiceList.pagination, + page, + }, + }); }, staleTime: 60 * 1000, gcTime: 5 * 60 * 1000, enabled: isAuthenticated && hasValidToken && subscriptionId > 0, }); } - -/** - * Hook to perform subscription actions (suspend, resume, cancel, etc.) - */ -export function useSubscriptionAction() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ id, action }: { id: number; action: string }) => { - await apiClient.POST("/subscriptions/{id}/actions", { - params: { path: { id } }, - body: { action }, - }); - }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }); - }, - }); -} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index a9ba5fb9..5607326e 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -1,5 +1,5 @@ export * as ApiTypes from "./__generated__/types"; -export { createClient } from "./runtime/client"; +export { createClient, resolveBaseUrl } from "./runtime/client"; export type { ApiClient, AuthHeaderResolver, diff --git a/packages/api-client/src/runtime/client.ts b/packages/api-client/src/runtime/client.ts index 5f4fbeb0..eae08ec1 100644 --- a/packages/api-client/src/runtime/client.ts +++ b/packages/api-client/src/runtime/client.ts @@ -1,6 +1,9 @@ import createOpenApiClient from "openapi-fetch"; +import type { + Middleware, + MiddlewareCallbackParams, +} from "openapi-fetch"; import type { paths } from "../__generated__/types"; - export class ApiError extends Error { constructor( message: string, @@ -12,11 +15,72 @@ export class ApiError extends Error { } } -export type ApiClient = ReturnType>; +type StrictApiClient = ReturnType>; + +type FlexibleApiMethods = { + GET(path: string, options?: unknown): Promise<{ data?: T | null }>; + POST(path: string, options?: unknown): Promise<{ data?: T | null }>; + PUT(path: string, options?: unknown): Promise<{ data?: T | null }>; + PATCH(path: string, options?: unknown): Promise<{ data?: T | null }>; + DELETE(path: string, options?: unknown): Promise<{ data?: T | null }>; +}; + +export type ApiClient = StrictApiClient & FlexibleApiMethods; export type AuthHeaderResolver = () => string | undefined; +type EnvKey = + | "NEXT_PUBLIC_API_BASE" + | "NEXT_PUBLIC_API_URL" + | "API_BASE_URL" + | "API_BASE" + | "API_URL"; + +const BASE_URL_ENV_KEYS: readonly EnvKey[] = [ + "NEXT_PUBLIC_API_BASE", + "NEXT_PUBLIC_API_URL", + "API_BASE_URL", + "API_BASE", + "API_URL", +]; + +const DEFAULT_BASE_URL = "http://localhost:4000/api"; + +const normalizeBaseUrl = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + return DEFAULT_BASE_URL; + } + + if (trimmed === "/") { + return trimmed; + } + + // Avoid accidental double slashes when openapi-fetch joins with request path + return trimmed.replace(/\/+$/, ""); +}; + +const resolveBaseUrlFromEnv = () => { + for (const key of BASE_URL_ENV_KEYS) { + const envValue = process.env?.[key]; + if (typeof envValue === "string" && envValue.trim()) { + return normalizeBaseUrl(envValue); + } + } + + return DEFAULT_BASE_URL; +}; + +export const resolveBaseUrl = (baseUrl?: string) => { + if (typeof baseUrl === "string" && baseUrl.trim()) { + return normalizeBaseUrl(baseUrl); + } + + return resolveBaseUrlFromEnv(); +}; + export interface CreateClientOptions { + baseUrl?: string; getAuthHeader?: AuthHeaderResolver; handleError?: (response: Response) => void | Promise; } @@ -49,10 +113,8 @@ async function defaultHandleError(response: Response) { throw new ApiError(message, response, body); } -export function createClient( - baseUrl: string, - options: CreateClientOptions = {} -): ApiClient { +export function createClient(options: CreateClientOptions = {}): ApiClient { + const baseUrl = resolveBaseUrl(options.baseUrl); const client = createOpenApiClient({ baseUrl }); const handleError = options.handleError ?? defaultHandleError; @@ -60,8 +122,8 @@ export function createClient( if (typeof client.use === "function") { const resolveAuthHeader = options.getAuthHeader; - client.use({ - onRequest({ request }) { + const middleware: Middleware = { + onRequest({ request }: MiddlewareCallbackParams) { if (!resolveAuthHeader) return; if (!request || typeof request.headers?.has !== "function") return; if (request.headers.has("Authorization")) return; @@ -71,13 +133,15 @@ export function createClient( request.headers.set("Authorization", headerValue); }, - async onResponse({ response }) { + async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) { await handleError(response); }, - } as never); + }; + + client.use(middleware as never); } - return client; + return client as ApiClient; } export type { paths }; diff --git a/packages/domain/src/utils/currency.ts b/packages/domain/src/utils/currency.ts new file mode 100644 index 00000000..ec3303ea --- /dev/null +++ b/packages/domain/src/utils/currency.ts @@ -0,0 +1,44 @@ +const DEFAULT_CURRENCY_LOCALE = "en-US"; + +const currencyLocaleMap: Record = { + USD: "en-US", + EUR: "de-DE", + GBP: "en-GB", + JPY: "ja-JP", + AUD: "en-AU", + CAD: "en-CA", +}; + +export interface FormatCurrencyOptions { + currency: string; + locale?: string; + minimumFractionDigits?: number; + maximumFractionDigits?: number; +} + +export const getCurrencyLocale = (currency: string): string => + currencyLocaleMap[currency.toUpperCase()] ?? DEFAULT_CURRENCY_LOCALE; + +export const formatCurrency = ( + amount: number, + currencyOrOptions: string | FormatCurrencyOptions = "JPY" +): string => { + const options = + typeof currencyOrOptions === "string" + ? { currency: currencyOrOptions } + : currencyOrOptions; + + const { + currency, + locale = getCurrencyLocale(options.currency), + minimumFractionDigits, + maximumFractionDigits, + } = options; + + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + minimumFractionDigits, + maximumFractionDigits, + }).format(amount); +}; diff --git a/packages/domain/src/utils/index.ts b/packages/domain/src/utils/index.ts index 6c4d23d6..e8fe893e 100644 --- a/packages/domain/src/utils/index.ts +++ b/packages/domain/src/utils/index.ts @@ -2,4 +2,4 @@ export * from "./validation"; export * from "./array-utils"; export * from "./filters"; - +export * from "./currency"; diff --git a/packages/domain/src/utils/validation.ts b/packages/domain/src/utils/validation.ts index da5d327d..390d119d 100644 --- a/packages/domain/src/utils/validation.ts +++ b/packages/domain/src/utils/validation.ts @@ -103,16 +103,6 @@ export function sanitizeString(input: string): string { .trim(); } -/** - * Formats currency amount - */ -export function formatCurrency(amount: number, currency = "JPY"): string { - return new Intl.NumberFormat("ja-JP", { - style: "currency", - currency, - }).format(amount); -} - /** * Formats date to locale string */