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; error?: string;
helperText?: string; helperText?: string;
required?: boolean; required?: boolean;
labelProps?: Omit<LabelProps, "htmlFor" | "required">; labelProps?: Omit<LabelProps, "htmlFor">;
fieldId?: string; fieldId?: string;
children?: React.ReactNode; children?: React.ReactNode;
containerClassName?: string;
inputClassName?: string;
} }
const FormField = forwardRef<HTMLInputElement, FormFieldProps>( 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 ref
) => { ) => {
const generatedId = useId(); const generatedId = useId();
@ -29,40 +42,47 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
const helperTextId = helperText ? `${id}-helper` : undefined; const helperTextId = helperText ? `${id}-helper` : undefined;
return ( return (
<div className={cn("space-y-1", className)}> <div className={cn("space-y-1", containerClassName)}>
{label && ( {label && (
<Label <Label
htmlFor={id} htmlFor={id}
required={required} className={cn(
variant={error ? "error" : "default"} "block text-sm font-medium text-gray-700",
{...labelProps} 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> </Label>
)} )}
{children ? ( {children ? (
isValidElement(children) ? ( isValidElement(children)
cloneElement( ? cloneElement(children, {
children as React.ReactElement<any>,
{
id, id,
"aria-invalid": error ? "true" : undefined, "aria-invalid": error ? "true" : undefined,
"aria-describedby": cn(errorId, helperTextId) || undefined, "aria-describedby": cn(errorId, helperTextId) || undefined,
} as any } as Record<string, unknown>)
) : children
) : (
children
)
) : ( ) : (
<Input <Input
id={id} id={id}
ref={ref} ref={ref}
error={error}
aria-invalid={error ? "true" : undefined} aria-invalid={error ? "true" : undefined}
aria-describedby={cn(errorId, helperTextId) || 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 } = const { className, ...rest } = inputProps;
inputProps as Record<string, unknown>;
return rest; 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 { hydrated, hasCheckedAuth, loading } = useAuthStore();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { data: activeSubscriptions } = useActiveSubscriptions(); const activeSubscriptionsQuery = useActiveSubscriptions();
const activeSubscriptions = activeSubscriptionsQuery.data ?? [];
// Initialize with a stable default to avoid hydration mismatch // Initialize with a stable default to avoid hydration mismatch
const [expandedItems, setExpandedItems] = useState<string[]>([]); const [expandedItems, setExpandedItems] = useState<string[]>([]);
@ -71,6 +72,9 @@ export function AppShell({ children }: AppShellProps) {
void (async () => { void (async () => {
try { try {
const prof = await accountService.getProfile(); const prof = await accountService.getProfile();
if (!prof) {
return;
}
useAuthStore.setState(state => ({ useAuthStore.setState(state => ({
...state, ...state,
user: state.user user: state.user
@ -80,7 +84,7 @@ export function AppShell({ children }: AppShellProps) {
lastName: prof.lastName || state.user.lastName, lastName: prof.lastName || state.user.lastName,
phone: prof.phone || state.user.phone, phone: prof.phone || state.user.phone,
} }
: (prof as unknown as typeof state.user), : prof,
})); }));
} catch { } catch {
// best-effort profile hydration; ignore errors // best-effort profile hydration; ignore errors

View File

@ -86,7 +86,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
const { user, isAuthenticated, checkAuth } = useAuthStore(); const { user, isAuthenticated, checkAuth } = useAuthStore();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { data: activeSubscriptions } = useActiveSubscriptions(); const activeSubscriptionsQuery = useActiveSubscriptions();
const activeSubscriptions = activeSubscriptionsQuery.data ?? [];
// Initialize expanded items from localStorage or defaults // Initialize expanded items from localStorage or defaults
const [expandedItems, setExpandedItems] = useState<string[]>(() => { 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 { forwardRef } from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
const buttonVariants = cva( 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", "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 = { type ButtonAsAnchorProps = {
as: "a"; as: "a";
href: string; href: string;
} & AnchorHTMLAttributes<HTMLAnchorElement> & } & AnchorHTMLAttributes<HTMLAnchorElement> &
VariantProps<typeof buttonVariants>; VariantProps<typeof buttonVariants> &
ButtonExtras;
type ButtonAsButtonProps = { type ButtonAsButtonProps = {
as?: "button"; as?: "button";
} & ButtonHTMLAttributes<HTMLButtonElement> & } & ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>; VariantProps<typeof buttonVariants> &
ButtonExtras;
export type ButtonProps = ButtonAsAnchorProps | ButtonAsButtonProps; export type ButtonProps = ButtonAsAnchorProps | ButtonAsButtonProps;
const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((props, ref) => { 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") { 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 ( return (
<a <a
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
href={href} href={href}
ref={ref as React.Ref<HTMLAnchorElement>} ref={ref as React.Ref<HTMLAnchorElement>}
aria-busy={loading || undefined}
{...anchorProps} {...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 ( return (
<button <button
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref as React.Ref<HTMLButtonElement>} ref={ref as React.Ref<HTMLButtonElement>}
disabled={loading || disabled}
aria-busy={loading || undefined}
{...buttonProps} {...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"; Button.displayName = "Button";

View File

@ -7,17 +7,17 @@
export { Button, buttonVariants } from "./button"; export { Button, buttonVariants } from "./button";
export type { ButtonProps } from "./button"; export type { ButtonProps } from "./button";
export { Input, inputVariants } from "./input"; export { Input } from "./input";
export type { InputProps } from "./input"; export type { InputProps } from "./input";
export { Label, labelVariants } from "./label"; export { Label } from "./label";
export type { LabelProps } from "./label"; export type { LabelProps } from "./label";
export { ErrorMessage, errorMessageVariants } from "./error-message"; export { ErrorMessage, errorMessageVariants } from "./error-message";
export type { ErrorMessageProps } from "./error-message"; export type { ErrorMessageProps } from "./error-message";
// Status and Feedback Components // Status and Feedback Components
export { StatusPill, statusPillVariants } from "./status-pill"; export { StatusPill } from "./status-pill";
export type { StatusPillProps } from "./status-pill"; export type { StatusPillProps } from "./status-pill";
export { Badge, badgeVariants } from "./badge"; 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 { forwardRef } from "react";
import { cn } from "@/shared/utils"; 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) => { const Input = forwardRef<HTMLInputElement, InputProps>(
return ( ({ className, type, error, ...props }, ref) => {
<input const isInvalid = Boolean(error);
type={type}
className={cn( return (
"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", <input
className type={type}
)} className={cn(
ref={ref} "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",
{...props} 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"; Input.displayName = "Input";
export { 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"; export type StatusPillProps = HTMLAttributes<HTMLSpanElement> & {
interface StatusPillProps extends HTMLAttributes<HTMLSpanElement> {
label: string; label: string;
variant?: Variant; variant?: "success" | "warning" | "info" | "neutral" | "error";
} size?: "sm" | "md" | "lg";
icon?: ReactNode;
};
export function StatusPill({ export const StatusPill = forwardRef<HTMLSpanElement, StatusPillProps>(
label, ({ label, variant = "neutral", size = "md", icon, className, ...rest }, ref) => {
variant = "neutral",
className = "",
...rest
}: StatusPillProps) {
const tone = const tone =
variant === "success" variant === "success"
? "bg-green-50 text-green-700 ring-green-600/20" ? "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-red-50 text-red-700 ring-red-600/20"
: "bg-gray-50 text-gray-700 ring-gray-400/30"; : "bg-gray-50 text-gray-700 ring-gray-400/30";
return ( const sizing =
<span size === "sm"
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}`} ? "px-2 py-0.5 text-xs"
{...rest} : size === "lg"
> ? "px-4 py-1.5 text-sm"
{label} : "px-3 py-1 text-xs";
</span>
); 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; title?: string;
icon?: ReactNode; icon?: ReactNode;
right?: ReactNode; right?: ReactNode;
header?: ReactNode; // Optional custom header content (overrides title/icon/right layout) header?: ReactNode;
footer?: ReactNode; // Optional footer section separated by a subtle divider footer?: ReactNode;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
headerClassName?: string; headerClassName?: string;
bodyClassName?: string; bodyClassName?: string;
} }
export function SubCard({ export const SubCard = forwardRef<HTMLDivElement, SubCardProps>(
title, (
icon, {
right, title,
header, icon,
footer, right,
children, header,
className = "", footer,
headerClassName = "", children,
bodyClassName = "", className = "",
}: SubCardProps) { headerClassName = "",
return ( bodyClassName = "",
},
ref
) => (
<div <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}`} 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 ? ( {header ? (
@ -47,5 +51,6 @@ export function SubCard({
</div> </div>
) : null} ) : null}
</div> </div>
); )
} );
SubCard.displayName = "SubCard";

View File

@ -1,4 +1,4 @@
/**
* Core API client configuration * Core API client configuration
* Wraps the shared generated client to inject portal-specific behavior like auth headers. * Wraps the shared generated client to inject portal-specific behavior like auth headers.
*/ */
@ -7,16 +7,16 @@ import {
createClient as createOpenApiClient, createClient as createOpenApiClient,
type ApiClient as GeneratedApiClient, type ApiClient as GeneratedApiClient,
type AuthHeaderResolver, type AuthHeaderResolver,
resolveBaseUrl,
} from "@customer-portal/api-client"; } from "@customer-portal/api-client";
import { env } from "../config/env"; import { env } from "../config/env";
const baseUrl = env.NEXT_PUBLIC_API_BASE;
let authHeaderResolver: AuthHeaderResolver | undefined; let authHeaderResolver: AuthHeaderResolver | undefined;
const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.(); const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.();
export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, { export const apiClient: GeneratedApiClient = createOpenApiClient({
baseUrl: resolveBaseUrl(env.NEXT_PUBLIC_API_BASE),
getAuthHeader: resolveAuthHeader, getAuthHeader: resolveAuthHeader,
}); });

View File

@ -3,3 +3,4 @@ export { queryKeys } from "./query-keys";
export type { ApiClient } from "./client"; export type { ApiClient } from "./client";
export type { AuthHeaderResolver } from "@customer-portal/api-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 * 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 = { export const queryKeys = {
// Auth queries auth,
auth: { dashboard,
all: ['auth'] as const, billing,
profile: () => [...queryKeys.auth.all, 'profile'] as const, subscriptions,
}, catalog,
// 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,
},
},
} as const; } 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"; import { z } from "zod";
const EnvSchema = z.object({ 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_NAME: z.string().default("Assist Solutions Portal"),
NEXT_PUBLIC_APP_VERSION: z.string().default("0.1.0"), 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 { useProfileData } from "./useProfileData";
export { useProfileEdit } from "./useProfileEdit"; export { useProfileEdit } from "./useProfileEdit";
export { useAddressEdit } from "./useAddressEdit"; 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 { apiClient } from "@/core/api";
import { getDataOrThrow, getNullableData } from "@/core/api/response-helpers";
import type { Address, AuthUser } from "@customer-portal/domain"; 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 = { type ProfileUpdateInput = {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
@ -16,22 +10,22 @@ type ProfileUpdateInput = {
export const accountService = { export const accountService = {
async getProfile() { async getProfile() {
const response = await apiClient.GET('/me'); const response = await apiClient.GET('/api/me');
return ensureData<AuthUser | null>(response.data); return getNullableData<AuthUser>(response);
}, },
async updateProfile(update: ProfileUpdateInput) { async updateProfile(update: ProfileUpdateInput) {
const response = await apiClient.PATCH('/me', { body: update }); const response = await apiClient.PATCH('/api/me', { body: update });
return ensureData<AuthUser>(response.data); return getDataOrThrow<AuthUser>(response, "Failed to update profile");
}, },
async getAddress() { async getAddress() {
const response = await apiClient.GET('/me/address'); const response = await apiClient.GET('/api/me/address');
return ensureData<Address | null>(response.data); return getNullableData<Address>(response);
}, },
async updateAddress(address: Address) { async updateAddress(address: Address) {
const response = await apiClient.PATCH('/me/address', { body: address }); const response = await apiClient.PATCH('/api/me/address', { body: address });
return ensureData<Address>(response.data); return getDataOrThrow<Address>(response, "Failed to update address");
}, },
}; };

View File

@ -103,10 +103,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
} }
try { try {
const result = await linkWhmcs({ const result = await linkWhmcs(formData.email.trim(), formData.password);
email: formData.email.trim(),
password: formData.password,
});
onTransferred?.({ needsPasswordSet: result.needsPasswordSet, email: formData.email.trim() }); onTransferred?.({ needsPasswordSet: result.needsPasswordSet, email: formData.email.trim() });
} catch (err) { } catch (err) {

View File

@ -10,7 +10,7 @@ import Link from "next/link";
import { Button, Input, ErrorMessage } from "@/components/ui"; import { Button, Input, ErrorMessage } from "@/components/ui";
import { FormField } from "@/components/common/FormField"; import { FormField } from "@/components/common/FormField";
import { usePasswordReset } from "../../hooks/use-auth"; import { usePasswordReset } from "../../hooks/use-auth";
import { useFormValidation, commonValidationSchemas, z } from "@customer-portal/domain"; import { useFormValidation } from "@/core/validation";
// removed unused type imports // removed unused type imports
interface PasswordResetFormProps { interface PasswordResetFormProps {
@ -47,6 +47,7 @@ export function PasswordResetForm({
className = "", className = "",
}: PasswordResetFormProps) { }: PasswordResetFormProps) {
const { requestPasswordReset, resetPassword, loading, error, clearError } = usePasswordReset(); const { requestPasswordReset, resetPassword, loading, error, clearError } = usePasswordReset();
const { validationRules, validateField } = useFormValidation();
const [requestData, setRequestData] = useState<RequestFormData>({ const [requestData, setRequestData] = useState<RequestFormData>({
email: "", email: "",

View File

@ -27,7 +27,7 @@ export function SessionTimeoutWarning({
const expiryTime = Date.parse(tokens.expiresAt); const expiryTime = Date.parse(tokens.expiresAt);
if (Number.isNaN(expiryTime)) { 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; expiryRef.current = null;
setShowWarning(false); setShowWarning(false);
setTimeLeft(0); 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 { Button, Input, ErrorMessage } from "@/components/ui";
import { FormField } from "@/components/common/FormField"; import { FormField } from "@/components/common/FormField";
import { useWhmcsLink } from "../../hooks/use-auth"; import { useWhmcsLink } from "../../hooks/use-auth";
import { useFormValidation, commonValidationSchemas, z } from "@customer-portal/domain"; import { useFormValidation } from "@/core/validation";
interface SetPasswordFormProps { interface SetPasswordFormProps {
email?: string; email?: string;
@ -41,6 +41,7 @@ export function SetPasswordForm({
className = "", className = "",
}: SetPasswordFormProps) { }: SetPasswordFormProps) {
const { setPassword, loading, error, clearError } = useWhmcsLink(); const { setPassword, loading, error, clearError } = useWhmcsLink();
const { validationRules, validateField } = useFormValidation();
const [formData, setFormData] = useState<FormData>({ const [formData, setFormData] = useState<FormData>({
email: initialEmail, email: initialEmail,

View File

@ -4,16 +4,3 @@
*/ */
export { useAuthStore } from "./services/auth.store"; 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 { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware"; import { persist, createJSONStorage } from "zustand/middleware";
import { apiClient, configureApiClientAuth } from "@/core/api"; import { apiClient, configureApiClientAuth } from "@/core/api";
import { getDataOrDefault, getDataOrThrow } from "@/core/api/response-helpers";
import type { import type {
AuthTokens, AuthTokens,
SignupRequest, SignupRequest,
@ -36,6 +37,21 @@ type RawAuthTokens =
| (Omit<AuthTokens, "expiresAt"> & { expiresAt: string | number | Date }) | (Omit<AuthTokens, "expiresAt"> & { expiresAt: string | number | Date })
| LegacyAuthTokens; | 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) => { const toIsoString = (value: string | number | Date | null | undefined) => {
if (value instanceof Date) { if (value instanceof Date) {
@ -169,18 +185,17 @@ export const useAuthStore = create<AuthStoreState>()(
login: async (credentials: LoginRequest) => { login: async (credentials: LoginRequest) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST('/auth/login', { body: credentials }); const response = await apiClient.POST('/api/auth/login', { body: credentials });
if (response.data) { const payload = getDataOrThrow<AuthPayload>(response, 'Failed to login');
const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens); const tokens = normalizeAuthTokens(payload.tokens);
set({ set({
user: response.data.user, user: payload.user,
tokens, tokens,
isAuthenticated: true, isAuthenticated: true,
loading: false, loading: false,
error: null, error: null,
persistSession: Boolean(credentials.rememberMe), persistSession: Boolean(credentials.rememberMe),
}); });
}
} catch (error) { } catch (error) {
const authError = error as AuthError; const authError = error as AuthError;
set({ set({
@ -198,17 +213,16 @@ export const useAuthStore = create<AuthStoreState>()(
signup: async (data: SignupRequest) => { signup: async (data: SignupRequest) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST('/auth/signup', { body: data }); const response = await apiClient.POST('/api/auth/signup', { body: data });
if (response.data) { const payload = getDataOrThrow<AuthPayload>(response, 'Failed to sign up');
const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens); const tokens = normalizeAuthTokens(payload.tokens);
set({ set({
user: response.data.user, user: payload.user,
tokens, tokens,
isAuthenticated: true, isAuthenticated: true,
loading: false, loading: false,
error: null, error: null,
}); });
}
} catch (error) { } catch (error) {
const authError = error as AuthError; const authError = error as AuthError;
set({ set({
@ -228,7 +242,7 @@ export const useAuthStore = create<AuthStoreState>()(
// Call logout API if we have tokens // Call logout API if we have tokens
if (tokens?.accessToken) { if (tokens?.accessToken) {
try { try {
await apiClient.POST('/auth/logout'); await apiClient.POST('/api/auth/logout');
} catch (error) { } catch (error) {
console.warn("Logout API call failed:", error); console.warn("Logout API call failed:", error);
// Continue with local logout even if API call fails // Continue with local logout even if API call fails
@ -247,7 +261,7 @@ export const useAuthStore = create<AuthStoreState>()(
requestPasswordReset: async (data: ForgotPasswordRequest) => { requestPasswordReset: async (data: ForgotPasswordRequest) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
await apiClient.POST('/auth/request-password-reset', { body: data }); await apiClient.POST('/api/auth/request-password-reset', { body: data });
set({ loading: false }); set({ loading: false });
} catch (error) { } catch (error) {
const authError = error as AuthError; const authError = error as AuthError;
@ -259,11 +273,14 @@ export const useAuthStore = create<AuthStoreState>()(
resetPassword: async (data: ResetPasswordRequest) => { resetPassword: async (data: ResetPasswordRequest) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST('/auth/reset-password', { body: data }); const response = await apiClient.POST('/api/auth/reset-password', { body: data });
const { user, tokens } = response.data!; const payload = getDataOrThrow<AuthPayload>(
const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens); response,
'Failed to reset password'
);
const normalizedTokens = normalizeAuthTokens(payload.tokens);
set({ set({
user, user: payload.user,
tokens: normalizedTokens, tokens: normalizedTokens,
isAuthenticated: true, isAuthenticated: true,
loading: false, loading: false,
@ -290,11 +307,14 @@ export const useAuthStore = create<AuthStoreState>()(
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST('/auth/change-password', { body: data }); const response = await apiClient.POST('/api/auth/change-password', { body: data });
const { user, tokens: newTokens } = response.data!; const payload = getDataOrThrow<AuthPayload>(
const normalizedTokens = normalizeAuthTokens(newTokens as RawAuthTokens); response,
'Failed to change password'
);
const normalizedTokens = normalizeAuthTokens(payload.tokens);
set({ set({
user, user: payload.user,
tokens: normalizedTokens, tokens: normalizedTokens,
loading: false, loading: false,
error: null, error: null,
@ -309,8 +329,11 @@ export const useAuthStore = create<AuthStoreState>()(
checkPasswordNeeded: async (email: string) => { checkPasswordNeeded: async (email: string) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST('/auth/check-password-needed', { body: { email } }); const response = await apiClient.POST('/api/auth/check-password-needed', { body: { email } });
const result = response.data!; const result = getDataOrThrow<PasswordCheckPayload>(
response,
'Failed to check password requirements'
);
set({ loading: false }); set({ loading: false });
return result; return result;
} catch (error) { } catch (error) {
@ -323,8 +346,11 @@ export const useAuthStore = create<AuthStoreState>()(
linkWhmcs: async (email: string, password: string) => { linkWhmcs: async (email: string, password: string) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST('/auth/link-whmcs', { body: { email, password } }); const response = await apiClient.POST('/api/auth/link-whmcs', { body: { email, password } });
const result = response.data!; const result = getDataOrThrow<LinkWhmcsPayload>(
response,
'Failed to link WHMCS account'
);
set({ loading: false }); set({ loading: false });
return result; return result;
} catch (error) { } catch (error) {
@ -337,11 +363,11 @@ export const useAuthStore = create<AuthStoreState>()(
setPassword: async (email: string, password: string) => { setPassword: async (email: string, password: string) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST('/auth/set-password', { body: { email, password } }); const response = await apiClient.POST('/api/auth/set-password', { body: { email, password } });
const { user, tokens } = response.data!; const payload = getDataOrThrow<AuthPayload>(response, 'Failed to set password');
const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens); const normalizedTokens = normalizeAuthTokens(payload.tokens);
set({ set({
user, user: payload.user,
tokens: normalizedTokens, tokens: normalizedTokens,
isAuthenticated: true, isAuthenticated: true,
loading: false, loading: false,
@ -377,8 +403,8 @@ export const useAuthStore = create<AuthStoreState>()(
set({ loading: true }); set({ loading: true });
try { try {
const response = await apiClient.GET('/me'); const response = await apiClient.GET('/api/me');
const user = response.data ?? null; const user = getDataOrDefault<AuthUser | null>(response, null);
if (user) { if (user) {
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true }); set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
} else { } else {

View File

@ -44,13 +44,11 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
); );
} }
const formatAmount = (amount: number) => { const formatAmount = (amount: number) =>
return formatCurrency(amount, { formatCurrency(amount, {
currency: summary.currency, currency: summary.currency,
currencySymbol: summary.currencySymbol,
locale: getCurrencyLocale(summary.currency), locale: getCurrencyLocale(summary.currency),
}); });
};
const summaryItems = [ const summaryItems = [
{ {

View File

@ -89,7 +89,6 @@ export function InvoiceTable({
<span className="text-sm font-medium text-gray-900"> <span className="text-sm font-medium text-gray-900">
{formatCurrency(invoice.total, { {formatCurrency(invoice.total, {
currency: invoice.currency, currency: invoice.currency,
currencySymbol: invoice.currencySymbol,
locale: getCurrencyLocale(invoice.currency), locale: getCurrencyLocale(invoice.currency),
})} })}
</span> </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 "./InvoiceStatusBadge";
export * from "./InvoiceItemRow"; export * from "./InvoiceItemRow";
export * from "./PaymentMethodCard";

View File

@ -9,13 +9,12 @@ import {
type UseQueryResult, type UseQueryResult,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { apiClient, queryKeys } from "@/core/api"; import { apiClient, queryKeys } from "@/core/api";
import { getDataOrDefault, getDataOrThrow } from "@/core/api/response-helpers";
import type { InvoiceQueryParams } from "@/core/api/types";
import type { import type {
Invoice, Invoice,
InvoiceList, InvoiceList,
InvoiceQueryParams,
InvoiceSsoLink, InvoiceSsoLink,
InvoicePaymentLink,
PaymentGatewayList,
PaymentMethodList, PaymentMethodList,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
@ -52,15 +51,6 @@ type PaymentMethodsQueryOptions = Omit<
"queryKey" | "queryFn" "queryKey" | "queryFn"
>; >;
type PaymentLinkMutationOptions = UseMutationOptions<
InvoicePaymentLink,
Error,
{
invoiceId: number;
paymentMethodId?: number;
gatewayName?: string;
}
>;
type SsoLinkMutationOptions = UseMutationOptions< type SsoLinkMutationOptions = UseMutationOptions<
InvoiceSsoLink, InvoiceSsoLink,
@ -69,26 +59,18 @@ type SsoLinkMutationOptions = UseMutationOptions<
>; >;
async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> { async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
const response = await apiClient.GET("/invoices", params ? { params: { query: params } } : undefined); const response = await apiClient.GET("/api/invoices", params ? { params: { query: params } } : undefined);
return response.data ?? emptyInvoiceList; return getDataOrDefault(response, emptyInvoiceList);
} }
async function fetchInvoice(id: string): Promise<Invoice> { async function fetchInvoice(id: string): Promise<Invoice> {
const response = await apiClient.GET("/invoices/{id}", { params: { path: { id } } }); const response = await apiClient.GET("/api/invoices/{id}", { params: { path: { id } } });
if (!response.data) { return getDataOrThrow(response, "Invoice not found");
throw new Error("Invoice not found");
}
return response.data;
} }
async function fetchPaymentMethods(): Promise<PaymentMethodList> { async function fetchPaymentMethods(): Promise<PaymentMethodList> {
const response = await apiClient.GET("/invoices/payment-methods"); const response = await apiClient.GET("/api/invoices/payment-methods");
return response.data ?? emptyPaymentMethods; return getDataOrDefault(response, emptyPaymentMethods);
}
async function fetchPaymentGateways(): Promise<PaymentGatewayList> {
const response = await apiClient.GET("/invoices/payment-gateways");
return response.data ?? { gateways: [], totalCount: 0 };
} }
export function useInvoices( 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( export function useCreateInvoiceSsoLink(
options?: SsoLinkMutationOptions options?: SsoLinkMutationOptions
@ -182,16 +116,13 @@ export function useCreateInvoiceSsoLink(
> { > {
return useMutation({ return useMutation({
mutationFn: async ({ invoiceId, target }) => { mutationFn: async ({ invoiceId, target }) => {
const response = await apiClient.POST("/invoices/{id}/sso-link", { const response = await apiClient.POST("/api/invoices/{id}/sso-link", {
params: { params: {
path: { id: invoiceId }, path: { id: invoiceId },
query: target ? { target } : undefined, query: target ? { target } : undefined,
}, },
}); });
if (!response.data) { return getDataOrThrow(response, "Failed to create SSO link");
throw new Error("Failed to create SSO link");
}
return response.data;
}, },
...options, ...options,
}); });

View File

@ -29,7 +29,7 @@ export function usePaymentRefresh<T>({
setToast({ visible: true, text: "Refreshing payment methods...", tone: "info" }); setToast({ visible: true, text: "Refreshing payment methods...", tone: "info" });
try { try {
try { try {
await apiClient.POST("/invoices/payment-methods/refresh"); await apiClient.POST("/api/invoices/payment-methods/refresh");
} catch (err) { } catch (err) {
// Soft-fail cache refresh, still attempt refetch // Soft-fail cache refresh, still attempt refetch
console.warn("Payment methods cache refresh failed:", err); 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 { CreditCardIcon } from "@heroicons/react/24/outline";
import { logger } from "@customer-portal/logging"; import { logger } from "@customer-portal/logging";
import { apiClient } from "@/core/api"; import { apiClient } from "@/core/api";
import { getDataOrThrow } from "@/core/api/response-helpers";
import { openSsoLink } from "@/features/billing/utils/sso"; import { openSsoLink } from "@/features/billing/utils/sso";
import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks"; import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks";
import { import {
@ -27,9 +28,11 @@ export function InvoiceDetailContainer() {
const [loadingPayment, setLoadingPayment] = useState(false); const [loadingPayment, setLoadingPayment] = useState(false);
const [loadingPaymentMethods, setLoadingPaymentMethods] = 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 createSsoLinkMutation = useCreateInvoiceSsoLink();
const { data: invoice, isLoading, error } = useInvoice(invoiceId); const { data: invoice, isLoading, error } = useInvoice(invoiceIdParam ?? "");
const handleCreateSsoLink = (target: "view" | "download" | "pay" = "view") => { const handleCreateSsoLink = (target: "view" | "download" | "pay" = "view") => {
void (async () => { void (async () => {
@ -53,10 +56,13 @@ export function InvoiceDetailContainer() {
void (async () => { void (async () => {
setLoadingPaymentMethods(true); setLoadingPaymentMethods(true);
try { try {
const response = await apiClient.POST('/auth/sso-link', { const response = await apiClient.POST('/api/auth/sso-link', {
body: { path: "index.php?rp=/account/paymentmethods" } 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 }); openSsoLink(sso.url, { newTab: true });
} catch (err) { } catch (err) {
logger.error(err, "Failed to create payment methods SSO link"); logger.error(err, "Failed to create payment methods SSO link");
@ -180,5 +186,3 @@ export function InvoiceDetailContainer() {
} }
export default InvoiceDetailContainer; export default InvoiceDetailContainer;

View File

@ -6,8 +6,9 @@ import { ErrorBoundary } from "@/components/common";
import { SubCard } from "@/components/ui/sub-card"; import { SubCard } from "@/components/ui/sub-card";
import { useSession } from "@/features/auth/hooks"; import { useSession } from "@/features/auth/hooks";
import { useAuthStore } from "@/features/auth/services/auth.store"; import { useAuthStore } from "@/features/auth/services/auth.store";
// ApiClientError import removed - using generic error handling
import { apiClient } from "@/core/api"; import { apiClient } from "@/core/api";
import { getDataOrThrow } from "@/core/api/response-helpers";
import { openSsoLink } from "@/features/billing/utils/sso"; import { openSsoLink } from "@/features/billing/utils/sso";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import { PaymentMethodCard, usePaymentMethods } from "@/features/billing"; import { PaymentMethodCard, usePaymentMethods } from "@/features/billing";
@ -46,25 +47,32 @@ export function PaymentMethodsContainer() {
}); });
const openPaymentMethods = async () => { const openPaymentMethods = async () => {
try { setIsLoading(true);
setIsLoading(true); setError(null);
setError(null);
if (!isAuthenticated) { if (!isAuthenticated) {
setError("Please log in to access payment methods."); 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(false); setIsLoading(false);
} catch (error) { return;
logger.error(error, "Failed to open payment methods"); }
if (error && typeof error === 'object' && 'status' in error && (error as any).status === 401)
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."); 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); setIsLoading(false);
} }
}; };
@ -73,16 +81,22 @@ export function PaymentMethodsContainer() {
// Placeholder hook for future logic when returning from WHMCS // Placeholder hook for future logic when returning from WHMCS
}, [isAuthenticated]); }, [isAuthenticated]);
if (error || paymentMethodsError) { const combinedError = error
const errorObj = ? new Error(error)
error || (paymentMethodsError instanceof Error ? paymentMethodsError : new Error("Unexpected error")); : paymentMethodsError instanceof Error
? paymentMethodsError
: paymentMethodsError
? new Error(String(paymentMethodsError))
: null;
if (combinedError) {
return ( return (
<PageLayout <PageLayout
icon={<CreditCardIcon />} icon={<CreditCardIcon />}
title="Payment Methods" title="Payment Methods"
description="Manage your saved payment methods and billing information" description="Manage your saved payment methods and billing information"
> >
<AsyncBlock error={errorObj} variant="page"> <AsyncBlock error={combinedError} variant="page">
<></> <></>
</AsyncBlock> </AsyncBlock>
</PageLayout> </PageLayout>
@ -97,9 +111,9 @@ export function PaymentMethodsContainer() {
> >
<ErrorBoundary> <ErrorBoundary>
<InlineToast <InlineToast
visible={paymentRefresh.toast.visible} visible={paymentRefresh.toast.visible}
text={paymentRefresh.toast.text} text={paymentRefresh.toast.text}
tone={paymentRefresh.toast.tone} tone={paymentRefresh.toast.tone}
/> />
{/* Simplified: remove verbose banner; controls exist via buttons */} {/* Simplified: remove verbose banner; controls exist via buttons */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@ -147,7 +161,6 @@ export function PaymentMethodsContainer() {
key={paymentMethod.id} key={paymentMethod.id}
paymentMethod={paymentMethod} paymentMethod={paymentMethod}
className="focus:outline-none focus:ring-2 focus:ring-blue-500" className="focus:outline-none focus:ring-2 focus:ring-blue-500"
showActions={false}
/> />
))} ))}
</div> </div>
@ -207,4 +220,3 @@ export function PaymentMethodsContainer() {
} }
export default PaymentMethodsContainer; export default PaymentMethodsContainer;

View File

@ -24,7 +24,7 @@ interface BillingInfo {
company: string | null; company: string | null;
email: string; email: string;
phone: string | null; phone: string | null;
address: Address; address: Address | null;
isComplete: boolean; isComplete: boolean;
} }
@ -54,32 +54,33 @@ export function AddressConfirmation({
const fetchBillingInfo = useCallback(async () => { const fetchBillingInfo = useCallback(async () => {
try { try {
setLoading(true); 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({ setBillingInfo({
company: null, company: null,
email: "", email: "",
phone: null, phone: null,
address: data, address: data,
isComplete: !!( isComplete,
data &&
data.street &&
data.city &&
data.state &&
data.postalCode &&
data.country
),
}); });
// Since address is required at signup, it should always be complete
// But we still need verification for Internet orders
if (requiresAddressVerification) { if (requiresAddressVerification) {
// For Internet orders, don't auto-confirm - require explicit verification
setAddressConfirmed(false); setAddressConfirmed(false);
onAddressIncomplete(); // Keep disabled until explicitly confirmed onAddressIncomplete();
} else { } else if (isComplete && data) {
// For other order types, auto-confirm since address exists from signup onAddressConfirmed(data);
if (data) onAddressConfirmed(data);
setAddressConfirmed(true); setAddressConfirmed(true);
} else {
onAddressIncomplete();
setAddressConfirmed(false);
} }
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load address"); setError(err instanceof Error ? err.message : "Failed to load address");
@ -99,7 +100,7 @@ export function AddressConfirmation({
setEditing(true); setEditing(true);
setEditedAddress( setEditedAddress(
billingInfo?.address || { billingInfo?.address ?? {
street: "", street: "",
streetLine2: "", streetLine2: "",
city: "", city: "",
@ -134,17 +135,17 @@ export function AddressConfirmation({
try { try {
setError(null); setError(null);
// Build minimal PATCH payload with only provided fields const sanitizedAddress: Address = {
const payload: Record<string, string> = {}; street: editedAddress.street?.trim() || null,
if (editedAddress.street) payload.street = editedAddress.street; streetLine2: editedAddress.streetLine2?.trim() || null,
if (editedAddress.streetLine2) payload.streetLine2 = editedAddress.streetLine2; city: editedAddress.city?.trim() || null,
if (editedAddress.city) payload.city = editedAddress.city; state: editedAddress.state?.trim() || null,
if (editedAddress.state) payload.state = editedAddress.state; postalCode: editedAddress.postalCode?.trim() || null,
if (editedAddress.postalCode) payload.postalCode = editedAddress.postalCode; country: editedAddress.country?.trim() || null,
if (editedAddress.country) payload.country = editedAddress.country; };
// Persist to server (WHMCS via BFF) // Persist to server (WHMCS via BFF)
const updatedAddress = await accountService.updateAddress(payload); const updatedAddress = await accountService.updateAddress(sanitizedAddress);
// Rebuild BillingInfo from updated address // Rebuild BillingInfo from updated address
const updatedInfo: BillingInfo = { const updatedInfo: BillingInfo = {
@ -234,6 +235,8 @@ export function AddressConfirmation({
if (!billingInfo) return null; if (!billingInfo) return null;
const address = billingInfo.address;
return wrap( return wrap(
<> <>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@ -379,18 +382,18 @@ export function AddressConfirmation({
</div> </div>
) : ( ) : (
<div> <div>
{billingInfo.address.street ? ( {address?.street ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="text-gray-900 space-y-1"> <div className="text-gray-900 space-y-1">
<p className="font-semibold text-base">{billingInfo.address.street}</p> <p className="font-semibold text-base">{address.street}</p>
{billingInfo.address.streetLine2 && ( {address.streetLine2 ? (
<p className="text-gray-700">{billingInfo.address.streetLine2}</p> <p className="text-gray-700">{address.streetLine2}</p>
)} ) : null}
<p className="text-gray-700"> <p className="text-gray-700">
{billingInfo.address.city}, {billingInfo.address.state}{" "} {[address.city, address.state].filter(Boolean).join(", ")}
{billingInfo.address.postalCode} {address.postalCode ? ` ${address.postalCode}` : ""}
</p> </p>
<p className="text-gray-600">{billingInfo.address.country}</p> <p className="text-gray-600">{address.country}</p>
</div> </div>
{/* Status message for Internet orders when pending */} {/* Status message for Internet orders when pending */}

View File

@ -9,6 +9,7 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/ui"; import { AnimatedCard } from "@/components/ui";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
// Use consolidated domain types // Use consolidated domain types
import type { import type {
@ -105,6 +106,7 @@ export function EnhancedOrderSummary({
headerContent, headerContent,
footerContent, footerContent,
}: EnhancedOrderSummaryProps) { }: EnhancedOrderSummaryProps) {
const router = useRouter();
const sizeClasses = { const sizeClasses = {
compact: "p-4", compact: "p-4",
standard: "p-6", standard: "p-6",
@ -335,11 +337,13 @@ export function EnhancedOrderSummary({
<div className="flex gap-4"> <div className="flex gap-4">
{backUrl ? ( {backUrl ? (
<Button <Button
as="a"
href={backUrl}
variant="outline" variant="outline"
className="flex-1 group" className="flex-1 group"
disabled={disabled || loading} 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" /> <ArrowLeftIcon className="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
{backLabel} {backLabel}

View File

@ -1,44 +1,25 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react";
import { Skeleton } from "@/components/ui/loading-skeleton"; import { Skeleton } from "@/components/ui/loading-skeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AlertBanner } from "@/components/common/AlertBanner"; import { AlertBanner } from "@/components/common/AlertBanner";
import { CreditCardIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { useState, useEffect } from "react";
import {
CreditCardIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
// Use domain PaymentMethod type
import type { PaymentMethod } from "@customer-portal/domain"; import type { PaymentMethod } from "@customer-portal/domain";
export interface PaymentFormProps { export interface PaymentFormProps {
// Payment methods
existingMethods?: PaymentMethod[]; existingMethods?: PaymentMethod[];
selectedMethodId?: string; selectedMethodId?: string;
// Callbacks
onMethodSelect?: (methodId: string) => void; onMethodSelect?: (methodId: string) => void;
onAddNewMethod?: () => void; onAddNewMethod?: () => void;
onValidationChange?: (isValid: boolean, errors: string[]) => void; onValidationChange?: (isValid: boolean, errors: string[]) => void;
// Configuration
title?: string; title?: string;
description?: string; description?: string;
showTitle?: boolean; showTitle?: boolean;
allowNewMethod?: boolean; allowNewMethod?: boolean;
requirePaymentMethod?: boolean; requirePaymentMethod?: boolean;
// State
loading?: boolean; loading?: boolean;
disabled?: boolean; disabled?: boolean;
// Styling
variant?: "default" | "compact" | "inline";
// Custom content
children?: React.ReactNode; children?: React.ReactNode;
footerContent?: React.ReactNode; footerContent?: React.ReactNode;
} }
@ -56,77 +37,41 @@ export function PaymentForm({
requirePaymentMethod = true, requirePaymentMethod = true,
loading = false, loading = false,
disabled = false, disabled = false,
variant = "default",
children, children,
footerContent, footerContent,
}: PaymentFormProps) { }: PaymentFormProps) {
const [selectedMethod, setSelectedMethod] = useState<string>(selectedMethodId || ""); const [selectedMethod, setSelectedMethod] = useState<string>(selectedMethodId ?? "");
const [errors, setErrors] = useState<string[]>([]); 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(() => { useEffect(() => {
if (selectedMethodId !== undefined) { if (selectedMethodId !== undefined) {
setSelectedMethod(selectedMethodId); setSelectedMethod(selectedMethodId);
} }
}, [selectedMethodId]); }, [selectedMethodId]);
const getCardBrandIcon = (brand?: string) => { useEffect(() => {
// In a real implementation, you'd return appropriate brand icons const validationErrors: string[] = [];
return <CreditCardIcon className="h-6 w-6 text-gray-400" />;
};
const formatCardNumber = (last4?: string) => { if (requirePaymentMethod) {
return last4 ? `•••• •••• •••• ${last4}` : "•••• •••• •••• ••••"; 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) => { setErrors(validationErrors);
if (!month || !year) return ""; onValidationChange?.(validationErrors.length === 0, validationErrors);
return `${month.toString().padStart(2, "0")}/${year.toString().slice(-2)}`; }, [existingMethods, requirePaymentMethod, selectedMethod, onValidationChange]);
};
const containerClasses = const methods = useMemo(() => existingMethods, [existingMethods]);
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";
if (loading) { if (loading) {
return ( return (
<div className={containerClasses}> <div className="p-6 bg-white border border-gray-200 rounded-lg">
<div className="space-y-4"> <div className="space-y-4">
{Array.from({ length: 2 }).map((_, i) => ( {Array.from({ length: 2 }).map((_, index) => (
<div key={i} className="flex items-center justify-between"> <div key={index} className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-8 w-12" /> <Skeleton className="h-8 w-12" />
<div className="space-y-2"> <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 ( return (
<div className={containerClasses}> <div className="p-6 bg-white border border-gray-200 rounded-lg space-y-4">
{showTitle && ( {showTitle && (
<div className="mb-6"> <div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-1">
<CreditCardIcon className="h-5 w-5 text-blue-600" /> <CreditCardIcon className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">{title}</h3> <h3 className="text-lg font-semibold text-gray-900">{title}</h3>
</div> </div>
{description && <p className="text-sm text-gray-600">{description}</p>} {description ? <p className="text-sm text-gray-600">{description}</p> : null}
</div> </div>
)} )}
{/* No payment methods */} {methods.length === 0 ? (
{existingMethods.length === 0 ? ( <div className="py-8 text-center">
<div className="text-center py-8"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4"> <CreditCardIcon className="h-6 w-6 text-gray-400" />
<CreditCardIcon className="h-8 w-8 text-gray-400" />
</div> </div>
<h4 className="text-lg font-medium text-gray-900 mb-2">No Payment Method on File</h4> <p className="text-sm text-gray-600 mb-4">Add a payment method to continue.</p>
<p className="text-gray-600 mb-6">Add a payment method to complete your order.</p> {allowNewMethod && onAddNewMethod ? (
{allowNewMethod && onAddNewMethod && (
<Button onClick={onAddNewMethod} disabled={disabled}> <Button onClick={onAddNewMethod} disabled={disabled}>
Add Payment Method Add Payment Method
</Button> </Button>
)} ) : null}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-3">
{/* Existing payment methods */} {methods.map(renderMethod)}
<div className="space-y-3"> {allowNewMethod && onAddNewMethod ? (
{existingMethods.map(method => ( <div className="pt-3">
<label <Button
key={method.id} variant="outline"
className={`flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all ${ className="w-full"
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
onClick={onAddNewMethod} onClick={onAddNewMethod}
disabled={disabled} 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" /> Add Another Method
<span className="font-medium">Add New Payment Method</span> </Button>
</button>
</div> </div>
)} ) : null}
</div> </div>
)} )}
{/* Custom content */} {children}
{children && <div className="mt-6">{children}</div>}
{/* Validation errors */} {errors.length > 0 ? (
{errors.length > 0 && ( <AlertBanner variant="error" title="Payment Required" elevated size="sm">
<AlertBanner variant="error" title="Payment Required" className="mt-4" elevated size="sm">
<ul className="list-disc list-inside"> <ul className="list-disc list-inside">
{errors.map((error, index) => ( {errors.map((msg, index) => (
<li key={index}>{error}</li> <li key={index}>{msg}</li>
))} ))}
</ul> </ul>
</AlertBanner> </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 ? (
{footerContent && <div className="mt-6 pt-4 border-t border-gray-200">{footerContent}</div>} <div className="pt-4 border-t border-gray-200">{footerContent}</div>
) : null}
</div> </div>
); );
} }

View File

@ -1,44 +1,45 @@
import { apiClient } from "@/core/api"; import { apiClient } from "@/core/api";
import { getDataOrDefault } from "@/core/api/response-helpers";
import type { InternetProduct, SimProduct, VpnProduct } from "@customer-portal/domain"; import type { InternetProduct, SimProduct, VpnProduct } from "@customer-portal/domain";
async function getInternetPlans(): Promise<InternetProduct[]> { async function getInternetPlans(): Promise<InternetProduct[]> {
const response = await apiClient.GET("/catalog/internet/plans"); const response = await apiClient.GET("/api/catalog/internet/plans");
return response.data ?? []; return getDataOrDefault(response, [] as InternetProduct[]);
} }
async function getInternetInstallations(): Promise<InternetProduct[]> { async function getInternetInstallations(): Promise<InternetProduct[]> {
const response = await apiClient.GET("/catalog/internet/installations"); const response = await apiClient.GET("/api/catalog/internet/installations");
return response.data ?? []; return getDataOrDefault(response, [] as InternetProduct[]);
} }
async function getInternetAddons(): Promise<InternetProduct[]> { async function getInternetAddons(): Promise<InternetProduct[]> {
const response = await apiClient.GET("/catalog/internet/addons"); const response = await apiClient.GET("/api/catalog/internet/addons");
return response.data ?? []; return getDataOrDefault(response, [] as InternetProduct[]);
} }
async function getSimPlans(): Promise<SimProduct[]> { async function getSimPlans(): Promise<SimProduct[]> {
const response = await apiClient.GET("/catalog/sim/plans"); const response = await apiClient.GET("/api/catalog/sim/plans");
return response.data ?? []; return getDataOrDefault(response, [] as SimProduct[]);
} }
async function getSimActivationFees(): Promise<SimProduct[]> { async function getSimActivationFees(): Promise<SimProduct[]> {
const response = await apiClient.GET("/catalog/sim/activation-fees"); const response = await apiClient.GET("/api/catalog/sim/activation-fees");
return response.data ?? []; return getDataOrDefault(response, [] as SimProduct[]);
} }
async function getSimAddons(): Promise<SimProduct[]> { async function getSimAddons(): Promise<SimProduct[]> {
const response = await apiClient.GET("/catalog/sim/addons"); const response = await apiClient.GET("/api/catalog/sim/addons");
return response.data ?? []; return getDataOrDefault(response, [] as SimProduct[]);
} }
async function getVpnPlans(): Promise<VpnProduct[]> { async function getVpnPlans(): Promise<VpnProduct[]> {
const response = await apiClient.GET("/catalog/vpn/plans"); const response = await apiClient.GET("/api/catalog/vpn/plans");
return response.data ?? []; return getDataOrDefault(response, [] as VpnProduct[]);
} }
async function getVpnActivationFees(): Promise<VpnProduct[]> { async function getVpnActivationFees(): Promise<VpnProduct[]> {
const response = await apiClient.GET("/catalog/vpn/activation-fees"); const response = await apiClient.GET("/api/catalog/vpn/activation-fees");
return response.data ?? []; return getDataOrDefault(response, [] as VpnProduct[]);
} }
export const catalogService = { 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 * 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 { useAuthStore } from "@/features/auth/services/auth.store";
import { apiClient } from "@/core/api"; import { apiClient, queryKeys } from "@/core/api";
import type { DashboardSummary, DashboardError } from "@customer-portal/domain"; import { getDataOrThrow } from "@/core/api/response-helpers";
import type {
DashboardSummary,
DashboardError,
} from "@customer-portal/domain";
class DashboardDataError extends Error { class DashboardDataError extends Error {
constructor( 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 * Hook for fetching dashboard summary data
@ -35,7 +31,7 @@ export function useDashboardSummary() {
const { isAuthenticated, tokens } = useAuthStore(); const { isAuthenticated, tokens } = useAuthStore();
return useQuery<DashboardSummary, DashboardError>({ return useQuery<DashboardSummary, DashboardError>({
queryKey: dashboardQueryKeys.summary(), queryKey: queryKeys.dashboard.summary(),
queryFn: async () => { queryFn: async () => {
if (!tokens?.accessToken) { if (!tokens?.accessToken) {
throw new DashboardDataError( throw new DashboardDataError(
@ -45,8 +41,11 @@ export function useDashboardSummary() {
} }
try { try {
const response = await apiClient.GET('/dashboard/summary'); const response = await apiClient.GET('/api/dashboard/summary');
return response.data!; return getDataOrThrow<DashboardSummary>(
response,
"Dashboard summary response was empty"
);
} catch (error) { } catch (error) {
// Transform API errors to DashboardError format // Transform API errors to DashboardError format
if (error instanceof Error) { 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> { 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) { if (!response.data) {
throw new Error("Order creation failed"); 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> { 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; return (response.data ?? []) as T;
} }
async function getOrderById<T = unknown>(orderId: string): Promise<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 } }, params: { path: { sfOrderId: orderId } },
}); });
if (!response.data) { if (!response.data) {

View File

@ -42,7 +42,7 @@ export function ChangePlanModal({
} }
setLoading(true); setLoading(true);
try { try {
await apiClient.POST("/subscriptions/{id}/sim/change-plan", { await apiClient.POST("/api/subscriptions/{id}/sim/change-plan", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
body: { body: {
newPlanCode, newPlanCode,

View File

@ -58,7 +58,7 @@ export function SimActions({
setError(null); setError(null);
try { try {
await apiClient.POST("/subscriptions/{id}/sim/reissue-esim", { await apiClient.POST("/api/subscriptions/{id}/sim/reissue-esim", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
}); });
@ -77,7 +77,7 @@ export function SimActions({
setError(null); setError(null);
try { try {
await apiClient.POST("/subscriptions/{id}/sim/cancel", { await apiClient.POST("/api/subscriptions/{id}/sim/cancel", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
}); });

View File

@ -75,7 +75,7 @@ export function SimFeatureToggles({
if (nt !== initial.nt) featurePayload.networkType = nt; if (nt !== initial.nt) featurePayload.networkType = nt;
if (Object.keys(featurePayload).length > 0) { 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 } }, params: { path: { id: subscriptionId } },
body: featurePayload, body: featurePayload,
}); });

View File

@ -30,7 +30,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
try { try {
setError(null); setError(null);
const response = await apiClient.GET("/subscriptions/{id}/sim", { const response = await apiClient.GET("/api/subscriptions/{id}/sim", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
}); });

View File

@ -45,7 +45,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
quotaMb: getCurrentAmountMb(), quotaMb: getCurrentAmountMb(),
}; };
await apiClient.POST("/subscriptions/{id}/sim/top-up", { await apiClient.POST("/api/subscriptions/{id}/sim/top-up", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
body: requestBody, 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 * 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 { apiClient, queryKeys } from "@/core/api";
import { getDataOrDefault, getDataOrThrow, getNullableData } from "@/core/api/response-helpers";
import { useAuthSession } from "@/features/auth/services/auth.store"; import { useAuthSession } from "@/features/auth/services/auth.store";
import type { InvoiceList, Subscription, SubscriptionList } from "@customer-portal/domain"; import type { InvoiceList, Subscription, SubscriptionList } from "@customer-portal/domain";
@ -57,13 +58,12 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
const { isAuthenticated, hasValidToken } = useAuthSession(); const { isAuthenticated, hasValidToken } = useAuthSession();
return useQuery<SubscriptionList>({ return useQuery<SubscriptionList>({
queryKey: queryKeys.subscriptions.list(status ? { status } : {}), queryKey: queryKeys.subscriptions.list(status ? { status } : undefined),
queryFn: async () => { queryFn: async () => {
const response = await apiClient.GET( const response = await apiClient.GET("/api/subscriptions",
"/subscriptions",
status ? { params: { query: { status } } } : undefined status ? { params: { query: { status } } } : undefined
); );
return toSubscriptionList(response.data); return toSubscriptionList(getNullableData(response));
}, },
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000, gcTime: 10 * 60 * 1000,
@ -78,10 +78,10 @@ export function useActiveSubscriptions() {
const { isAuthenticated, hasValidToken } = useAuthSession(); const { isAuthenticated, hasValidToken } = useAuthSession();
return useQuery<Subscription[]>({ return useQuery<Subscription[]>({
queryKey: [...queryKeys.subscriptions.all, "active"] as const, queryKey: queryKeys.subscriptions.active(),
queryFn: async () => { queryFn: async () => {
const response = await apiClient.GET("/subscriptions/active"); const response = await apiClient.GET("/api/subscriptions/active");
return response.data ?? []; return getDataOrDefault(response, [] as Subscription[]);
}, },
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000, gcTime: 10 * 60 * 1000,
@ -98,8 +98,8 @@ export function useSubscriptionStats() {
return useQuery({ return useQuery({
queryKey: queryKeys.subscriptions.stats(), queryKey: queryKeys.subscriptions.stats(),
queryFn: async () => { queryFn: async () => {
const response = await apiClient.GET("/subscriptions/stats"); const response = await apiClient.GET("/api/subscriptions/stats");
return response.data ?? emptyStats; return getDataOrDefault(response, emptyStats);
}, },
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000, gcTime: 10 * 60 * 1000,
@ -116,13 +116,10 @@ export function useSubscription(subscriptionId: number) {
return useQuery<Subscription>({ return useQuery<Subscription>({
queryKey: queryKeys.subscriptions.detail(String(subscriptionId)), queryKey: queryKeys.subscriptions.detail(String(subscriptionId)),
queryFn: async () => { queryFn: async () => {
const response = await apiClient.GET("/subscriptions/{id}", { const response = await apiClient.GET("/api/subscriptions/{id}", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
}); });
if (!response.data) { return getDataOrThrow<Subscription>(response, "Subscription not found");
throw new Error("Subscription not found");
}
return response.data;
}, },
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000, gcTime: 10 * 60 * 1000,
@ -143,44 +140,22 @@ export function useSubscriptionInvoices(
return useQuery<InvoiceList>({ return useQuery<InvoiceList>({
queryKey: queryKeys.subscriptions.invoices(subscriptionId, { page, limit }), queryKey: queryKeys.subscriptions.invoices(subscriptionId, { page, limit }),
queryFn: async () => { queryFn: async () => {
const response = await apiClient.GET("/subscriptions/{id}/invoices", { const response = await apiClient.GET("/api/subscriptions/{id}/invoices", {
params: { params: {
path: { id: subscriptionId }, path: { id: subscriptionId },
query: { page, limit }, query: { page, limit },
}, },
}); });
if (!response.data) { return getDataOrDefault(response, {
return { ...emptyInvoiceList,
...emptyInvoiceList, pagination: {
pagination: { ...emptyInvoiceList.pagination,
...emptyInvoiceList.pagination, page,
page, },
}, });
};
}
return response.data;
}, },
staleTime: 60 * 1000, staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000,
enabled: isAuthenticated && hasValidToken && subscriptionId > 0, 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 * as ApiTypes from "./__generated__/types";
export { createClient } from "./runtime/client"; export { createClient, resolveBaseUrl } from "./runtime/client";
export type { export type {
ApiClient, ApiClient,
AuthHeaderResolver, AuthHeaderResolver,

View File

@ -1,6 +1,9 @@
import createOpenApiClient from "openapi-fetch"; import createOpenApiClient from "openapi-fetch";
import type {
Middleware,
MiddlewareCallbackParams,
} from "openapi-fetch";
import type { paths } from "../__generated__/types"; import type { paths } from "../__generated__/types";
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
message: string, 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; 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 { export interface CreateClientOptions {
baseUrl?: string;
getAuthHeader?: AuthHeaderResolver; getAuthHeader?: AuthHeaderResolver;
handleError?: (response: Response) => void | Promise<void>; handleError?: (response: Response) => void | Promise<void>;
} }
@ -49,10 +113,8 @@ async function defaultHandleError(response: Response) {
throw new ApiError(message, response, body); throw new ApiError(message, response, body);
} }
export function createClient( export function createClient(options: CreateClientOptions = {}): ApiClient {
baseUrl: string, const baseUrl = resolveBaseUrl(options.baseUrl);
options: CreateClientOptions = {}
): ApiClient {
const client = createOpenApiClient<paths>({ baseUrl }); const client = createOpenApiClient<paths>({ baseUrl });
const handleError = options.handleError ?? defaultHandleError; const handleError = options.handleError ?? defaultHandleError;
@ -60,8 +122,8 @@ export function createClient(
if (typeof client.use === "function") { if (typeof client.use === "function") {
const resolveAuthHeader = options.getAuthHeader; const resolveAuthHeader = options.getAuthHeader;
client.use({ const middleware: Middleware = {
onRequest({ request }) { onRequest({ request }: MiddlewareCallbackParams) {
if (!resolveAuthHeader) return; if (!resolveAuthHeader) return;
if (!request || typeof request.headers?.has !== "function") return; if (!request || typeof request.headers?.has !== "function") return;
if (request.headers.has("Authorization")) return; if (request.headers.has("Authorization")) return;
@ -71,13 +133,15 @@ export function createClient(
request.headers.set("Authorization", headerValue); request.headers.set("Authorization", headerValue);
}, },
async onResponse({ response }) { async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) {
await handleError(response); await handleError(response);
}, },
} as never); };
client.use(middleware as never);
} }
return client; return client as ApiClient;
} }
export type { paths }; 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 "./validation";
export * from "./array-utils"; export * from "./array-utils";
export * from "./filters"; export * from "./filters";
export * from "./currency";

View File

@ -103,16 +103,6 @@ export function sanitizeString(input: string): string {
.trim(); .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 * Formats date to locale string
*/ */