Refactor FormField component to enhance props structure and improve input handling. Remove unused ProfileCompletionGuard component and related hooks. Update API client calls to include '/api' prefix for consistency. Streamline billing and subscription services with improved error handling and response management. Clean up unused imports and components across the codebase.

This commit is contained in:
T. Narantuya 2025-09-19 12:58:00 +09:00
parent 06009bd2d5
commit 54fb396557
52 changed files with 907 additions and 1210 deletions

View File

@ -13,14 +13,27 @@ interface FormFieldProps
error?: string;
helperText?: string;
required?: boolean;
labelProps?: Omit<LabelProps, "htmlFor" | "required">;
labelProps?: Omit<LabelProps, "htmlFor">;
fieldId?: string;
children?: React.ReactNode;
containerClassName?: string;
inputClassName?: string;
}
const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
(
{ 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<HTMLInputElement, FormFieldProps>(
const helperTextId = helperText ? `${id}-helper` : undefined;
return (
<div className={cn("space-y-1", className)}>
<div className={cn("space-y-1", containerClassName)}>
{label && (
<Label
htmlFor={id}
required={required}
variant={error ? "error" : "default"}
{...labelProps}
className={cn(
"block text-sm font-medium text-gray-700",
error && "text-red-600",
labelProps?.className
)}
{...(labelProps ? { ...labelProps, className: undefined } : undefined)}
>
{label}
<span>{label}</span>
{required ? (
<span aria-hidden="true" className="ml-1 text-red-600">
*
</span>
) : null}
</Label>
)}
{children ? (
isValidElement(children) ? (
cloneElement(
children as React.ReactElement<any>,
{
isValidElement(children)
? cloneElement(children, {
id,
"aria-invalid": error ? "true" : undefined,
"aria-describedby": cn(errorId, helperTextId) || undefined,
} as any
)
) : (
children
)
} as Record<string, unknown>)
: children
) : (
<Input
id={id}
ref={ref}
error={error}
aria-invalid={error ? "true" : undefined}
aria-describedby={cn(errorId, helperTextId) || undefined}
className={cn(
error &&
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
inputClassName,
inputProps.className
)}
{...(() => {
const { children: _children, dangerouslySetInnerHTML: _dangerouslySetInnerHTML, ...rest } =
inputProps as Record<string, unknown>;
const { className, ...rest } = inputProps;
return rest;
})()}
/>

View File

@ -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 (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">Loading...</span>
</div>
);
}
// If requiring complete profile and it's not complete, show loading (will redirect)
if (requireComplete && !isComplete) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">Redirecting to complete profile...</span>
</div>
);
}
// Show banner if profile is incomplete and banner is enabled
if (!isComplete && showBanner) {
return (
<div>
<div className="bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200 rounded-xl p-6 mb-6">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-6 w-6 text-amber-600" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-amber-900 mb-2">Complete Your Profile</h3>
<p className="text-amber-800 mb-4">
Some features may be limited until you complete your profile information.
</p>
<button
onClick={redirectToCompletion}
className="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium"
>
<MapPinIcon className="h-4 w-4 mr-2" />
Complete Profile
</button>
</div>
</div>
</div>
{children}
</div>
);
}
// Profile is complete or banner is disabled, show children
return <>{children}</>;
}

View File

@ -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<string[]>([]);
@ -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

View File

@ -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<string[]>(() => {

View File

@ -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<HTMLAnchorElement> &
VariantProps<typeof buttonVariants>;
VariantProps<typeof buttonVariants> &
ButtonExtras;
type ButtonAsButtonProps = {
as?: "button";
} & ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
VariantProps<typeof buttonVariants> &
ButtonExtras;
export type ButtonProps = ButtonAsAnchorProps | ButtonAsButtonProps;
const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((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 (
<a
className={cn(buttonVariants({ variant, size, className }))}
href={href}
ref={ref as React.Ref<HTMLAnchorElement>}
aria-busy={loading || undefined}
{...anchorProps}
/>
>
<span className="inline-flex items-center gap-2">
{loading ? <LoadingSpinner size="sm" /> : leftIcon}
<span>{loading ? loadingText ?? children : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span>
</a>
);
}
const { className, variant, size, as: _as, ...buttonProps } = props;
const { className, variant, size, as: _as, disabled, ...buttonProps } = rest as ButtonAsButtonProps;
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref as React.Ref<HTMLButtonElement>}
disabled={loading || disabled}
aria-busy={loading || undefined}
{...buttonProps}
/>
>
<span className="inline-flex items-center gap-2">
{loading ? <LoadingSpinner size="sm" /> : leftIcon}
<span>{loading ? loadingText ?? children : children}</span>
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
</span>
</button>
);
});
Button.displayName = "Button";

View File

@ -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";

View File

@ -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<HTMLInputElement>;
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: ReactNode;
}
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
});
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, error, ...props }, ref) => {
const isInvalid = Boolean(error);
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
isInvalid &&
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
className
)}
aria-invalid={isInvalid || undefined}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@ -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<HTMLSpanElement> {
export type StatusPillProps = HTMLAttributes<HTMLSpanElement> & {
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<HTMLSpanElement, StatusPillProps>(
({ 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 (
<span
className={`inline-flex items-center rounded-[var(--cp-pill-radius)] px-[var(--cp-pill-px)] py-[var(--cp-pill-py)] text-xs font-medium ring-1 ring-inset ${tone} ${className}`}
{...rest}
>
{label}
</span>
);
}
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 (
<span
ref={ref}
className={cn(
"inline-flex items-center rounded-full ring-1 ring-inset gap-1",
sizing,
tone,
className
)}
{...rest}
>
{icon ? <span className="flex items-center justify-center">{icon}</span> : null}
<span>{label}</span>
</span>
);
}
);
StatusPill.displayName = "StatusPill";

View File

@ -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<HTMLDivElement, SubCardProps>(
(
{
title,
icon,
right,
header,
footer,
children,
className = "",
headerClassName = "",
bodyClassName = "",
},
ref
) => (
<div
ref={ref}
className={`border-[var(--cp-card-border)] bg-white shadow-[var(--cp-card-shadow)] rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] transition-shadow duration-[var(--cp-transition-normal)] hover:shadow-[var(--cp-card-shadow-lg)] ${className}`}
>
{header ? (
@ -47,5 +51,6 @@ export function SubCard({
</div>
) : null}
</div>
);
}
)
);
SubCard.displayName = "SubCard";

View File

@ -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,
});

