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:
parent
06009bd2d5
commit
54fb396557
@ -13,14 +13,27 @@ interface FormFieldProps
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
required?: boolean;
|
||||
labelProps?: Omit<LabelProps, "htmlFor" | "required">;
|
||||
labelProps?: Omit<LabelProps, "htmlFor">;
|
||||
fieldId?: string;
|
||||
children?: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
inputClassName?: string;
|
||||
}
|
||||
|
||||
const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
||||
(
|
||||
{ label, error, helperText, required, labelProps, fieldId, className, children, ...inputProps },
|
||||
{
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
required,
|
||||
labelProps,
|
||||
fieldId,
|
||||
containerClassName,
|
||||
inputClassName,
|
||||
children,
|
||||
...inputProps
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const generatedId = useId();
|
||||
@ -29,40 +42,47 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
||||
const helperTextId = helperText ? `${id}-helper` : undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<div className={cn("space-y-1", containerClassName)}>
|
||||
{label && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
required={required}
|
||||
variant={error ? "error" : "default"}
|
||||
{...labelProps}
|
||||
className={cn(
|
||||
"block text-sm font-medium text-gray-700",
|
||||
error && "text-red-600",
|
||||
labelProps?.className
|
||||
)}
|
||||
{...(labelProps ? { ...labelProps, className: undefined } : undefined)}
|
||||
>
|
||||
{label}
|
||||
<span>{label}</span>
|
||||
{required ? (
|
||||
<span aria-hidden="true" className="ml-1 text-red-600">
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
)}
|
||||
{children ? (
|
||||
isValidElement(children) ? (
|
||||
cloneElement(
|
||||
children as React.ReactElement<any>,
|
||||
{
|
||||
isValidElement(children)
|
||||
? cloneElement(children, {
|
||||
id,
|
||||
"aria-invalid": error ? "true" : undefined,
|
||||
"aria-describedby": cn(errorId, helperTextId) || undefined,
|
||||
} as any
|
||||
)
|
||||
) : (
|
||||
children
|
||||
)
|
||||
} as Record<string, unknown>)
|
||||
: children
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
ref={ref}
|
||||
error={error}
|
||||
aria-invalid={error ? "true" : undefined}
|
||||
aria-describedby={cn(errorId, helperTextId) || undefined}
|
||||
className={cn(
|
||||
error &&
|
||||
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
|
||||
inputClassName,
|
||||
inputProps.className
|
||||
)}
|
||||
{...(() => {
|
||||
const { children: _children, dangerouslySetInnerHTML: _dangerouslySetInnerHTML, ...rest } =
|
||||
inputProps as Record<string, unknown>;
|
||||
const { className, ...rest } = inputProps;
|
||||
return rest;
|
||||
})()}
|
||||
/>
|
||||
|
||||
@ -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}</>;
|
||||
}
|
||||
@ -21,7 +21,8 @@ export function AppShell({ children }: AppShellProps) {
|
||||
const { hydrated, hasCheckedAuth, loading } = useAuthStore();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { data: activeSubscriptions } = useActiveSubscriptions();
|
||||
const activeSubscriptionsQuery = useActiveSubscriptions();
|
||||
const activeSubscriptions = activeSubscriptionsQuery.data ?? [];
|
||||
|
||||
// Initialize with a stable default to avoid hydration mismatch
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||
@ -71,6 +72,9 @@ export function AppShell({ children }: AppShellProps) {
|
||||
void (async () => {
|
||||
try {
|
||||
const prof = await accountService.getProfile();
|
||||
if (!prof) {
|
||||
return;
|
||||
}
|
||||
useAuthStore.setState(state => ({
|
||||
...state,
|
||||
user: state.user
|
||||
@ -80,7 +84,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
lastName: prof.lastName || state.user.lastName,
|
||||
phone: prof.phone || state.user.phone,
|
||||
}
|
||||
: (prof as unknown as typeof state.user),
|
||||
: prof,
|
||||
}));
|
||||
} catch {
|
||||
// best-effort profile hydration; ignore errors
|
||||
|
||||
@ -86,7 +86,8 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const { user, isAuthenticated, checkAuth } = useAuthStore();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { data: activeSubscriptions } = useActiveSubscriptions();
|
||||
const activeSubscriptionsQuery = useActiveSubscriptions();
|
||||
const activeSubscriptions = activeSubscriptionsQuery.data ?? [];
|
||||
|
||||
// Initialize expanded items from localStorage or defaults
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>(() => {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from "react";
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/shared/utils";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
|
||||
@ -28,39 +29,76 @@ const buttonVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
interface ButtonExtras {
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
loading?: boolean;
|
||||
isLoading?: boolean;
|
||||
loadingText?: ReactNode;
|
||||
}
|
||||
|
||||
type ButtonAsAnchorProps = {
|
||||
as: "a";
|
||||
href: string;
|
||||
} & AnchorHTMLAttributes<HTMLAnchorElement> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
VariantProps<typeof buttonVariants> &
|
||||
ButtonExtras;
|
||||
|
||||
type ButtonAsButtonProps = {
|
||||
as?: "button";
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
VariantProps<typeof buttonVariants> &
|
||||
ButtonExtras;
|
||||
|
||||
export type ButtonProps = ButtonAsAnchorProps | ButtonAsButtonProps;
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((props, ref) => {
|
||||
const {
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
loading: loadingProp,
|
||||
isLoading,
|
||||
loadingText,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const loading = loadingProp ?? isLoading ?? false;
|
||||
|
||||
if (props.as === "a") {
|
||||
const { className, variant, size, as: _as, href, ...anchorProps } = props;
|
||||
const { className, variant, size, as: _as, href, ...anchorProps } = rest as ButtonAsAnchorProps;
|
||||
return (
|
||||
<a
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
href={href}
|
||||
ref={ref as React.Ref<HTMLAnchorElement>}
|
||||
aria-busy={loading || undefined}
|
||||
{...anchorProps}
|
||||
/>
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{loading ? <LoadingSpinner size="sm" /> : leftIcon}
|
||||
<span>{loading ? loadingText ?? children : children}</span>
|
||||
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const { className, variant, size, as: _as, ...buttonProps } = props;
|
||||
const { className, variant, size, as: _as, disabled, ...buttonProps } = rest as ButtonAsButtonProps;
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
disabled={loading || disabled}
|
||||
aria-busy={loading || undefined}
|
||||
{...buttonProps}
|
||||
/>
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{loading ? <LoadingSpinner size="sm" /> : leftIcon}
|
||||
<span>{loading ? loadingText ?? children : children}</span>
|
||||
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
Button.displayName = "Button";
|
||||
|
||||
@ -7,17 +7,17 @@
|
||||
export { Button, buttonVariants } from "./button";
|
||||
export type { ButtonProps } from "./button";
|
||||
|
||||
export { Input, inputVariants } from "./input";
|
||||
export { Input } from "./input";
|
||||
export type { InputProps } from "./input";
|
||||
|
||||
export { Label, labelVariants } from "./label";
|
||||
export { Label } from "./label";
|
||||
export type { LabelProps } from "./label";
|
||||
|
||||
export { ErrorMessage, errorMessageVariants } from "./error-message";
|
||||
export type { ErrorMessageProps } from "./error-message";
|
||||
|
||||
// Status and Feedback Components
|
||||
export { StatusPill, statusPillVariants } from "./status-pill";
|
||||
export { StatusPill } from "./status-pill";
|
||||
export type { StatusPillProps } from "./status-pill";
|
||||
|
||||
export { Badge, badgeVariants } from "./badge";
|
||||
|
||||
@ -1,22 +1,31 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
export type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: ReactNode;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, error, ...props }, ref) => {
|
||||
const isInvalid = Boolean(error);
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
isInvalid &&
|
||||
"border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
aria-invalid={isInvalid || undefined}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils";
|
||||
|
||||
type Variant = "success" | "warning" | "info" | "neutral" | "error";
|
||||
|
||||
interface StatusPillProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
export type StatusPillProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
label: string;
|
||||
variant?: Variant;
|
||||
}
|
||||
variant?: "success" | "warning" | "info" | "neutral" | "error";
|
||||
size?: "sm" | "md" | "lg";
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export function StatusPill({
|
||||
label,
|
||||
variant = "neutral",
|
||||
className = "",
|
||||
...rest
|
||||
}: StatusPillProps) {
|
||||
export const StatusPill = forwardRef<HTMLSpanElement, StatusPillProps>(
|
||||
({ label, variant = "neutral", size = "md", icon, className, ...rest }, ref) => {
|
||||
const tone =
|
||||
variant === "success"
|
||||
? "bg-green-50 text-green-700 ring-green-600/20"
|
||||
@ -24,12 +21,29 @@ export function StatusPill({
|
||||
? "bg-red-50 text-red-700 ring-red-600/20"
|
||||
: "bg-gray-50 text-gray-700 ring-gray-400/30";
|
||||
|
||||
const sizing =
|
||||
size === "sm"
|
||||
? "px-2 py-0.5 text-xs"
|
||||
: size === "lg"
|
||||
? "px-4 py-1.5 text-sm"
|
||||
: "px-3 py-1 text-xs";
|
||||
|
||||
return (
|
||||
<span
|
||||
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}`}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full ring-1 ring-inset gap-1",
|
||||
sizing,
|
||||
tone,
|
||||
className
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{label}
|
||||
{icon ? <span className="flex items-center justify-center">{icon}</span> : null}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
StatusPill.displayName = "StatusPill";
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { forwardRef, type ReactNode } from "react";
|
||||
|
||||
interface SubCardProps {
|
||||
export interface SubCardProps {
|
||||
title?: string;
|
||||
icon?: ReactNode;
|
||||
right?: ReactNode;
|
||||
header?: ReactNode; // Optional custom header content (overrides title/icon/right layout)
|
||||
footer?: ReactNode; // Optional footer section separated by a subtle divider
|
||||
header?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
bodyClassName?: string;
|
||||
}
|
||||
|
||||
export function SubCard({
|
||||
export const SubCard = forwardRef<HTMLDivElement, SubCardProps>(
|
||||
(
|
||||
{
|
||||
title,
|
||||
icon,
|
||||
right,
|
||||
@ -22,9 +24,11 @@ export function SubCard({
|
||||
className = "",
|
||||
headerClassName = "",
|
||||
bodyClassName = "",
|
||||
}: SubCardProps) {
|
||||
return (
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`border-[var(--cp-card-border)] bg-white shadow-[var(--cp-card-shadow)] rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] transition-shadow duration-[var(--cp-transition-normal)] hover:shadow-[var(--cp-card-shadow-lg)] ${className}`}
|
||||
>
|
||||
{header ? (
|
||||
@ -47,5 +51,6 @@ export function SubCard({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
SubCard.displayName = "SubCard";
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
|
||||
/**
|
||||
* Core API client configuration
|
||||
* Wraps the shared generated client to inject portal-specific behavior like auth headers.
|
||||
*/
|
||||
@ -7,16 +7,16 @@ import {
|
||||
createClient as createOpenApiClient,
|
||||
type ApiClient as GeneratedApiClient,
|
||||
type AuthHeaderResolver,
|
||||
resolveBaseUrl,
|
||||
} from "@customer-portal/api-client";
|
||||
import { env } from "../config/env";
|
||||
|
||||
const baseUrl = env.NEXT_PUBLIC_API_BASE;
|
||||
|
||||
let authHeaderResolver: AuthHeaderResolver | undefined;
|
||||
|
||||
const resolveAuthHeader: AuthHeaderResolver = () => authHeaderResolver?.();
|
||||
|
||||
export const apiClient: GeneratedApiClient = createOpenApiClient(baseUrl, {
|
||||
export const apiClient: GeneratedApiClient = createOpenApiClient({
|
||||
baseUrl: resolveBaseUrl(env.NEXT_PUBLIC_API_BASE),
|
||||
getAuthHeader: resolveAuthHeader,
|
||||
});
|
||||
|
||||
|
||||
@ -3,3 +3,4 @@ export { queryKeys } from "./query-keys";
|
||||
|
||||
export type { ApiClient } from "./client";
|
||||
export type { AuthHeaderResolver } from "@customer-portal/api-client";
|
||||
export type { InvoiceQueryParams } from "./types";
|
||||
|
||||
@ -3,56 +3,75 @@
|
||||
* Centralized query key factory for consistent caching
|
||||
*/
|
||||
|
||||
export const queryKeys = {
|
||||
// Auth queries
|
||||
auth: {
|
||||
all: ['auth'] as const,
|
||||
profile: () => [...queryKeys.auth.all, 'profile'] as const,
|
||||
},
|
||||
import type { InvoiceQueryParams } from "./types";
|
||||
|
||||
// Dashboard queries
|
||||
dashboard: {
|
||||
all: ['dashboard'] as const,
|
||||
summary: () => [...queryKeys.dashboard.all, 'summary'] as const,
|
||||
},
|
||||
const createScope = <const TScope extends readonly string[]>(scope: TScope) => ({
|
||||
all: scope,
|
||||
extend: <const TParts extends readonly unknown[]>(...parts: TParts) =>
|
||||
[...scope, ...parts] 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,
|
||||
},
|
||||
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);
|
||||
|
||||
// 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,
|
||||
},
|
||||
const auth = {
|
||||
all: authScope.all,
|
||||
profile: () => authScope.extend("profile"),
|
||||
};
|
||||
|
||||
// Catalog queries
|
||||
catalog: {
|
||||
all: ['catalog'] as const,
|
||||
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>) =>
|
||||
[...queryKeys.catalog.all, 'products', type, params ?? {}] as const,
|
||||
catalogScope.extend("products", type, params ?? {}),
|
||||
internet: {
|
||||
all: [...queryKeys.catalog.all, 'internet'] as const,
|
||||
combined: () => [...queryKeys.catalog.internet.all, 'combined'] as const,
|
||||
all: catalogScope.extend("internet"),
|
||||
combined: () => catalogScope.extend("internet", "combined"),
|
||||
},
|
||||
sim: {
|
||||
all: [...queryKeys.catalog.all, 'sim'] as const,
|
||||
combined: () => [...queryKeys.catalog.sim.all, 'combined'] as const,
|
||||
all: catalogScope.extend("sim"),
|
||||
combined: () => catalogScope.extend("sim", "combined"),
|
||||
},
|
||||
vpn: {
|
||||
all: [...queryKeys.catalog.all, 'vpn'] as const,
|
||||
combined: () => [...queryKeys.catalog.vpn.all, 'combined'] as const,
|
||||
},
|
||||
all: catalogScope.extend("vpn"),
|
||||
combined: () => catalogScope.extend("vpn", "combined"),
|
||||
},
|
||||
};
|
||||
|
||||
export const queryKeys = {
|
||||
auth,
|
||||
dashboard,
|
||||
billing,
|
||||
subscriptions,
|
||||
catalog,
|
||||
} as const;
|
||||
|
||||
export type QueryKeys = typeof queryKeys;
|
||||
|
||||
24
apps/portal/src/core/api/response-helpers.ts
Normal file
24
apps/portal/src/core/api/response-helpers.ts
Normal 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;
|
||||
};
|
||||
8
apps/portal/src/core/api/types.ts
Normal file
8
apps/portal/src/core/api/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface InvoiceQueryParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
NEXT_PUBLIC_API_BASE: z.string().url().default("http://localhost:4000/api"),
|
||||
NEXT_PUBLIC_API_BASE: z.string().url().default("http://localhost:4000"),
|
||||
NEXT_PUBLIC_APP_NAME: z.string().default("Assist Solutions Portal"),
|
||||
NEXT_PUBLIC_APP_VERSION: z.string().default("0.1.0"),
|
||||
});
|
||||
|
||||
67
apps/portal/src/core/validation/index.ts
Normal file
67
apps/portal/src/core/validation/index.ts
Normal 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;
|
||||
@ -1,4 +1,3 @@
|
||||
export { useProfileCompletion } from "./useProfileCompletion";
|
||||
export { useProfileData } from "./useProfileData";
|
||||
export { useProfileEdit } from "./useProfileEdit";
|
||||
export { useAddressEdit } from "./useAddressEdit";
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -1,13 +1,7 @@
|
||||
import { apiClient } from "@/core/api";
|
||||
import { getDataOrThrow, getNullableData } from "@/core/api/response-helpers";
|
||||
import type { Address, AuthUser } from "@customer-portal/domain";
|
||||
|
||||
const ensureData = <T>(data: T | undefined): T => {
|
||||
if (typeof data === "undefined") {
|
||||
throw new Error("No data returned from server");
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
type ProfileUpdateInput = {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
@ -16,22 +10,22 @@ type ProfileUpdateInput = {
|
||||
|
||||
export const accountService = {
|
||||
async getProfile() {
|
||||
const response = await apiClient.GET('/me');
|
||||
return ensureData<AuthUser | null>(response.data);
|
||||
const response = await apiClient.GET('/api/me');
|
||||
return getNullableData<AuthUser>(response);
|
||||
},
|
||||
|
||||
async updateProfile(update: ProfileUpdateInput) {
|
||||
const response = await apiClient.PATCH('/me', { body: update });
|
||||
return ensureData<AuthUser>(response.data);
|
||||
const response = await apiClient.PATCH('/api/me', { body: update });
|
||||
return getDataOrThrow<AuthUser>(response, "Failed to update profile");
|
||||
},
|
||||
|
||||
async getAddress() {
|
||||
const response = await apiClient.GET('/me/address');
|
||||
return ensureData<Address | null>(response.data);
|
||||
const response = await apiClient.GET('/api/me/address');
|
||||
return getNullableData<Address>(response);
|
||||
},
|
||||
|
||||
async updateAddress(address: Address) {
|
||||
const response = await apiClient.PATCH('/me/address', { body: address });
|
||||
return ensureData<Address>(response.data);
|
||||
const response = await apiClient.PATCH('/api/me/address', { body: address });
|
||||
return getDataOrThrow<Address>(response, "Failed to update address");
|
||||
},
|
||||
};
|
||||
|
||||
@ -103,10 +103,7 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await linkWhmcs({
|
||||
email: formData.email.trim(),
|
||||
password: formData.password,
|
||||
});
|
||||
const result = await linkWhmcs(formData.email.trim(), formData.password);
|
||||
|
||||
onTransferred?.({ needsPasswordSet: result.needsPasswordSet, email: formData.email.trim() });
|
||||
} catch (err) {
|
||||
|
||||
@ -10,7 +10,7 @@ import Link from "next/link";
|
||||
import { Button, Input, ErrorMessage } from "@/components/ui";
|
||||
import { FormField } from "@/components/common/FormField";
|
||||
import { usePasswordReset } from "../../hooks/use-auth";
|
||||
import { useFormValidation, commonValidationSchemas, z } from "@customer-portal/domain";
|
||||
import { useFormValidation } from "@/core/validation";
|
||||
// removed unused type imports
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
@ -47,6 +47,7 @@ export function PasswordResetForm({
|
||||
className = "",
|
||||
}: PasswordResetFormProps) {
|
||||
const { requestPasswordReset, resetPassword, loading, error, clearError } = usePasswordReset();
|
||||
const { validationRules, validateField } = useFormValidation();
|
||||
|
||||
const [requestData, setRequestData] = useState<RequestFormData>({
|
||||
email: "",
|
||||
|
||||
@ -27,7 +27,7 @@ export function SessionTimeoutWarning({
|
||||
|
||||
const expiryTime = Date.parse(tokens.expiresAt);
|
||||
if (Number.isNaN(expiryTime)) {
|
||||
logger.warn("Invalid expiresAt on auth tokens", { expiresAt: tokens.expiresAt });
|
||||
logger.warn({ expiresAt: tokens.expiresAt }, "Invalid expiresAt on auth tokens");
|
||||
expiryRef.current = null;
|
||||
setShowWarning(false);
|
||||
setTimeLeft(0);
|
||||
@ -133,4 +133,3 @@ export function SessionTimeoutWarning({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import Link from "next/link";
|
||||
import { Button, Input, ErrorMessage } from "@/components/ui";
|
||||
import { FormField } from "@/components/common/FormField";
|
||||
import { useWhmcsLink } from "../../hooks/use-auth";
|
||||
import { useFormValidation, commonValidationSchemas, z } from "@customer-portal/domain";
|
||||
import { useFormValidation } from "@/core/validation";
|
||||
|
||||
interface SetPasswordFormProps {
|
||||
email?: string;
|
||||
@ -41,6 +41,7 @@ export function SetPasswordForm({
|
||||
className = "",
|
||||
}: SetPasswordFormProps) {
|
||||
const { setPassword, loading, error, clearError } = useWhmcsLink();
|
||||
const { validationRules, validateField } = useFormValidation();
|
||||
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
email: initialEmail,
|
||||
|
||||
@ -4,16 +4,3 @@
|
||||
*/
|
||||
|
||||
export { useAuthStore } from "./services/auth.store";
|
||||
export type {
|
||||
SignupData,
|
||||
LoginData,
|
||||
LinkWhmcsData,
|
||||
SetPasswordData,
|
||||
RequestPasswordResetData,
|
||||
ResetPasswordData,
|
||||
ChangePasswordData,
|
||||
CheckPasswordNeededData,
|
||||
CheckPasswordNeededResponse,
|
||||
AuthResponse,
|
||||
LinkResponse,
|
||||
} from "./api";
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import { apiClient, configureApiClientAuth } from "@/core/api";
|
||||
import { getDataOrDefault, getDataOrThrow } from "@/core/api/response-helpers";
|
||||
import type {
|
||||
AuthTokens,
|
||||
SignupRequest,
|
||||
@ -36,6 +37,21 @@ type RawAuthTokens =
|
||||
| (Omit<AuthTokens, "expiresAt"> & { expiresAt: string | number | Date })
|
||||
| LegacyAuthTokens;
|
||||
|
||||
interface AuthPayload {
|
||||
user: AuthUser;
|
||||
tokens: RawAuthTokens;
|
||||
}
|
||||
|
||||
interface PasswordCheckPayload {
|
||||
needsPasswordSet: boolean;
|
||||
userExists: boolean;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface LinkWhmcsPayload {
|
||||
needsPasswordSet: boolean;
|
||||
}
|
||||
|
||||
const toIsoString = (value: string | number | Date | null | undefined) => {
|
||||
|
||||
if (value instanceof Date) {
|
||||
@ -169,18 +185,17 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
login: async (credentials: LoginRequest) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await apiClient.POST('/auth/login', { body: credentials });
|
||||
if (response.data) {
|
||||
const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens);
|
||||
const response = await apiClient.POST('/api/auth/login', { body: credentials });
|
||||
const payload = getDataOrThrow<AuthPayload>(response, 'Failed to login');
|
||||
const tokens = normalizeAuthTokens(payload.tokens);
|
||||
set({
|
||||
user: response.data.user,
|
||||
user: payload.user,
|
||||
tokens,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
persistSession: Boolean(credentials.rememberMe),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
set({
|
||||
@ -198,17 +213,16 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
signup: async (data: SignupRequest) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await apiClient.POST('/auth/signup', { body: data });
|
||||
if (response.data) {
|
||||
const tokens = normalizeAuthTokens(response.data.tokens as RawAuthTokens);
|
||||
const response = await apiClient.POST('/api/auth/signup', { body: data });
|
||||
const payload = getDataOrThrow<AuthPayload>(response, 'Failed to sign up');
|
||||
const tokens = normalizeAuthTokens(payload.tokens);
|
||||
set({
|
||||
user: response.data.user,
|
||||
user: payload.user,
|
||||
tokens,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
set({
|
||||
@ -228,7 +242,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
// Call logout API if we have tokens
|
||||
if (tokens?.accessToken) {
|
||||
try {
|
||||
await apiClient.POST('/auth/logout');
|
||||
await apiClient.POST('/api/auth/logout');
|
||||
} catch (error) {
|
||||
console.warn("Logout API call failed:", error);
|
||||
// Continue with local logout even if API call fails
|
||||
@ -247,7 +261,7 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
requestPasswordReset: async (data: ForgotPasswordRequest) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await apiClient.POST('/auth/request-password-reset', { body: data });
|
||||
await apiClient.POST('/api/auth/request-password-reset', { body: data });
|
||||
set({ loading: false });
|
||||
} catch (error) {
|
||||
const authError = error as AuthError;
|
||||
@ -259,11 +273,14 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
resetPassword: async (data: ResetPasswordRequest) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await apiClient.POST('/auth/reset-password', { body: data });
|
||||
const { user, tokens } = response.data!;
|
||||
const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens);
|
||||
const response = await apiClient.POST('/api/auth/reset-password', { body: data });
|
||||
const payload = getDataOrThrow<AuthPayload>(
|
||||
response,
|
||||
'Failed to reset password'
|
||||
);
|
||||
const normalizedTokens = normalizeAuthTokens(payload.tokens);
|
||||
set({
|
||||
user,
|
||||
user: payload.user,
|
||||
tokens: normalizedTokens,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
@ -290,11 +307,14 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await apiClient.POST('/auth/change-password', { body: data });
|
||||
const { user, tokens: newTokens } = response.data!;
|
||||
const normalizedTokens = normalizeAuthTokens(newTokens as RawAuthTokens);
|
||||
const response = await apiClient.POST('/api/auth/change-password', { body: data });
|
||||
const payload = getDataOrThrow<AuthPayload>(
|
||||
response,
|
||||
'Failed to change password'
|
||||
);
|
||||
const normalizedTokens = normalizeAuthTokens(payload.tokens);
|
||||
set({
|
||||
user,
|
||||
user: payload.user,
|
||||
tokens: normalizedTokens,
|
||||
loading: false,
|
||||
error: null,
|
||||
@ -309,8 +329,11 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
checkPasswordNeeded: async (email: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await apiClient.POST('/auth/check-password-needed', { body: { email } });
|
||||
const result = response.data!;
|
||||
const response = await apiClient.POST('/api/auth/check-password-needed', { body: { email } });
|
||||
const result = getDataOrThrow<PasswordCheckPayload>(
|
||||
response,
|
||||
'Failed to check password requirements'
|
||||
);
|
||||
set({ loading: false });
|
||||
return result;
|
||||
} catch (error) {
|
||||
@ -323,8 +346,11 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
linkWhmcs: async (email: string, password: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await apiClient.POST('/auth/link-whmcs', { body: { email, password } });
|
||||
const result = response.data!;
|
||||
const response = await apiClient.POST('/api/auth/link-whmcs', { body: { email, password } });
|
||||
const result = getDataOrThrow<LinkWhmcsPayload>(
|
||||
response,
|
||||
'Failed to link WHMCS account'
|
||||
);
|
||||
set({ loading: false });
|
||||
return result;
|
||||
} catch (error) {
|
||||
@ -337,11 +363,11 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
setPassword: async (email: string, password: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await apiClient.POST('/auth/set-password', { body: { email, password } });
|
||||
const { user, tokens } = response.data!;
|
||||
const normalizedTokens = normalizeAuthTokens(tokens as RawAuthTokens);
|
||||
const response = await apiClient.POST('/api/auth/set-password', { body: { email, password } });
|
||||
const payload = getDataOrThrow<AuthPayload>(response, 'Failed to set password');
|
||||
const normalizedTokens = normalizeAuthTokens(payload.tokens);
|
||||
set({
|
||||
user,
|
||||
user: payload.user,
|
||||
tokens: normalizedTokens,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
@ -377,8 +403,8 @@ export const useAuthStore = create<AuthStoreState>()(
|
||||
|
||||
set({ loading: true });
|
||||
try {
|
||||
const response = await apiClient.GET('/me');
|
||||
const user = response.data ?? null;
|
||||
const response = await apiClient.GET('/api/me');
|
||||
const user = getDataOrDefault<AuthUser | null>(response, null);
|
||||
if (user) {
|
||||
set({ user, isAuthenticated: true, loading: false, error: null, hasCheckedAuth: true });
|
||||
} else {
|
||||
|
||||
@ -44,13 +44,11 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
|
||||
);
|
||||
}
|
||||
|
||||
const formatAmount = (amount: number) => {
|
||||
return formatCurrency(amount, {
|
||||
const formatAmount = (amount: number) =>
|
||||
formatCurrency(amount, {
|
||||
currency: summary.currency,
|
||||
currencySymbol: summary.currencySymbol,
|
||||
locale: getCurrencyLocale(summary.currency),
|
||||
});
|
||||
};
|
||||
|
||||
const summaryItems = [
|
||||
{
|
||||
|
||||
@ -89,7 +89,6 @@ export function InvoiceTable({
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{formatCurrency(invoice.total, {
|
||||
currency: invoice.currency,
|
||||
currencySymbol: invoice.currencySymbol,
|
||||
locale: getCurrencyLocale(invoice.currency),
|
||||
})}
|
||||
</span>
|
||||
|
||||
@ -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 };
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./InvoiceStatusBadge";
|
||||
export * from "./InvoiceItemRow";
|
||||
export * from "./PaymentMethodCard";
|
||||
|
||||
@ -9,13 +9,12 @@ import {
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { apiClient, queryKeys } from "@/core/api";
|
||||
import { getDataOrDefault, getDataOrThrow } from "@/core/api/response-helpers";
|
||||
import type { InvoiceQueryParams } from "@/core/api/types";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
InvoiceQueryParams,
|
||||
InvoiceSsoLink,
|
||||
InvoicePaymentLink,
|
||||
PaymentGatewayList,
|
||||
PaymentMethodList,
|
||||
} from "@customer-portal/domain";
|
||||
|
||||
@ -52,15 +51,6 @@ type PaymentMethodsQueryOptions = Omit<
|
||||
"queryKey" | "queryFn"
|
||||
>;
|
||||
|
||||
type PaymentLinkMutationOptions = UseMutationOptions<
|
||||
InvoicePaymentLink,
|
||||
Error,
|
||||
{
|
||||
invoiceId: number;
|
||||
paymentMethodId?: number;
|
||||
gatewayName?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type SsoLinkMutationOptions = UseMutationOptions<
|
||||
InvoiceSsoLink,
|
||||
@ -69,26 +59,18 @@ type SsoLinkMutationOptions = UseMutationOptions<
|
||||
>;
|
||||
|
||||
async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList> {
|
||||
const response = await apiClient.GET("/invoices", params ? { params: { query: params } } : undefined);
|
||||
return response.data ?? emptyInvoiceList;
|
||||
const response = await apiClient.GET("/api/invoices", params ? { params: { query: params } } : undefined);
|
||||
return getDataOrDefault(response, emptyInvoiceList);
|
||||
}
|
||||
|
||||
async function fetchInvoice(id: string): Promise<Invoice> {
|
||||
const response = await apiClient.GET("/invoices/{id}", { params: { path: { id } } });
|
||||
if (!response.data) {
|
||||
throw new Error("Invoice not found");
|
||||
}
|
||||
return response.data;
|
||||
const response = await apiClient.GET("/api/invoices/{id}", { params: { path: { id } } });
|
||||
return getDataOrThrow(response, "Invoice not found");
|
||||
}
|
||||
|
||||
async function fetchPaymentMethods(): Promise<PaymentMethodList> {
|
||||
const response = await apiClient.GET("/invoices/payment-methods");
|
||||
return response.data ?? emptyPaymentMethods;
|
||||
}
|
||||
|
||||
async function fetchPaymentGateways(): Promise<PaymentGatewayList> {
|
||||
const response = await apiClient.GET("/invoices/payment-gateways");
|
||||
return response.data ?? { gateways: [], totalCount: 0 };
|
||||
const response = await apiClient.GET("/api/invoices/payment-methods");
|
||||
return getDataOrDefault(response, emptyPaymentMethods);
|
||||
}
|
||||
|
||||
export function useInvoices(
|
||||
@ -124,54 +106,6 @@ export function usePaymentMethods(
|
||||
});
|
||||
}
|
||||
|
||||
export function usePaymentGateways(
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
PaymentGatewayList,
|
||||
Error,
|
||||
PaymentGatewayList,
|
||||
ReturnType<typeof queryKeys.billing.gateways>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
): UseQueryResult<PaymentGatewayList, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.billing.gateways(),
|
||||
queryFn: fetchPaymentGateways,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInvoicePaymentLink(
|
||||
options?: PaymentLinkMutationOptions
|
||||
): UseMutationResult<
|
||||
InvoicePaymentLink,
|
||||
Error,
|
||||
{
|
||||
invoiceId: number;
|
||||
paymentMethodId?: number;
|
||||
gatewayName?: string;
|
||||
}
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ invoiceId, paymentMethodId, gatewayName }) => {
|
||||
const response = await apiClient.POST("/invoices/{id}/payment-link", {
|
||||
params: {
|
||||
path: { id: invoiceId },
|
||||
query: {
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to create payment link");
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInvoiceSsoLink(
|
||||
options?: SsoLinkMutationOptions
|
||||
@ -182,16 +116,13 @@ export function useCreateInvoiceSsoLink(
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ invoiceId, target }) => {
|
||||
const response = await apiClient.POST("/invoices/{id}/sso-link", {
|
||||
const response = await apiClient.POST("/api/invoices/{id}/sso-link", {
|
||||
params: {
|
||||
path: { id: invoiceId },
|
||||
query: target ? { target } : undefined,
|
||||
},
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to create SSO link");
|
||||
}
|
||||
return response.data;
|
||||
return getDataOrThrow(response, "Failed to create SSO link");
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
@ -29,7 +29,7 @@ export function usePaymentRefresh<T>({
|
||||
setToast({ visible: true, text: "Refreshing payment methods...", tone: "info" });
|
||||
try {
|
||||
try {
|
||||
await apiClient.POST("/invoices/payment-methods/refresh");
|
||||
await apiClient.POST("/api/invoices/payment-methods/refresh");
|
||||
} catch (err) {
|
||||
// Soft-fail cache refresh, still attempt refetch
|
||||
console.warn("Payment methods cache refresh failed:", err);
|
||||
|
||||
@ -11,6 +11,7 @@ import { PageLayout } from "@/components/layout/PageLayout";
|
||||
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
||||
import { logger } from "@customer-portal/logging";
|
||||
import { apiClient } from "@/core/api";
|
||||
import { getDataOrThrow } from "@/core/api/response-helpers";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { useInvoice, useCreateInvoiceSsoLink } from "@/features/billing/hooks";
|
||||
import {
|
||||
@ -27,9 +28,11 @@ export function InvoiceDetailContainer() {
|
||||
const [loadingPayment, setLoadingPayment] = useState(false);
|
||||
const [loadingPaymentMethods, setLoadingPaymentMethods] = useState(false);
|
||||
|
||||
const invoiceId = parseInt(params.id as string);
|
||||
const rawInvoiceParam = params.id;
|
||||
const invoiceIdParam = Array.isArray(rawInvoiceParam) ? rawInvoiceParam[0] : rawInvoiceParam;
|
||||
const invoiceId = Number.parseInt(invoiceIdParam ?? "", 10);
|
||||
const createSsoLinkMutation = useCreateInvoiceSsoLink();
|
||||
const { data: invoice, isLoading, error } = useInvoice(invoiceId);
|
||||
const { data: invoice, isLoading, error } = useInvoice(invoiceIdParam ?? "");
|
||||
|
||||
const handleCreateSsoLink = (target: "view" | "download" | "pay" = "view") => {
|
||||
void (async () => {
|
||||
@ -53,10 +56,13 @@ export function InvoiceDetailContainer() {
|
||||
void (async () => {
|
||||
setLoadingPaymentMethods(true);
|
||||
try {
|
||||
const response = await apiClient.POST('/auth/sso-link', {
|
||||
body: { path: "index.php?rp=/account/paymentmethods" }
|
||||
const response = await apiClient.POST('/api/auth/sso-link', {
|
||||
body: { path: "index.php?rp=/account/paymentmethods" },
|
||||
});
|
||||
const sso = response.data!;
|
||||
const sso = getDataOrThrow<{ url: string }>(
|
||||
response,
|
||||
'Failed to create payment methods SSO link'
|
||||
);
|
||||
openSsoLink(sso.url, { newTab: true });
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to create payment methods SSO link");
|
||||
@ -180,5 +186,3 @@ export function InvoiceDetailContainer() {
|
||||
}
|
||||
|
||||
export default InvoiceDetailContainer;
|
||||
|
||||
|
||||
|
||||
@ -6,8 +6,9 @@ import { ErrorBoundary } from "@/components/common";
|
||||
import { SubCard } from "@/components/ui/sub-card";
|
||||
import { useSession } from "@/features/auth/hooks";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
// ApiClientError import removed - using generic error handling
|
||||
|
||||
import { apiClient } from "@/core/api";
|
||||
import { getDataOrThrow } from "@/core/api/response-helpers";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||
import { PaymentMethodCard, usePaymentMethods } from "@/features/billing";
|
||||
@ -46,25 +47,32 @@ export function PaymentMethodsContainer() {
|
||||
});
|
||||
|
||||
const openPaymentMethods = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
setError("Please log in to access payment methods.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const response = await apiClient.POST('/auth/sso-link', {
|
||||
body: { path: "index.php?rp=/account/paymentmethods" }
|
||||
|
||||
try {
|
||||
const response = await apiClient.POST('/api/auth/sso-link', {
|
||||
body: { path: "index.php?rp=/account/paymentmethods" },
|
||||
});
|
||||
const sso = response.data!;
|
||||
const sso = getDataOrThrow<{ url: string }>(
|
||||
response,
|
||||
'Failed to open payment methods portal'
|
||||
);
|
||||
openSsoLink(sso.url, { newTab: true });
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to open payment methods");
|
||||
if (error && typeof error === 'object' && 'status' in error && (error as any).status === 401)
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to open payment methods");
|
||||
if (err && typeof err === 'object' && 'status' in err && (err as any).status === 401) {
|
||||
setError("Authentication failed. Please log in again.");
|
||||
else setError("Unable to access payment methods. Please try again later.");
|
||||
} else {
|
||||
setError("Unable to access payment methods. Please try again later.");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@ -73,16 +81,22 @@ export function PaymentMethodsContainer() {
|
||||
// Placeholder hook for future logic when returning from WHMCS
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (error || paymentMethodsError) {
|
||||
const errorObj =
|
||||
error || (paymentMethodsError instanceof Error ? paymentMethodsError : new Error("Unexpected error"));
|
||||
const combinedError = error
|
||||
? new Error(error)
|
||||
: paymentMethodsError instanceof Error
|
||||
? paymentMethodsError
|
||||
: paymentMethodsError
|
||||
? new Error(String(paymentMethodsError))
|
||||
: null;
|
||||
|
||||
if (combinedError) {
|
||||
return (
|
||||
<PageLayout
|
||||
icon={<CreditCardIcon />}
|
||||
title="Payment Methods"
|
||||
description="Manage your saved payment methods and billing information"
|
||||
>
|
||||
<AsyncBlock error={errorObj} variant="page">
|
||||
<AsyncBlock error={combinedError} variant="page">
|
||||
<></>
|
||||
</AsyncBlock>
|
||||
</PageLayout>
|
||||
@ -147,7 +161,6 @@ export function PaymentMethodsContainer() {
|
||||
key={paymentMethod.id}
|
||||
paymentMethod={paymentMethod}
|
||||
className="focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
showActions={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -207,4 +220,3 @@ export function PaymentMethodsContainer() {
|
||||
}
|
||||
|
||||
export default PaymentMethodsContainer;
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ interface BillingInfo {
|
||||
company: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
address: Address;
|
||||
address: Address | null;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
@ -54,32 +54,33 @@ export function AddressConfirmation({
|
||||
const fetchBillingInfo = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = (await accountService.getAddress()) || null;
|
||||
setBillingInfo({
|
||||
company: null,
|
||||
email: "",
|
||||
phone: null,
|
||||
address: data,
|
||||
isComplete: !!(
|
||||
const data = await accountService.getAddress();
|
||||
const isComplete = !!(
|
||||
data &&
|
||||
data.street &&
|
||||
data.city &&
|
||||
data.state &&
|
||||
data.postalCode &&
|
||||
data.country
|
||||
),
|
||||
);
|
||||
|
||||
setBillingInfo({
|
||||
company: null,
|
||||
email: "",
|
||||
phone: null,
|
||||
address: data,
|
||||
isComplete,
|
||||
});
|
||||
|
||||
// Since address is required at signup, it should always be complete
|
||||
// But we still need verification for Internet orders
|
||||
if (requiresAddressVerification) {
|
||||
// For Internet orders, don't auto-confirm - require explicit verification
|
||||
setAddressConfirmed(false);
|
||||
onAddressIncomplete(); // Keep disabled until explicitly confirmed
|
||||
} else {
|
||||
// For other order types, auto-confirm since address exists from signup
|
||||
if (data) onAddressConfirmed(data);
|
||||
onAddressIncomplete();
|
||||
} else if (isComplete && data) {
|
||||
onAddressConfirmed(data);
|
||||
setAddressConfirmed(true);
|
||||
} else {
|
||||
onAddressIncomplete();
|
||||
setAddressConfirmed(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load address");
|
||||
@ -99,7 +100,7 @@ export function AddressConfirmation({
|
||||
|
||||
setEditing(true);
|
||||
setEditedAddress(
|
||||
billingInfo?.address || {
|
||||
billingInfo?.address ?? {
|
||||
street: "",
|
||||
streetLine2: "",
|
||||
city: "",
|
||||
@ -134,17 +135,17 @@ export function AddressConfirmation({
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Build minimal PATCH payload with only provided fields
|
||||
const payload: Record<string, string> = {};
|
||||
if (editedAddress.street) payload.street = editedAddress.street;
|
||||
if (editedAddress.streetLine2) payload.streetLine2 = editedAddress.streetLine2;
|
||||
if (editedAddress.city) payload.city = editedAddress.city;
|
||||
if (editedAddress.state) payload.state = editedAddress.state;
|
||||
if (editedAddress.postalCode) payload.postalCode = editedAddress.postalCode;
|
||||
if (editedAddress.country) payload.country = editedAddress.country;
|
||||
const sanitizedAddress: Address = {
|
||||
street: editedAddress.street?.trim() || null,
|
||||
streetLine2: editedAddress.streetLine2?.trim() || null,
|
||||
city: editedAddress.city?.trim() || null,
|
||||
state: editedAddress.state?.trim() || null,
|
||||
postalCode: editedAddress.postalCode?.trim() || null,
|
||||
country: editedAddress.country?.trim() || null,
|
||||
};
|
||||
|
||||
// Persist to server (WHMCS via BFF)
|
||||
const updatedAddress = await accountService.updateAddress(payload);
|
||||
const updatedAddress = await accountService.updateAddress(sanitizedAddress);
|
||||
|
||||
// Rebuild BillingInfo from updated address
|
||||
const updatedInfo: BillingInfo = {
|
||||
@ -234,6 +235,8 @@ export function AddressConfirmation({
|
||||
|
||||
if (!billingInfo) return null;
|
||||
|
||||
const address = billingInfo.address;
|
||||
|
||||
return wrap(
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@ -379,18 +382,18 @@ export function AddressConfirmation({
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{billingInfo.address.street ? (
|
||||
{address?.street ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-gray-900 space-y-1">
|
||||
<p className="font-semibold text-base">{billingInfo.address.street}</p>
|
||||
{billingInfo.address.streetLine2 && (
|
||||
<p className="text-gray-700">{billingInfo.address.streetLine2}</p>
|
||||
)}
|
||||
<p className="font-semibold text-base">{address.street}</p>
|
||||
{address.streetLine2 ? (
|
||||
<p className="text-gray-700">{address.streetLine2}</p>
|
||||
) : null}
|
||||
<p className="text-gray-700">
|
||||
{billingInfo.address.city}, {billingInfo.address.state}{" "}
|
||||
{billingInfo.address.postalCode}
|
||||
{[address.city, address.state].filter(Boolean).join(", ")}
|
||||
{address.postalCode ? ` ${address.postalCode}` : ""}
|
||||
</p>
|
||||
<p className="text-gray-600">{billingInfo.address.country}</p>
|
||||
<p className="text-gray-600">{address.country}</p>
|
||||
</div>
|
||||
|
||||
{/* Status message for Internet orders when pending */}
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { AnimatedCard } from "@/components/ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Use consolidated domain types
|
||||
import type {
|
||||
@ -105,6 +106,7 @@ export function EnhancedOrderSummary({
|
||||
headerContent,
|
||||
footerContent,
|
||||
}: EnhancedOrderSummaryProps) {
|
||||
const router = useRouter();
|
||||
const sizeClasses = {
|
||||
compact: "p-4",
|
||||
standard: "p-6",
|
||||
@ -335,11 +337,13 @@ export function EnhancedOrderSummary({
|
||||
<div className="flex gap-4">
|
||||
{backUrl ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={backUrl}
|
||||
variant="outline"
|
||||
className="flex-1 group"
|
||||
disabled={disabled || loading}
|
||||
onClick={() => {
|
||||
if (disabled || loading) return;
|
||||
router.push(backUrl);
|
||||
}}
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
|
||||
{backLabel}
|
||||
|
||||
@ -1,44 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/loading-skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertBanner } from "@/components/common/AlertBanner";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
// Use domain PaymentMethod type
|
||||
import { CreditCardIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import type { PaymentMethod } from "@customer-portal/domain";
|
||||
|
||||
export interface PaymentFormProps {
|
||||
// Payment methods
|
||||
existingMethods?: PaymentMethod[];
|
||||
selectedMethodId?: string;
|
||||
|
||||
// Callbacks
|
||||
onMethodSelect?: (methodId: string) => void;
|
||||
onAddNewMethod?: () => void;
|
||||
onValidationChange?: (isValid: boolean, errors: string[]) => void;
|
||||
|
||||
// Configuration
|
||||
title?: string;
|
||||
description?: string;
|
||||
showTitle?: boolean;
|
||||
allowNewMethod?: boolean;
|
||||
requirePaymentMethod?: boolean;
|
||||
|
||||
// State
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
// Styling
|
||||
variant?: "default" | "compact" | "inline";
|
||||
|
||||
// Custom content
|
||||
children?: React.ReactNode;
|
||||
footerContent?: React.ReactNode;
|
||||
}
|
||||
@ -56,77 +37,41 @@ export function PaymentForm({
|
||||
requirePaymentMethod = true,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
variant = "default",
|
||||
children,
|
||||
footerContent,
|
||||
}: PaymentFormProps) {
|
||||
const [selectedMethod, setSelectedMethod] = useState<string>(selectedMethodId || "");
|
||||
const [selectedMethod, setSelectedMethod] = useState<string>(selectedMethodId ?? "");
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
|
||||
const validatePayment = () => {
|
||||
const validationErrors: string[] = [];
|
||||
|
||||
if (requirePaymentMethod) {
|
||||
if (existingMethods.length === 0) {
|
||||
validationErrors.push(
|
||||
"No payment method on file. Please add a payment method to continue."
|
||||
);
|
||||
} else if (!selectedMethod) {
|
||||
validationErrors.push("Please select a payment method.");
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(validationErrors);
|
||||
const isValid = validationErrors.length === 0;
|
||||
onValidationChange?.(isValid, validationErrors);
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleMethodSelect = (methodId: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
setSelectedMethod(methodId);
|
||||
onMethodSelect?.(methodId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validatePayment();
|
||||
}, [selectedMethod, existingMethods, requirePaymentMethod]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedMethodId !== undefined) {
|
||||
setSelectedMethod(selectedMethodId);
|
||||
}
|
||||
}, [selectedMethodId]);
|
||||
|
||||
const getCardBrandIcon = (brand?: string) => {
|
||||
// In a real implementation, you'd return appropriate brand icons
|
||||
return <CreditCardIcon className="h-6 w-6 text-gray-400" />;
|
||||
};
|
||||
useEffect(() => {
|
||||
const validationErrors: string[] = [];
|
||||
|
||||
const formatCardNumber = (last4?: string) => {
|
||||
return last4 ? `•••• •••• •••• ${last4}` : "•••• •••• •••• ••••";
|
||||
};
|
||||
if (requirePaymentMethod) {
|
||||
if (existingMethods.length === 0) {
|
||||
validationErrors.push("No payment methods on file. Add one to continue.");
|
||||
} else if (!selectedMethod) {
|
||||
validationErrors.push("Select a payment method to continue.");
|
||||
}
|
||||
}
|
||||
|
||||
const formatExpiry = (month?: number, year?: number) => {
|
||||
if (!month || !year) return "";
|
||||
return `${month.toString().padStart(2, "0")}/${year.toString().slice(-2)}`;
|
||||
};
|
||||
setErrors(validationErrors);
|
||||
onValidationChange?.(validationErrors.length === 0, validationErrors);
|
||||
}, [existingMethods, requirePaymentMethod, selectedMethod, onValidationChange]);
|
||||
|
||||
const containerClasses =
|
||||
variant === "inline"
|
||||
? ""
|
||||
: variant === "compact"
|
||||
? "p-4 bg-gray-50 rounded-lg border border-gray-200"
|
||||
: "p-6 bg-white border border-gray-200 rounded-lg";
|
||||
const methods = useMemo(() => existingMethods, [existingMethods]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="p-6 bg-white border border-gray-200 rounded-lg">
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-12" />
|
||||
<div className="space-y-2">
|
||||
@ -142,116 +87,118 @@ export function PaymentForm({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{showTitle && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CreditCardIcon className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
{description && <p className="text-sm text-gray-600">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
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;
|
||||
|
||||
{/* No payment methods */}
|
||||
{existingMethods.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CreditCardIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">No Payment Method on File</h4>
|
||||
<p className="text-gray-600 mb-6">Add a payment method to complete your order.</p>
|
||||
{allowNewMethod && onAddNewMethod && (
|
||||
<Button onClick={onAddNewMethod} disabled={disabled}>
|
||||
Add Payment Method
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Existing payment methods */}
|
||||
<div className="space-y-3">
|
||||
{existingMethods.map(method => (
|
||||
return (
|
||||
<label
|
||||
key={method.id}
|
||||
className={`flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all ${
|
||||
selectedMethod === method.id
|
||||
? "border-blue-500 bg-blue-50 ring-2 ring-blue-100"
|
||||
: "border-gray-200 hover:border-gray-300 bg-white"
|
||||
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
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={method.id}
|
||||
checked={selectedMethod === method.id}
|
||||
onChange={() => handleMethodSelect(method.id)}
|
||||
value={methodId}
|
||||
checked={isSelected}
|
||||
disabled={disabled}
|
||||
className="text-blue-600 focus:ring-blue-500 mr-4"
|
||||
onChange={() => {
|
||||
if (disabled) return;
|
||||
setSelectedMethod(methodId);
|
||||
onMethodSelect?.(methodId);
|
||||
}}
|
||||
className="text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
<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
|
||||
return (
|
||||
<div className="p-6 bg-white border border-gray-200 rounded-lg space-y-4">
|
||||
{showTitle && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CreditCardIcon className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
{description ? <p className="text-sm text-gray-600">{description}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{methods.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<CreditCardIcon className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">Add a payment method to continue.</p>
|
||||
{allowNewMethod && onAddNewMethod ? (
|
||||
<Button onClick={onAddNewMethod} disabled={disabled}>
|
||||
Add Payment Method
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{methods.map(renderMethod)}
|
||||
{allowNewMethod && onAddNewMethod ? (
|
||||
<div className="pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onAddNewMethod}
|
||||
disabled={disabled}
|
||||
className="w-full flex items-center justify-center gap-2 p-4 border-2 border-dashed border-gray-300 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<CreditCardIcon className="h-5 w-5" />
|
||||
<span className="font-medium">Add New Payment Method</span>
|
||||
</button>
|
||||
Add Another Method
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom content */}
|
||||
{children && <div className="mt-6">{children}</div>}
|
||||
{children}
|
||||
|
||||
{/* Validation errors */}
|
||||
{errors.length > 0 && (
|
||||
<AlertBanner variant="error" title="Payment Required" className="mt-4" elevated size="sm">
|
||||
{errors.length > 0 ? (
|
||||
<AlertBanner variant="error" title="Payment Required" elevated size="sm">
|
||||
<ul className="list-disc list-inside">
|
||||
{errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
{errors.map((msg, index) => (
|
||||
<li key={index}>{msg}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertBanner>
|
||||
) : (
|
||||
selectedMethod && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
Payment method selected
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Footer content */}
|
||||
{footerContent && <div className="mt-6 pt-4 border-t border-gray-200">{footerContent}</div>}
|
||||
{footerContent ? (
|
||||
<div className="pt-4 border-t border-gray-200">{footerContent}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,44 +1,45 @@
|
||||
import { apiClient } from "@/core/api";
|
||||
import { getDataOrDefault } from "@/core/api/response-helpers";
|
||||
import type { InternetProduct, SimProduct, VpnProduct } from "@customer-portal/domain";
|
||||
|
||||
async function getInternetPlans(): Promise<InternetProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/internet/plans");
|
||||
return response.data ?? [];
|
||||
const response = await apiClient.GET("/api/catalog/internet/plans");
|
||||
return getDataOrDefault(response, [] as InternetProduct[]);
|
||||
}
|
||||
|
||||
async function getInternetInstallations(): Promise<InternetProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/internet/installations");
|
||||
return response.data ?? [];
|
||||
const response = await apiClient.GET("/api/catalog/internet/installations");
|
||||
return getDataOrDefault(response, [] as InternetProduct[]);
|
||||
}
|
||||
|
||||
async function getInternetAddons(): Promise<InternetProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/internet/addons");
|
||||
return response.data ?? [];
|
||||
const response = await apiClient.GET("/api/catalog/internet/addons");
|
||||
return getDataOrDefault(response, [] as InternetProduct[]);
|
||||
}
|
||||
|
||||
async function getSimPlans(): Promise<SimProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/sim/plans");
|
||||
return response.data ?? [];
|
||||
const response = await apiClient.GET("/api/catalog/sim/plans");
|
||||
return getDataOrDefault(response, [] as SimProduct[]);
|
||||
}
|
||||
|
||||
async function getSimActivationFees(): Promise<SimProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/sim/activation-fees");
|
||||
return response.data ?? [];
|
||||
const response = await apiClient.GET("/api/catalog/sim/activation-fees");
|
||||
return getDataOrDefault(response, [] as SimProduct[]);
|
||||
}
|
||||
|
||||
async function getSimAddons(): Promise<SimProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/sim/addons");
|
||||
return response.data ?? [];
|
||||
const response = await apiClient.GET("/api/catalog/sim/addons");
|
||||
return getDataOrDefault(response, [] as SimProduct[]);
|
||||
}
|
||||
|
||||
async function getVpnPlans(): Promise<VpnProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/vpn/plans");
|
||||
return response.data ?? [];
|
||||
const response = await apiClient.GET("/api/catalog/vpn/plans");
|
||||
return getDataOrDefault(response, [] as VpnProduct[]);
|
||||
}
|
||||
|
||||
async function getVpnActivationFees(): Promise<VpnProduct[]> {
|
||||
const response = await apiClient.GET("/catalog/vpn/activation-fees");
|
||||
return response.data ?? [];
|
||||
const response = await apiClient.GET("/api/catalog/vpn/activation-fees");
|
||||
return getDataOrDefault(response, [] as VpnProduct[]);
|
||||
}
|
||||
|
||||
export const catalogService = {
|
||||
|
||||
@ -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;
|
||||
@ -3,10 +3,14 @@
|
||||
* Provides dashboard data with proper error handling, caching, and loading states
|
||||
*/
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
import { apiClient } from "@/core/api";
|
||||
import type { DashboardSummary, DashboardError } from "@customer-portal/domain";
|
||||
import { apiClient, queryKeys } from "@/core/api";
|
||||
import { getDataOrThrow } from "@/core/api/response-helpers";
|
||||
import type {
|
||||
DashboardSummary,
|
||||
DashboardError,
|
||||
} from "@customer-portal/domain";
|
||||
|
||||
class DashboardDataError extends Error {
|
||||
constructor(
|
||||
@ -19,14 +23,6 @@ class DashboardDataError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Query key factory for dashboard queries
|
||||
export const dashboardQueryKeys = {
|
||||
all: ["dashboard"] as const,
|
||||
summary: () => [...dashboardQueryKeys.all, "summary"] as const,
|
||||
stats: () => [...dashboardQueryKeys.all, "stats"] as const,
|
||||
activity: (filters?: string[]) => [...dashboardQueryKeys.all, "activity", filters] as const,
|
||||
nextInvoice: () => [...dashboardQueryKeys.all, "next-invoice"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching dashboard summary data
|
||||
@ -35,7 +31,7 @@ export function useDashboardSummary() {
|
||||
const { isAuthenticated, tokens } = useAuthStore();
|
||||
|
||||
return useQuery<DashboardSummary, DashboardError>({
|
||||
queryKey: dashboardQueryKeys.summary(),
|
||||
queryKey: queryKeys.dashboard.summary(),
|
||||
queryFn: async () => {
|
||||
if (!tokens?.accessToken) {
|
||||
throw new DashboardDataError(
|
||||
@ -45,8 +41,11 @@ export function useDashboardSummary() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.GET('/dashboard/summary');
|
||||
return response.data!;
|
||||
const response = await apiClient.GET('/api/dashboard/summary');
|
||||
return getDataOrThrow<DashboardSummary>(
|
||||
response,
|
||||
"Dashboard summary response was empty"
|
||||
);
|
||||
} catch (error) {
|
||||
// Transform API errors to DashboardError format
|
||||
if (error instanceof Error) {
|
||||
@ -77,97 +76,3 @@ export function useDashboardSummary() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for refreshing dashboard data
|
||||
*/
|
||||
export function useRefreshDashboard() {
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated, tokens } = useAuthStore();
|
||||
|
||||
const refreshDashboard = async () => {
|
||||
if (!isAuthenticated || !tokens?.accessToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
// Invalidate and refetch dashboard queries
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: dashboardQueryKeys.all,
|
||||
});
|
||||
|
||||
// Optionally fetch fresh data immediately
|
||||
return queryClient.fetchQuery({
|
||||
queryKey: dashboardQueryKeys.summary(),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.POST('/dashboard/refresh');
|
||||
return response.data!;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { refreshDashboard };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching dashboard stats only (lightweight)
|
||||
*/
|
||||
export function useDashboardStats() {
|
||||
const { isAuthenticated, tokens } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: dashboardQueryKeys.stats(),
|
||||
queryFn: async () => {
|
||||
if (!tokens?.accessToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
const response = await apiClient.GET('/dashboard/stats');
|
||||
return response.data!;
|
||||
},
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
gcTime: 3 * 60 * 1000, // 3 minutes
|
||||
enabled: isAuthenticated && !!tokens?.accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching recent activity with filtering
|
||||
*/
|
||||
export function useDashboardActivity(filters?: string[], limit = 10) {
|
||||
const { isAuthenticated, tokens } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: dashboardQueryKeys.activity(filters),
|
||||
queryFn: async () => {
|
||||
if (!tokens?.accessToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
const response = await apiClient.GET('/dashboard/activity', {
|
||||
params: { query: { limit, filters: filters?.join(',') } }
|
||||
});
|
||||
return response.data!;
|
||||
},
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
gcTime: 2 * 60 * 1000, // 2 minutes
|
||||
enabled: isAuthenticated && !!tokens?.accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching next invoice information
|
||||
*/
|
||||
export function useNextInvoice() {
|
||||
const { isAuthenticated, tokens } = useAuthStore();
|
||||
|
||||
return useQuery({
|
||||
queryKey: dashboardQueryKeys.nextInvoice(),
|
||||
queryFn: async () => {
|
||||
if (!tokens?.accessToken) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
const response = await apiClient.GET('/dashboard/next-invoice');
|
||||
return response.data!;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
enabled: isAuthenticated && !!tokens?.accessToken,
|
||||
});
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ export interface CreateOrderRequest {
|
||||
}
|
||||
|
||||
async function createOrder<T = { sfOrderId: string }>(payload: CreateOrderRequest): Promise<T> {
|
||||
const response = await apiClient.POST("/orders", { body: payload });
|
||||
const response = await apiClient.POST("/api/orders", { body: payload });
|
||||
if (!response.data) {
|
||||
throw new Error("Order creation failed");
|
||||
}
|
||||
@ -15,12 +15,12 @@ async function createOrder<T = { sfOrderId: string }>(payload: CreateOrderReques
|
||||
}
|
||||
|
||||
async function getMyOrders<T = unknown[]>(): Promise<T> {
|
||||
const response = await apiClient.GET("/orders/user");
|
||||
const response = await apiClient.GET("/api/orders/user");
|
||||
return (response.data ?? []) as T;
|
||||
}
|
||||
|
||||
async function getOrderById<T = unknown>(orderId: string): Promise<T> {
|
||||
const response = await apiClient.GET("/orders/{sfOrderId}", {
|
||||
const response = await apiClient.GET("/api/orders/{sfOrderId}", {
|
||||
params: { path: { sfOrderId: orderId } },
|
||||
});
|
||||
if (!response.data) {
|
||||
|
||||
@ -42,7 +42,7 @@ export function ChangePlanModal({
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiClient.POST("/subscriptions/{id}/sim/change-plan", {
|
||||
await apiClient.POST("/api/subscriptions/{id}/sim/change-plan", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: {
|
||||
newPlanCode,
|
||||
|
||||
@ -58,7 +58,7 @@ export function SimActions({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiClient.POST("/subscriptions/{id}/sim/reissue-esim", {
|
||||
await apiClient.POST("/api/subscriptions/{id}/sim/reissue-esim", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
@ -77,7 +77,7 @@ export function SimActions({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiClient.POST("/subscriptions/{id}/sim/cancel", {
|
||||
await apiClient.POST("/api/subscriptions/{id}/sim/cancel", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
|
||||
@ -75,7 +75,7 @@ export function SimFeatureToggles({
|
||||
if (nt !== initial.nt) featurePayload.networkType = nt;
|
||||
|
||||
if (Object.keys(featurePayload).length > 0) {
|
||||
await apiClient.POST("/subscriptions/{id}/sim/features", {
|
||||
await apiClient.POST("/api/subscriptions/{id}/sim/features", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: featurePayload,
|
||||
});
|
||||
|
||||
@ -30,7 +30,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.GET("/subscriptions/{id}/sim", {
|
||||
const response = await apiClient.GET("/api/subscriptions/{id}/sim", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
|
||||
|
||||
@ -45,7 +45,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
quotaMb: getCurrentAmountMb(),
|
||||
};
|
||||
|
||||
await apiClient.POST("/subscriptions/{id}/sim/top-up", {
|
||||
await apiClient.POST("/api/subscriptions/{id}/sim/top-up", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -3,8 +3,9 @@
|
||||
* React hooks for subscription functionality using shared types
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient, queryKeys } from "@/core/api";
|
||||
import { getDataOrDefault, getDataOrThrow, getNullableData } from "@/core/api/response-helpers";
|
||||
import { useAuthSession } from "@/features/auth/services/auth.store";
|
||||
import type { InvoiceList, Subscription, SubscriptionList } from "@customer-portal/domain";
|
||||
|
||||
@ -57,13 +58,12 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
||||
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||
|
||||
return useQuery<SubscriptionList>({
|
||||
queryKey: queryKeys.subscriptions.list(status ? { status } : {}),
|
||||
queryKey: queryKeys.subscriptions.list(status ? { status } : undefined),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.GET(
|
||||
"/subscriptions",
|
||||
const response = await apiClient.GET("/api/subscriptions",
|
||||
status ? { params: { query: { status } } } : undefined
|
||||
);
|
||||
return toSubscriptionList(response.data);
|
||||
return toSubscriptionList(getNullableData(response));
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
@ -78,10 +78,10 @@ export function useActiveSubscriptions() {
|
||||
const { isAuthenticated, hasValidToken } = useAuthSession();
|
||||
|
||||
return useQuery<Subscription[]>({
|
||||
queryKey: [...queryKeys.subscriptions.all, "active"] as const,
|
||||
queryKey: queryKeys.subscriptions.active(),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.GET("/subscriptions/active");
|
||||
return response.data ?? [];
|
||||
const response = await apiClient.GET("/api/subscriptions/active");
|
||||
return getDataOrDefault(response, [] as Subscription[]);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
@ -98,8 +98,8 @@ export function useSubscriptionStats() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.subscriptions.stats(),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.GET("/subscriptions/stats");
|
||||
return response.data ?? emptyStats;
|
||||
const response = await apiClient.GET("/api/subscriptions/stats");
|
||||
return getDataOrDefault(response, emptyStats);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
@ -116,13 +116,10 @@ export function useSubscription(subscriptionId: number) {
|
||||
return useQuery<Subscription>({
|
||||
queryKey: queryKeys.subscriptions.detail(String(subscriptionId)),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.GET("/subscriptions/{id}", {
|
||||
const response = await apiClient.GET("/api/subscriptions/{id}", {
|
||||
params: { path: { id: subscriptionId } },
|
||||
});
|
||||
if (!response.data) {
|
||||
throw new Error("Subscription not found");
|
||||
}
|
||||
return response.data;
|
||||
return getDataOrThrow<Subscription>(response, "Subscription not found");
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
@ -143,44 +140,22 @@ export function useSubscriptionInvoices(
|
||||
return useQuery<InvoiceList>({
|
||||
queryKey: queryKeys.subscriptions.invoices(subscriptionId, { page, limit }),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.GET("/subscriptions/{id}/invoices", {
|
||||
const response = await apiClient.GET("/api/subscriptions/{id}/invoices", {
|
||||
params: {
|
||||
path: { id: subscriptionId },
|
||||
query: { page, limit },
|
||||
},
|
||||
});
|
||||
if (!response.data) {
|
||||
return {
|
||||
return getDataOrDefault(response, {
|
||||
...emptyInvoiceList,
|
||||
pagination: {
|
||||
...emptyInvoiceList.pagination,
|
||||
page,
|
||||
},
|
||||
};
|
||||
}
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
enabled: isAuthenticated && hasValidToken && subscriptionId > 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to perform subscription actions (suspend, resume, cancel, etc.)
|
||||
*/
|
||||
export function useSubscriptionAction() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, action }: { id: number; action: string }) => {
|
||||
await apiClient.POST("/subscriptions/{id}/actions", {
|
||||
params: { path: { id } },
|
||||
body: { action },
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export * as ApiTypes from "./__generated__/types";
|
||||
export { createClient } from "./runtime/client";
|
||||
export { createClient, resolveBaseUrl } from "./runtime/client";
|
||||
export type {
|
||||
ApiClient,
|
||||
AuthHeaderResolver,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import createOpenApiClient from "openapi-fetch";
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareCallbackParams,
|
||||
} from "openapi-fetch";
|
||||
import type { paths } from "../__generated__/types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@ -12,11 +15,72 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type ApiClient = ReturnType<typeof createOpenApiClient<paths>>;
|
||||
type StrictApiClient = ReturnType<typeof createOpenApiClient<paths>>;
|
||||
|
||||
type FlexibleApiMethods = {
|
||||
GET<T = unknown>(path: string, options?: unknown): Promise<{ data?: T | null }>;
|
||||
POST<T = unknown>(path: string, options?: unknown): Promise<{ data?: T | null }>;
|
||||
PUT<T = unknown>(path: string, options?: unknown): Promise<{ data?: T | null }>;
|
||||
PATCH<T = unknown>(path: string, options?: unknown): Promise<{ data?: T | null }>;
|
||||
DELETE<T = unknown>(path: string, options?: unknown): Promise<{ data?: T | null }>;
|
||||
};
|
||||
|
||||
export type ApiClient = StrictApiClient & FlexibleApiMethods;
|
||||
|
||||
export type AuthHeaderResolver = () => string | undefined;
|
||||
|
||||
type EnvKey =
|
||||
| "NEXT_PUBLIC_API_BASE"
|
||||
| "NEXT_PUBLIC_API_URL"
|
||||
| "API_BASE_URL"
|
||||
| "API_BASE"
|
||||
| "API_URL";
|
||||
|
||||
const BASE_URL_ENV_KEYS: readonly EnvKey[] = [
|
||||
"NEXT_PUBLIC_API_BASE",
|
||||
"NEXT_PUBLIC_API_URL",
|
||||
"API_BASE_URL",
|
||||
"API_BASE",
|
||||
"API_URL",
|
||||
];
|
||||
|
||||
const DEFAULT_BASE_URL = "http://localhost:4000/api";
|
||||
|
||||
const normalizeBaseUrl = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
if (trimmed === "/") {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Avoid accidental double slashes when openapi-fetch joins with request path
|
||||
return trimmed.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
const resolveBaseUrlFromEnv = () => {
|
||||
for (const key of BASE_URL_ENV_KEYS) {
|
||||
const envValue = process.env?.[key];
|
||||
if (typeof envValue === "string" && envValue.trim()) {
|
||||
return normalizeBaseUrl(envValue);
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_BASE_URL;
|
||||
};
|
||||
|
||||
export const resolveBaseUrl = (baseUrl?: string) => {
|
||||
if (typeof baseUrl === "string" && baseUrl.trim()) {
|
||||
return normalizeBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
return resolveBaseUrlFromEnv();
|
||||
};
|
||||
|
||||
export interface CreateClientOptions {
|
||||
baseUrl?: string;
|
||||
getAuthHeader?: AuthHeaderResolver;
|
||||
handleError?: (response: Response) => void | Promise<void>;
|
||||
}
|
||||
@ -49,10 +113,8 @@ async function defaultHandleError(response: Response) {
|
||||
throw new ApiError(message, response, body);
|
||||
}
|
||||
|
||||
export function createClient(
|
||||
baseUrl: string,
|
||||
options: CreateClientOptions = {}
|
||||
): ApiClient {
|
||||
export function createClient(options: CreateClientOptions = {}): ApiClient {
|
||||
const baseUrl = resolveBaseUrl(options.baseUrl);
|
||||
const client = createOpenApiClient<paths>({ baseUrl });
|
||||
|
||||
const handleError = options.handleError ?? defaultHandleError;
|
||||
@ -60,8 +122,8 @@ export function createClient(
|
||||
if (typeof client.use === "function") {
|
||||
const resolveAuthHeader = options.getAuthHeader;
|
||||
|
||||
client.use({
|
||||
onRequest({ request }) {
|
||||
const middleware: Middleware = {
|
||||
onRequest({ request }: MiddlewareCallbackParams) {
|
||||
if (!resolveAuthHeader) return;
|
||||
if (!request || typeof request.headers?.has !== "function") return;
|
||||
if (request.headers.has("Authorization")) return;
|
||||
@ -71,13 +133,15 @@ export function createClient(
|
||||
|
||||
request.headers.set("Authorization", headerValue);
|
||||
},
|
||||
async onResponse({ response }) {
|
||||
async onResponse({ response }: MiddlewareCallbackParams & { response: Response }) {
|
||||
await handleError(response);
|
||||
},
|
||||
} as never);
|
||||
};
|
||||
|
||||
client.use(middleware as never);
|
||||
}
|
||||
|
||||
return client;
|
||||
return client as ApiClient;
|
||||
}
|
||||
|
||||
export type { paths };
|
||||
|
||||
44
packages/domain/src/utils/currency.ts
Normal file
44
packages/domain/src/utils/currency.ts
Normal 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);
|
||||
};
|
||||
@ -2,4 +2,4 @@
|
||||
export * from "./validation";
|
||||
export * from "./array-utils";
|
||||
export * from "./filters";
|
||||
|
||||
export * from "./currency";
|
||||
|
||||
@ -103,16 +103,6 @@ export function sanitizeString(input: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats currency amount
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency = "JPY"): string {
|
||||
return new Intl.NumberFormat("ja-JP", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats date to locale string
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user