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