View File

@ -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";

View File

@ -3,56 +3,75 @@
* Centralized query key factory for consistent caching
*/
import type { InvoiceQueryParams } from "./types";
const createScope = <const TScope extends readonly string[]>(scope: TScope) => ({
all: scope,
extend: <const TParts extends readonly unknown[]>(...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<string, unknown>) =>
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<string, unknown>) =>
[...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<string, unknown>) =>
[...queryKeys.subscriptions.all, 'list', params ?? {}] as const,
detail: (id: string) => [...queryKeys.subscriptions.all, 'detail', id] as const,
invoices: (subscriptionId: number, params?: Record<string, unknown>) =>
[...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<string, unknown>) =>
[...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;

View File

@ -0,0 +1,24 @@
const coerceData = <T>(container: { data?: unknown }): T | null | undefined =>
container.data as T | null | undefined;
export const getDataOrThrow = <T>(
container: { data?: unknown },
message = "API response did not include data"
): T => {
const data = coerceData<T>(container);
if (data === null || typeof data === "undefined") {
throw new Error(message);
}
return data;
};
export const getDataOrDefault = <T>(container: { data?: unknown }, fallback: T): T => {
const data = coerceData<T>(container);
return typeof data === "undefined" || data === null ? fallback : data;
};
export const getNullableData = <T>(container: { data?: unknown }): T | null => {
const data = coerceData<T>(container);
return typeof data === "undefined" ? null : data;
};

View File

@ -0,0 +1,8 @@
export interface InvoiceQueryParams {
page?: number;
limit?: number;
status?: string;
search?: string;
sort?: string;
[key: string]: unknown;
}

View File

@ -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"),
});

