Refactor authentication components to improve loading state management and user experience. Update Button component to utilize a Spinner for loading indicators, enhancing visual feedback during form submissions. Integrate LoadingOverlay in LoginView and SignupView for a full-page loading experience during authentication processes. Adjust useAuthStore to manage loading states more effectively, ensuring a smoother user experience during redirects.
This commit is contained in:
parent
2b54773ebf
commit
6390749150
@ -6,7 +6,7 @@ import type { Request } from "express";
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FailedLoginThrottleGuard {
|
export class FailedLoginThrottleGuard {
|
||||||
constructor(@Inject("REDIS") private readonly redis: Redis) {}
|
constructor(@Inject("REDIS_CLIENT") private readonly redis: Redis) {}
|
||||||
|
|
||||||
private getTracker(req: Request): string {
|
private getTracker(req: Request): string {
|
||||||
// Track by IP address + User Agent for failed login attempts only
|
// Track by IP address + User Agent for failed login attempts only
|
||||||
|
|||||||
41
apps/portal/src/components/atoms/LoadingOverlay.tsx
Normal file
41
apps/portal/src/components/atoms/LoadingOverlay.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Spinner } from "./Spinner";
|
||||||
|
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
/** Whether the overlay is visible */
|
||||||
|
isVisible: boolean;
|
||||||
|
/** Main loading message */
|
||||||
|
title: string;
|
||||||
|
/** Optional subtitle/description */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Spinner size */
|
||||||
|
spinnerSize?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||||
|
/** Custom spinner color */
|
||||||
|
spinnerClassName?: string;
|
||||||
|
/** Custom overlay background */
|
||||||
|
overlayClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingOverlay({
|
||||||
|
isVisible,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
spinnerSize = "xl",
|
||||||
|
spinnerClassName = "text-blue-600",
|
||||||
|
overlayClassName = "bg-white/80 backdrop-blur-sm",
|
||||||
|
}: LoadingOverlayProps) {
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 z-50 flex items-center justify-center ${overlayClassName}`}>
|
||||||
|
<div className="text-center">
|
||||||
|
<Spinner size={spinnerSize} className={`mb-4 ${spinnerClassName}`} />
|
||||||
|
<p className="text-lg font-medium text-gray-900">{title}</p>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/portal/src/components/atoms/Spinner.tsx
Normal file
42
apps/portal/src/components/atoms/Spinner.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SpinnerProps {
|
||||||
|
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
xs: "h-3 w-3",
|
||||||
|
sm: "h-4 w-4",
|
||||||
|
md: "h-6 w-6",
|
||||||
|
lg: "h-8 w-8",
|
||||||
|
xl: "h-10 w-10",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Spinner({ size = "sm", className }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={cn(
|
||||||
|
"animate-spin text-current",
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@ import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "reac
|
|||||||
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 "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
// Loading spinner removed - using inline spinner for buttons
|
import { Spinner } from "./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",
|
||||||
@ -75,15 +75,11 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
|
|||||||
aria-busy={loading || undefined}
|
aria-busy={loading || undefined}
|
||||||
{...anchorProps}
|
{...anchorProps}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center justify-center gap-2">
|
||||||
{loading ? (
|
{loading ? <Spinner size="sm" /> : leftIcon}
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
|
<span>{loading ? (loadingText ?? children) : children}</span>
|
||||||
) : (
|
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
||||||
leftIcon
|
</span>
|
||||||
)}
|
|
||||||
<span>{loading ? (loadingText ?? children) : children}</span>
|
|
||||||
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -104,12 +100,8 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
|
|||||||
aria-busy={loading || undefined}
|
aria-busy={loading || undefined}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center justify-center gap-2">
|
||||||
{loading ? (
|
{loading ? <Spinner size="sm" /> : leftIcon}
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border border-current border-t-transparent" />
|
|
||||||
) : (
|
|
||||||
leftIcon
|
|
||||||
)}
|
|
||||||
<span>{loading ? (loadingText ?? children) : children}</span>
|
<span>{loading ? (loadingText ?? children) : children}</span>
|
||||||
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
{!loading && rightIcon ? <span className="ml-1">{rightIcon}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -26,7 +26,9 @@ export type { StatusPillProps } from "./status-pill";
|
|||||||
export { Badge, badgeVariants } from "./badge";
|
export { Badge, badgeVariants } from "./badge";
|
||||||
export type { BadgeProps } from "./badge";
|
export type { BadgeProps } from "./badge";
|
||||||
|
|
||||||
// Loading components consolidated into skeleton loading
|
// Loading components
|
||||||
|
export { Spinner } from "./Spinner";
|
||||||
|
export { LoadingOverlay } from "./LoadingOverlay";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ErrorState,
|
ErrorState,
|
||||||
|
|||||||
@ -76,15 +76,14 @@ export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormPr
|
|||||||
|
|
||||||
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
|
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
|
||||||
|
|
||||||
<Button type="submit" disabled={isSubmitting || loading} className="w-full">
|
<Button
|
||||||
{isSubmitting || loading ? (
|
type="submit"
|
||||||
<>
|
disabled={isSubmitting || loading}
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
loading={isSubmitting || loading}
|
||||||
Linking Account...
|
loadingText="Linking Account..."
|
||||||
</>
|
className="w-full"
|
||||||
) : (
|
>
|
||||||
"Link WHMCS Account"
|
Link WHMCS Account
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@ -121,15 +121,14 @@ export function LoginForm({
|
|||||||
|
|
||||||
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
|
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
|
||||||
|
|
||||||
<Button type="submit" disabled={isSubmitting || loading} className="w-full">
|
<Button
|
||||||
{isSubmitting || loading ? (
|
type="submit"
|
||||||
<>
|
disabled={isSubmitting || loading}
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
loading={isSubmitting || loading}
|
||||||
Signing in...
|
loadingText="Signing in..."
|
||||||
</>
|
className="w-full"
|
||||||
) : (
|
>
|
||||||
"Sign in"
|
Sign in
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showSignupLink && (
|
{showSignupLink && (
|
||||||
|
|||||||
@ -35,14 +35,17 @@ export function useAuth() {
|
|||||||
checkAuth,
|
checkAuth,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
clearError,
|
clearError,
|
||||||
|
clearLoading,
|
||||||
} = useAuthStore();
|
} = useAuthStore();
|
||||||
|
|
||||||
// Enhanced login with redirect handling
|
// Enhanced login with redirect handling
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (credentials: LoginRequestInput) => {
|
async (credentials: LoginRequestInput) => {
|
||||||
await loginAction(credentials);
|
await loginAction(credentials);
|
||||||
|
// Keep loading state active during redirect
|
||||||
const redirectTo = getPostLoginRedirect(searchParams);
|
const redirectTo = getPostLoginRedirect(searchParams);
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
|
// Note: loading will be cleared when the new page loads
|
||||||
},
|
},
|
||||||
[loginAction, router, searchParams]
|
[loginAction, router, searchParams]
|
||||||
);
|
);
|
||||||
@ -84,6 +87,7 @@ export function useAuth() {
|
|||||||
checkAuth,
|
checkAuth,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
clearError,
|
clearError,
|
||||||
|
clearLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export interface AuthState {
|
|||||||
refreshSession: () => Promise<void>;
|
refreshSession: () => Promise<void>;
|
||||||
checkAuth: () => Promise<void>;
|
checkAuth: () => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
clearLoading: () => void;
|
||||||
hydrateUserProfile: (profile: Partial<AuthenticatedUser>) => void;
|
hydrateUserProfile: (profile: Partial<AuthenticatedUser>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +54,7 @@ type AuthResponseData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()((set, get) => {
|
export const useAuthStore = create<AuthState>()((set, get) => {
|
||||||
const applyAuthResponse = (data: AuthResponseData) => {
|
const applyAuthResponse = (data: AuthResponseData, keepLoading = false) => {
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
session: {
|
session: {
|
||||||
@ -61,7 +62,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
refreshExpiresAt: data.tokens.refreshExpiresAt,
|
refreshExpiresAt: data.tokens.refreshExpiresAt,
|
||||||
},
|
},
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
loading: false,
|
loading: keepLoading,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -82,7 +83,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed");
|
throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed");
|
||||||
}
|
}
|
||||||
applyAuthResponse(parsed.data);
|
applyAuthResponse(parsed.data, true); // Keep loading for redirect
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorInfo = getErrorInfo(error);
|
const errorInfo = getErrorInfo(error);
|
||||||
set({ loading: false, error: errorInfo.message, isAuthenticated: false });
|
set({ loading: false, error: errorInfo.message, isAuthenticated: false });
|
||||||
@ -302,6 +303,8 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
clearLoading: () => set({ loading: false }),
|
||||||
|
|
||||||
hydrateUserProfile: profile => {
|
hydrateUserProfile: profile => {
|
||||||
set(state => {
|
set(state => {
|
||||||
if (!state.user) {
|
if (!state.user) {
|
||||||
|
|||||||
@ -2,12 +2,25 @@
|
|||||||
|
|
||||||
import { AuthLayout } from "../components";
|
import { AuthLayout } from "../components";
|
||||||
import { LoginForm } from "@/features/auth/components";
|
import { LoginForm } from "@/features/auth/components";
|
||||||
|
import { useAuthStore } from "../services/auth.store";
|
||||||
|
import { LoadingOverlay } from "@/components/atoms";
|
||||||
|
|
||||||
export function LoginView() {
|
export function LoginView() {
|
||||||
|
const { loading, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout title="Welcome back" subtitle="Sign in to your Assist Solutions account">
|
<>
|
||||||
<LoginForm />
|
<AuthLayout title="Welcome back" subtitle="Sign in to your Assist Solutions account">
|
||||||
</AuthLayout>
|
<LoginForm />
|
||||||
|
</AuthLayout>
|
||||||
|
|
||||||
|
{/* Full-page loading overlay during authentication */}
|
||||||
|
<LoadingOverlay
|
||||||
|
isVisible={loading && isAuthenticated}
|
||||||
|
title="Redirecting to dashboard..."
|
||||||
|
subtitle="Please wait while we load your account"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { AuthLayout } from "../components";
|
import { AuthLayout } from "../components";
|
||||||
import { SetPasswordForm } from "@/features/auth/components";
|
import { SetPasswordForm } from "@/features/auth/components";
|
||||||
|
import { LoadingOverlay } from "@/components/atoms";
|
||||||
|
|
||||||
function SetPasswordContent() {
|
function SetPasswordContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -87,9 +88,12 @@ export function SetPasswordView() {
|
|||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<AuthLayout title="Set password" subtitle="Preparing your account transfer...">
|
<AuthLayout title="Set password" subtitle="Preparing your account transfer...">
|
||||||
<div className="flex items-center justify-center py-8">
|
<LoadingOverlay
|
||||||
<div className="h-10 w-10 border-b-2 border-blue-600 rounded-full animate-spin" />
|
isVisible={true}
|
||||||
</div>
|
title="Preparing your account..."
|
||||||
|
subtitle="Please wait while we set up your transfer"
|
||||||
|
overlayClassName="bg-transparent"
|
||||||
|
/>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -2,25 +2,38 @@
|
|||||||
|
|
||||||
import { AuthLayout } from "../components";
|
import { AuthLayout } from "../components";
|
||||||
import { SignupForm } from "@/features/auth/components";
|
import { SignupForm } from "@/features/auth/components";
|
||||||
|
import { useAuthStore } from "../services/auth.store";
|
||||||
|
import { LoadingOverlay } from "@/components/atoms";
|
||||||
|
|
||||||
export function SignupView() {
|
export function SignupView() {
|
||||||
|
const { loading, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout
|
<>
|
||||||
title="Create your portal account"
|
<AuthLayout
|
||||||
subtitle="Verify your details and set up secure access in a few guided steps"
|
title="Create your portal account"
|
||||||
>
|
subtitle="Verify your details and set up secure access in a few guided steps"
|
||||||
<div className="space-y-8">
|
>
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5">
|
<div className="space-y-8">
|
||||||
<h2 className="text-sm font-semibold text-blue-800 mb-3">What you'll need</h2>
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5">
|
||||||
<ul className="text-sm text-blue-700 space-y-2 list-disc list-inside">
|
<h2 className="text-sm font-semibold text-blue-800 mb-3">What you'll need</h2>
|
||||||
<li>Your Assist Solutions customer number</li>
|
<ul className="text-sm text-blue-700 space-y-2 list-disc list-inside">
|
||||||
<li>Primary contact details and service address</li>
|
<li>Your Assist Solutions customer number</li>
|
||||||
<li>A secure password that meets our enhanced requirements</li>
|
<li>Primary contact details and service address</li>
|
||||||
</ul>
|
<li>A secure password that meets our enhanced requirements</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<SignupForm />
|
||||||
</div>
|
</div>
|
||||||
<SignupForm />
|
</AuthLayout>
|
||||||
</div>
|
|
||||||
</AuthLayout>
|
{/* Full-page loading overlay during authentication */}
|
||||||
|
<LoadingOverlay
|
||||||
|
isVisible={loading && isAuthenticated}
|
||||||
|
title="Setting up your account..."
|
||||||
|
subtitle="Please wait while we prepare your dashboard"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export function useDashboardSummary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.GET<DashboardSummary>("/api/users/summary");
|
const response = await apiClient.GET<DashboardSummary>("/api/me/summary");
|
||||||
return getDataOrThrow<DashboardSummary>(response, "Dashboard summary response was empty");
|
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
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Activity, DashboardSummary } from "@customer-portal/domain";
|
import type { Activity, DashboardSummary } from "@customer-portal/domain";
|
||||||
@ -31,7 +31,12 @@ import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
|||||||
|
|
||||||
export function DashboardView() {
|
export function DashboardView() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, isAuthenticated, loading: authLoading } = useAuthStore();
|
const { user, isAuthenticated, loading: authLoading, clearLoading } = useAuthStore();
|
||||||
|
|
||||||
|
// Clear auth loading state when dashboard loads (after successful login)
|
||||||
|
useEffect(() => {
|
||||||
|
clearLoading();
|
||||||
|
}, [clearLoading]);
|
||||||
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
|
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
|
||||||
const upcomingInvoice = summary?.nextInvoice ?? null;
|
const upcomingInvoice = summary?.nextInvoice ?? null;
|
||||||
const createSsoLinkMutation = useCreateInvoiceSsoLink();
|
const createSsoLinkMutation = useCreateInvoiceSsoLink();
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export function useSubscriptions(options: UseSubscriptionsOptions = {}) {
|
|||||||
queryKey: queryKeys.subscriptions.list(status ? { status } : undefined),
|
queryKey: queryKeys.subscriptions.list(status ? { status } : undefined),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await apiClient.GET<SubscriptionList>(
|
const response = await apiClient.GET<SubscriptionList>(
|
||||||
"/subscriptions",
|
"/api/subscriptions",
|
||||||
status ? { params: { query: { status } } } : undefined
|
status ? { params: { query: { status } } } : undefined
|
||||||
);
|
);
|
||||||
return toSubscriptionList(getNullableData<SubscriptionList>(response));
|
return toSubscriptionList(getNullableData<SubscriptionList>(response));
|
||||||
@ -96,7 +96,7 @@ export function useSubscriptionStats() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.subscriptions.stats(),
|
queryKey: queryKeys.subscriptions.stats(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await apiClient.GET<typeof emptyStats>("/subscriptions/stats");
|
const response = await apiClient.GET<typeof emptyStats>("/api/subscriptions/stats");
|
||||||
return getDataOrDefault<typeof emptyStats>(response, emptyStats);
|
return getDataOrDefault<typeof emptyStats>(response, emptyStats);
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user