View File

@ -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<string, string>,
config: Record<string, ValidationRule[]>
) => {
const fieldErrors: Record<string, string | undefined> = {};
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;

View File

@ -1,4 +1,3 @@
export { useProfileCompletion } from "./useProfileCompletion";
export { useProfileData } from "./useProfileData";
export { useProfileEdit } from "./useProfileEdit";
export { useAddressEdit } from "./useAddressEdit";

View File

@ -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<boolean>(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,
};
}

View File

@ -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 = <T>(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<AuthUser | null>(response.data);
const response = await apiClient.GET('/api/me');
return getNullableData<AuthUser>(response);
},
async updateProfile(update: ProfileUpdateInput) {
const response = await apiClient.PATCH('/me', { body: update });
return ensureData<AuthUser>(response.data);
const response = await apiClient.PATCH('/api/me', { body: update });
return getDataOrThrow<AuthUser>(response, "Failed to update profile");
},
async getAddress() {
const response = await apiClient.GET('/me/address');
return ensureData<Address | null>(response.data);
const response = await apiClient.GET('/api/me/address');
return getNullableData<Address>(response);
},
async updateAddress(address: Address) {
const response = await apiClient.PATCH('/me/address', { body: address });
return ensureData<Address>(response.data);
const response = await apiClient.PATCH('/api/me/address', { body: address });
return getDataOrThrow<Address>(response, "Failed to update address");
},
};

View File

@ -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) {

View File

@ -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<RequestFormData>({
email: "",

View File

@ -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({
);
}

View File

@ -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<FormData>({
email: initialEmail,

View File

@ -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";

View File

@ -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<AuthTokens, "expiresAt"> & { 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<AuthStoreState>()(
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<AuthPayload>(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<AuthStoreState>()(
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<AuthPayload>(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<AuthStoreState>()(
// 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<AuthStoreState>()(
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<AuthStoreState>()(
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<AuthPayload>(
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<AuthStoreState>()(
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<AuthPayload>(
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<AuthStoreState>()(
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<PasswordCheckPayload>(
response,
'Failed to check password requirements'
);
set({ loading: false });
return result;
} catch (error) {
@ -323,8 +346,11 @@ export const useAuthStore = create<AuthStoreState>()(
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<LinkWhmcsPayload>(
response,
'Failed to link WHMCS account'
);
set({ loading: false });
return result;
} catch (error) {
@ -337,11 +363,11 @@ export const useAuthStore = create<AuthStoreState>()(
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<AuthPayload>(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<AuthStoreState>()(
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<AuthUser | null>(response, null);
if (user) {
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
} else {

View File

@ -44,13 +44,11 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
);
}
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 = [
{

View File

@ -89,7 +89,6 @@ export function InvoiceTable({
<span className="text-sm font-medium text-gray-900">
{formatCurrency(invoice.total, {
currency: invoice.currency,
currencySymbol: invoice.currencySymbol,
locale: getCurrencyLocale(invoice.currency),
})}
</span>

View File

@ -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 <BanknotesIcon className="h-6 w-6 text-gray-400" />;
}
if (brand?.toLowerCase().includes("mobile")) {
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-400" />;
}
return <CreditCardIcon className="h-6 w-6 text-gray-400" />;
};
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 (
<div
className={cn(
"flex items-center justify-between p-4 border border-gray-200 rounded-lg bg-white",
className
)}
>
<div className="flex items-center gap-4">
<div className="flex-shrink-0">{icon}</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium text-gray-900">{description}</p>
{paymentMethod.isDefault ? (
<StatusPill label="Default" variant="info" size="sm" />
) : null}
</div>
<div className="text-sm text-gray-500">
{paymentMethod.gatewayDisplayName || paymentMethod.gatewayName || paymentMethod.type}
</div>
{expiry ? <div className="text-xs text-gray-400">{expiry}</div> : null}
</div>
</div>
{showActions && actionSlot ? <div className="flex-shrink-0">{actionSlot}</div> : null}
</div>
);
}
export type { PaymentMethodCardProps };

View File

@ -1,2 +1,3 @@
export * from "./InvoiceStatusBadge";
export * from "./InvoiceItemRow";
export * from "./PaymentMethodCard";

View File

@ -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<InvoiceList> {
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<Invoice> {
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<PaymentMethodList> {
const response = await apiClient.GET("/invoices/payment-methods");
return response.data ?? emptyPaymentMethods;
}
async function fetchPaymentGateways(): Promise<PaymentGatewayList> {
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<typeof queryKeys.billing.gateways>
>,
"queryKey" | "queryFn"
>
): UseQueryResult<PaymentGatewayList, Error> {
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,
});

View File

@ -29,7 +29,7 @@ export function usePaymentRefresh<T>({
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);

View File

@ -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;

View File

@ -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 (
<PageLayout
icon={<CreditCardIcon />}
title="Payment Methods"
description="Manage your saved payment methods and billing information"
>
<AsyncBlock error={errorObj} variant="page">
<AsyncBlock error={combinedError} variant="page">
<></>
</AsyncBlock>
</PageLayout>
@ -97,9 +111,9 @@ export function PaymentMethodsContainer() {
>
<ErrorBoundary>
<InlineToast
visible={paymentRefresh.toast.visible}
text={paymentRefresh.toast.text}
tone={paymentRefresh.toast.tone}
visible={paymentRefresh.toast.visible}
text={paymentRefresh.toast.text}
tone={paymentRefresh.toast.tone}
/>
{/* Simplified: remove verbose banner; controls exist via buttons */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@ -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}
/>
))}
</div>
@ -207,4 +220,3 @@ export function PaymentMethodsContainer() {
}
export default PaymentMethodsContainer;

View File

@ -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<string, string> = {};
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(
<>
<div className="flex items-center justify-between mb-4">
@ -379,18 +382,18 @@ export function AddressConfirmation({
</div>
) : (
<div>
{billingInfo.address.street ? (
{address?.street ? (
<div className="space-y-4">
<div className="text-gray-900 space-y-1">
<p className="font-semibold text-base">{billingInfo.address.street}</p>
{billingInfo.address.streetLine2 && (
<p className="text-gray-700">{billingInfo.address.streetLine2}</p>
)}
<p className="font-semibold text-base">{address.street}</p>
{address.streetLine2 ? (
<p className="text-gray-700">{address.streetLine2}</p>
) : null}
<p className="text-gray-700">
{billingInfo.address.city}, {billingInfo.address.state}{" "}
{billingInfo.address.postalCode}
{[address.city, address.state].filter(Boolean).join(", ")}
{address.postalCode ? ` ${address.postalCode}` : ""}
</p>
<p className="text-gray-600">{billingInfo.address.country}</p>
<p className="text-gray-600">{address.country}</p>
</div>
{/* Status message for Internet orders when pending */}

View File

@ -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({
<div className="flex gap-4">
{backUrl ? (
<Button
as="a"
href={backUrl}
variant="outline"
className="flex-1 group"
disabled={disabled || loading}
onClick={() => {
if (disabled || loading) return;
router.push(backUrl);
}}
>
<ArrowLeftIcon className="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
{backLabel}

View File

@ -1,44 +1,25 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Skeleton } from "@/components/ui/loading-skeleton";
import { Button } from "@/components/ui/button";
import { AlertBanner } from "@/components/common/AlertBanner";
import { useState, useEffect } from "react";
import {
CreditCardIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
// Use domain PaymentMethod type
import { CreditCardIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import type { PaymentMethod } from "@customer-portal/domain";
export interface PaymentFormProps {
// Payment methods
existingMethods?: PaymentMethod[];
selectedMethodId?: string;
// Callbacks
onMethodSelect?: (methodId: string) => void;
onAddNewMethod?: () => void;
onValidationChange?: (isValid: boolean, errors: string[]) => void;
// Configuration
title?: string;
description?: string;
showTitle?: boolean;
allowNewMethod?: boolean;
requirePaymentMethod?: boolean;
// State
loading?: boolean;
disabled?: boolean;
// Styling
variant?: "default" | "compact" | "inline";
// Custom content
children?: React.ReactNode;
footerContent?: React.ReactNode;
}
@ -56,77 +37,41 @@ export function PaymentForm({
requirePaymentMethod = true,
loading = false,
disabled = false,
variant = "default",
children,
footerContent,
}: PaymentFormProps) {
const [selectedMethod, setSelectedMethod] = useState<string>(selectedMethodId || "");
const [selectedMethod, setSelectedMethod] = useState<string>(selectedMethodId ?? "");
const [errors, setErrors] = useState<string[]>([]);
const validatePayment = () => {
const validationErrors: string[] = [];
if (requirePaymentMethod) {
if (existingMethods.length === 0) {
validationErrors.push(
"No payment method on file. Please add a payment method to continue."
);
} else if (!selectedMethod) {
validationErrors.push("Please select a payment method.");
}
}
setErrors(validationErrors);
const isValid = validationErrors.length === 0;
onValidationChange?.(isValid, validationErrors);
return isValid;
};
const handleMethodSelect = (methodId: string) => {
if (disabled) return;
setSelectedMethod(methodId);
onMethodSelect?.(methodId);
};
useEffect(() => {
validatePayment();
}, [selectedMethod, existingMethods, requirePaymentMethod]);
useEffect(() => {
if (selectedMethodId !== undefined) {
setSelectedMethod(selectedMethodId);
}
}, [selectedMethodId]);
const getCardBrandIcon = (brand?: string) => {
// In a real implementation, you'd return appropriate brand icons
return <CreditCardIcon className="h-6 w-6 text-gray-400" />;
};
useEffect(() => {
const validationErrors: string[] = [];
const formatCardNumber = (last4?: string) => {
return last4 ? `•••• •••• •••• ${last4}` : "•••• •••• •••• ••••";
};
if (requirePaymentMethod) {
if (existingMethods.length === 0) {
validationErrors.push("No payment methods on file. Add one to continue.");
} else if (!selectedMethod) {
validationErrors.push("Select a payment method to continue.");
}
}
const formatExpiry = (month?: number, year?: number) => {
if (!month || !year) return "";
return `${month.toString().padStart(2, "0")}/${year.toString().slice(-2)}`;
};
setErrors(validationErrors);
onValidationChange?.(validationErrors.length === 0, validationErrors);
}, [existingMethods, requirePaymentMethod, selectedMethod, onValidationChange]);
const containerClasses =
variant === "inline"
? ""
: variant === "compact"
? "p-4 bg-gray-50 rounded-lg border border-gray-200"
: "p-6 bg-white border border-gray-200 rounded-lg";
const methods = useMemo(() => existingMethods, [existingMethods]);
if (loading) {
return (
<div className={containerClasses}>
<div className="p-6 bg-white border border-gray-200 rounded-lg">
<div className="space-y-4">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-12" />
<div className="space-y-2">
@ -142,116 +87,118 @@ export function PaymentForm({
);
}
const renderMethod = (method: PaymentMethod) => {
const methodId = String(method.id);
const isSelected = selectedMethod === methodId;
const label = method.cardBrand
? `${method.cardBrand.toUpperCase()} ${method.lastFour ? `•••• ${method.lastFour}` : ""}`.trim()
: method.description ?? method.type;
return (
<label
key={method.id}
className={[
"flex items-center justify-between p-4 border-2 rounded-lg cursor-pointer transition-colors",
isSelected ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
disabled ? "opacity-50 cursor-not-allowed" : "",
].join(" ")}
>
<div className="flex items-center gap-3">
<CreditCardIcon className="h-6 w-6 text-gray-400" />
<div>
<div className="text-sm font-medium text-gray-900 flex items-center gap-2">
{label}
{method.isDefault ? (
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full">Default</span>
) : null}
</div>
{method.expiryDate ? (
<div className="text-xs text-gray-500">Expires {method.expiryDate}</div>
) : null}
</div>
</div>
<input
type="radio"
name="paymentMethod"
value={methodId}
checked={isSelected}
disabled={disabled}
onChange={() => {
if (disabled) return;
setSelectedMethod(methodId);
onMethodSelect?.(methodId);
}}
className="text-blue-600 focus:ring-blue-500"
/>
</label>
);
};
return (
<div className={containerClasses}>
<div className="p-6 bg-white border border-gray-200 rounded-lg space-y-4">
{showTitle && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-2">
<div>
<div className="flex items-center gap-2 mb-1">
<CreditCardIcon className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
</div>
{description && <p className="text-sm text-gray-600">{description}</p>}
{description ? <p className="text-sm text-gray-600">{description}</p> : null}
</div>
)}
{/* No payment methods */}
{existingMethods.length === 0 ? (
<div className="text-center py-8">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CreditCardIcon className="h-8 w-8 text-gray-400" />
{methods.length === 0 ? (
<div className="py-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<CreditCardIcon className="h-6 w-6 text-gray-400" />
</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">No Payment Method on File</h4>
<p className="text-gray-600 mb-6">Add a payment method to complete your order.</p>
{allowNewMethod && onAddNewMethod && (
<p className="text-sm text-gray-600 mb-4">Add a payment method to continue.</p>
{allowNewMethod && onAddNewMethod ? (
<Button onClick={onAddNewMethod} disabled={disabled}>
Add Payment Method
</Button>
)}
) : null}
</div>
) : (
<div className="space-y-4">
{/* Existing payment methods */}
<div className="space-y-3">
{existingMethods.map(method => (
<label
key={method.id}
className={`flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all ${
selectedMethod === method.id
? "border-blue-500 bg-blue-50 ring-2 ring-blue-100"
: "border-gray-200 hover:border-gray-300 bg-white"
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<input
type="radio"
name="paymentMethod"
value={method.id}
checked={selectedMethod === method.id}
onChange={() => handleMethodSelect(method.id)}
disabled={disabled}
className="text-blue-600 focus:ring-blue-500 mr-4"
/>
<div className="flex items-center flex-1">
<div className="mr-4">{getCardBrandIcon(method.brand)}</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">
{method.brand?.toUpperCase()} {formatCardNumber(method.last4)}
</span>
{method.isDefault && (
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full font-medium">
Default
</span>
)}
</div>
<div className="text-sm text-gray-600 mt-1">
{method.name && <span>{method.name} </span>}
Expires {formatExpiry(method.expiryMonth, method.expiryYear)}
</div>
</div>
{selectedMethod === method.id && (
<CheckCircleIcon className="h-5 w-5 text-blue-600 ml-4" />
)}
</div>
</label>
))}
</div>
{/* Add new payment method option */}
{allowNewMethod && onAddNewMethod && (
<div className="pt-4 border-t border-gray-200">
<button
<div className="space-y-3">
{methods.map(renderMethod)}
{allowNewMethod && onAddNewMethod ? (
<div className="pt-3">
<Button
variant="outline"
className="w-full"
onClick={onAddNewMethod}
disabled={disabled}
className="w-full flex items-center justify-center gap-2 p-4 border-2 border-dashed border-gray-300 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<CreditCardIcon className="h-5 w-5" />
<span className="font-medium">Add New Payment Method</span>
</button>
Add Another Method
</Button>
</div>
)}
) : null}
</div>
)}
{/* Custom content */}
{children && <div className="mt-6">{children}</div>}
{children}
{/* Validation errors */}
{errors.length > 0 && (
<AlertBanner variant="error" title="Payment Required" className="mt-4" elevated size="sm">
{errors.length > 0 ? (
<AlertBanner variant="error" title="Payment Required" elevated size="sm">
<ul className="list-disc list-inside">
{errors.map((error, index) => (
<li key={index}>{error}</li>
{errors.map((msg, index) => (
<li key={index}>{msg}</li>
))}
</ul>
</AlertBanner>
) : (
selectedMethod && (
<div className="flex items-center gap-2 text-sm text-green-600">
<CheckCircleIcon className="h-4 w-4" />
Payment method selected
</div>
)
)}
{/* Footer content */}
{footerContent && <div className="mt-6 pt-4 border-t border-gray-200">{footerContent}</div>}
{footerContent ? (
<div className="pt-4 border-t border-gray-200">{footerContent}</div>
) : null}
</div>
);
}

View File

@ -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<InternetProduct[]> {
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<InternetProduct[]> {
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<InternetProduct[]> {
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<SimProduct[]> {
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<SimProduct[]> {
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<SimProduct[]> {
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<VpnProduct[]> {
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<VpnProduct[]> {
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 = {

View File

@ -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;

View File

@ -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<DashboardSummary, DashboardError>({
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<DashboardSummary>(
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,
});
}

View File

@ -7,7 +7,7 @@ export interface CreateOrderRequest {
}
async function createOrder<T = { sfOrderId: string }>(payload: CreateOrderRequest): Promise<T> {
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<T = { sfOrderId: string }>(payload: CreateOrderReques
}
async function getMyOrders<T = unknown[]>(): Promise<T> {
const response = await apiClient.GET("/orders/user");
const response = await apiClient.GET("/api/orders/user");
return (response.data ?? []) as T;
}
async function getOrderById<T = unknown>(orderId: string): Promise<T> {
const response = await apiClient.GET("/orders/{sfOrderId}", {
const response = await apiClient.GET("/api/orders/{sfOrderId}", {
params: { path: { sfOrderId: orderId } },
});
if (!response.data) {

View File

@ -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,

View File

@ -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 } },
});

View File

@ -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,
});

View File

@ -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 } },
});

View File

@ -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,
});

View File

@ -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) => (
<div className="flex items-start space-x-3 p-4 border border-gray-200 rounded-lg hover:border-gray-300 transition-colors">
<div className="flex-shrink-0 mt-1">{icon}</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-gray-900">{label}</h4>
<p className="text-sm text-gray-600 mt-1">{description}</p>
</div>
<div className="flex-shrink-0">
<Button variant={variant} size="sm" disabled={disabled} onClick={onClick}>
{label}
</Button>
</div>
</div>
);
export function SubscriptionActions({
subscription,
onActionSuccess,
className,
}: SubscriptionActionsProps) {
const router = useRouter();
const [loading, setLoading] = useState<string | null>(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 (
<SubCard
title="Subscription Actions"
icon={<Cog6ToothIcon className="h-5 w-5" />}
className={cn("", className)}
>
<div className="space-y-4">
{/* Service Management Actions */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Service Management</h4>
<div className="space-y-3">
{/* SIM Management - Only for SIM services */}
{isSimService && isActive && (
<ActionButton
icon={<Cog6ToothIcon className="h-5 w-5 text-blue-600" />}
label="SIM Management"
description="Manage data usage, top-up, and SIM settings"
onClick={handleSimManagement}
/>
)}
{/* Service Settings - Available for all active services */}
{isActive && (
<ActionButton
icon={<Cog6ToothIcon className="h-5 w-5 text-gray-600" />}
label="Service Settings"
description="Configure service-specific settings and preferences"
onClick={handleServiceSettings}
/>
)}
{/* Suspend/Resume Actions */}
{isActive && (
<ActionButton
icon={<PauseIcon className="h-5 w-5 text-yellow-600" />}
label="Suspend Service"
description="Temporarily suspend this service"
variant="outline"
onClick={() => {
void handleSuspend();
}}
disabled={loading === "suspend"}
/>
)}
{isSuspended && (
<ActionButton
icon={<PlayIcon className="h-5 w-5 text-green-600" />}
label="Resume Service"
description="Resume suspended service"
variant="outline"
onClick={() => {
void handleResume();
}}
disabled={loading === "resume"}
/>
)}
</div>
</div>
{/* Plan Management Actions */}
{(isActive || isSuspended) && !subscription.cycle.includes("One-time") && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Plan Management</h4>
<div className="space-y-3">
<ActionButton
icon={<ArrowUpIcon className="h-5 w-5 text-green-600" />}
label="Upgrade Plan"
description="Upgrade to a higher tier plan with more features"
onClick={handleUpgrade}
/>
<ActionButton
icon={<ArrowDownIcon className="h-5 w-5 text-blue-600" />}
label="Downgrade Plan"
description="Switch to a lower tier plan"
onClick={handleDowngrade}
/>
</div>
</div>
)}
{/* Billing Actions */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Billing & Payment</h4>
<div className="space-y-3">
<ActionButton
icon={<DocumentTextIcon className="h-5 w-5 text-gray-600" />}
label="View Invoices"
description="View billing history and download invoices"
onClick={handleViewInvoices}
/>
<ActionButton
icon={<CreditCardIcon className="h-5 w-5 text-gray-600" />}
label="Manage Payment"
description="Update payment methods and billing information"
onClick={handleManagePayment}
/>
</div>
</div>
{/* Cancellation Actions */}
{!isCancelled && !isPending && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Cancellation</h4>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 mt-0.5" />
<div className="flex-1">
<h5 className="text-sm font-semibold text-red-900 mb-1">Cancel Subscription</h5>
<p className="text-sm text-red-800 mb-3">
Permanently cancel this subscription. This action cannot be undone and you will
lose access to the service.
</p>
<Button
variant="destructive"
size="sm"
onClick={() => {
void handleCancel();
}}
loading={loading === "cancel"}
loadingText="Cancelling..."
leftIcon={<XMarkIcon className="h-4 w-4" />}
>
Cancel Subscription
</Button>
</div>
</div>
</div>
</div>
)}
{/* Status Information */}
{(isCancelled || isPending) && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center space-x-2">
<ExclamationTriangleIcon className="h-5 w-5 text-gray-600" />
<h5 className="text-sm font-semibold text-gray-900">
{isCancelled ? "Subscription Cancelled" : "Subscription Pending"}
</h5>
</div>
<p className="text-sm text-gray-700 mt-2">
{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."}
</p>
</div>
)}
</div>
</SubCard>
);
}

View File

@ -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<SubscriptionList>({
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<Subscription[]>({
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<Subscription>({
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<Subscription>(response, "Subscription not found");
},
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
@ -143,44 +140,22 @@ export function useSubscriptionInvoices(
return useQuery<InvoiceList>({
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 });
},
});
}

View File

@ -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,

View File

@ -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<typeof createOpenApiClient<paths>>;
type StrictApiClient = ReturnType<typeof createOpenApiClient<paths>>;
type FlexibleApiMethods = {
GET<T = unknown>(path: string, options?: unknown): Promise<{ data?: T | null }>;
POST<T = unknown>(path: string, options?: unknown): Promise<{ data?: T | null }>;
PUT<T = unknown>(path: string, options?: unknown): Promise<{ data?: T | null }>;
PATCH<T = unknown>(path: string, options?: unknown): Promise<{ data?: T | null }>;
DELETE<T = unknown>(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<void>;
}
@ -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<paths>({ 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 };

View File

@ -0,0 +1,44 @@
const DEFAULT_CURRENCY_LOCALE = "en-US";
const currencyLocaleMap: Record<string, string> = {
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);
};

View File

@ -2,4 +2,4 @@
export * from "./validation";
export * from "./array-utils";
export * from "./filters";
export * from "./currency";

View File

@ -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
*/