Skip to content

Commit ca1d94f

Browse files
authored
Merge pull request #347 from Smartdevs17/feature/frontend-enhancement-pack
feat(frontend): Add skeleton components, URL-synced pagination, and retry strategy
2 parents 074fdea + fbb66d7 commit ca1d94f

6 files changed

Lines changed: 459 additions & 3 deletions

File tree

frontend/src/components/ui/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export { Input } from "./input";
55
export { Select } from "./select";
66
export { Modal } from "./modal";
77
export { Drawer } from "./drawer";
8-
export { Spinner, LoadingState, Skeleton } from "./spinner";
8+
export { Spinner, LoadingState, Skeleton, CardSkeleton, ListItemSkeleton, FormSkeleton, TimelineItemSkeleton, StatCardSkeleton, TableRowSkeleton, AvatarSkeleton, BadgeSkeleton } from "./spinner";
99
export { Toast, ToastContainer } from "./toast";
1010
export type { ToastType } from "./toast";
1111
export { ToastProvider } from "./ToastProvider";

frontend/src/components/ui/spinner.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,94 @@ export function LoadingState({ label = "Loading…", size = "md", className }: L
5050
export function Skeleton({ className }: { className?: string }) {
5151
return <div className={cn("animate-pulse rounded-lg bg-surface-overlay", className)} />;
5252
}
53+
54+
/** Card skeleton placeholder */
55+
export function CardSkeleton({ className }: { className?: string }) {
56+
return (
57+
<div className={cn("rounded-2xl border border-border bg-surface-raised p-5", className)}>
58+
<div className="space-y-3">
59+
<Skeleton className="h-4 w-1/3" />
60+
<Skeleton className="h-3 w-full" />
61+
<Skeleton className="h-3 w-2/3" />
62+
</div>
63+
</div>
64+
);
65+
}
66+
67+
/** List item skeleton placeholder */
68+
export function ListItemSkeleton({ className }: { className?: string }) {
69+
return (
70+
<div className={cn("flex items-center gap-4 rounded-2xl border border-border bg-surface-raised p-4", className)}>
71+
<Skeleton className="h-10 w-10 rounded-full shrink-0" />
72+
<div className="flex-1 space-y-2 min-w-0">
73+
<Skeleton className="h-4 w-3/4" />
74+
<Skeleton className="h-3 w-1/2" />
75+
</div>
76+
<Skeleton className="h-8 w-20 rounded-xl shrink-0" />
77+
</div>
78+
);
79+
}
80+
81+
/** Form skeleton placeholder */
82+
export function FormSkeleton({ rows = 3, className }: { rows?: number; className?: string }) {
83+
return (
84+
<div className={cn("space-y-4", className)}>
85+
{Array.from({ length: rows }).map((_, i) => (
86+
<div key={i} className="space-y-2">
87+
<Skeleton className="h-3 w-24" />
88+
<Skeleton className="h-10 w-full" />
89+
</div>
90+
))}
91+
</div>
92+
);
93+
}
94+
95+
/** Timeline event skeleton placeholder */
96+
export function TimelineItemSkeleton({ className }: { className?: string }) {
97+
return (
98+
<div className={cn("flex gap-4", className)}>
99+
<div className="flex flex-col items-center">
100+
<Skeleton className="h-6 w-6 rounded-full" />
101+
<Skeleton className="w-0.5 flex-1" />
102+
</div>
103+
<div className="flex-1 space-y-2 pb-6">
104+
<Skeleton className="h-4 w-1/2" />
105+
<Skeleton className="h-3 w-3/4" />
106+
</div>
107+
</div>
108+
);
109+
}
110+
111+
/** Stat card skeleton placeholder */
112+
export function StatCardSkeleton({ className }: { className?: string }) {
113+
return (
114+
<div className={cn("rounded-2xl border border-border bg-surface-overlay/40 p-4", className)}>
115+
<Skeleton className="h-3 w-20 mb-3" />
116+
<Skeleton className="h-8 w-16" />
117+
</div>
118+
);
119+
}
120+
121+
/** Table row skeleton placeholder */
122+
export function TableRowSkeleton({ cols = 5, className }: { cols?: number; className?: string }) {
123+
return (
124+
<tr className={cn("border-b border-border", className)}>
125+
{Array.from({ length: cols }).map((_, i) => (
126+
<td key={i} className="px-4 py-3">
127+
<Skeleton className="h-4 w-full" />
128+
</td>
129+
))}
130+
</tr>
131+
);
132+
}
133+
134+
/** Avatar skeleton placeholder */
135+
export function AvatarSkeleton({ size = "md", className }: { size?: "sm" | "md" | "lg"; className?: string }) {
136+
const sizeStyles = { sm: "h-8 w-8", md: "h-10 w-10", lg: "h-14 w-14" };
137+
return <Skeleton className={cn("rounded-full", sizeStyles[size], className)} />;
138+
}
139+
140+
/** Badge skeleton placeholder */
141+
export function BadgeSkeleton({ className }: { className?: string }) {
142+
return <Skeleton className={cn("h-6 w-20 rounded-full", className)} />;
143+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"use client";
2+
3+
import { useCallback, useMemo, useEffect } from "react";
4+
import { useSearchParams, useRouter, usePathname } from "next/navigation";
5+
import { usePagination } from "./usePagination";
6+
7+
const DEFAULT_PAGE_PARAM = "page";
8+
9+
export function usePaginationWithURL(
10+
totalItems: number,
11+
pageSize: number,
12+
options: {
13+
paramName?: string;
14+
initialPage?: number;
15+
onPageChange?: (page: number) => void;
16+
} = {}
17+
) {
18+
const { paramName = DEFAULT_PAGE_PARAM, initialPage = 1, onPageChange } = options;
19+
20+
const searchParams = useSearchParams();
21+
const router = useRouter();
22+
const pathname = usePathname();
23+
24+
const pageParam = searchParams.get(paramName);
25+
const pageFromUrl = pageParam ? parseInt(pageParam, 10) : initialPage;
26+
const safePageFromUrl = Number.isFinite(pageFromUrl) && pageFromUrl > 0 ? pageFromUrl : initialPage;
27+
28+
const pagination = usePagination(totalItems, pageSize, safePageFromUrl);
29+
30+
useEffect(() => {
31+
if (pagination.page !== safePageFromUrl) {
32+
pagination.setPage(safePageFromUrl);
33+
}
34+
}, [pagination, safePageFromUrl]);
35+
36+
const setPage = useCallback(
37+
(page: number) => {
38+
pagination.setPage(page);
39+
onPageChange?.(page);
40+
},
41+
[pagination, onPageChange]
42+
);
43+
44+
const pushPageToUrl = useCallback(
45+
(page: number) => {
46+
const params = new URLSearchParams(searchParams.toString());
47+
if (page === initialPage) {
48+
params.delete(paramName);
49+
} else {
50+
params.set(paramName, String(page));
51+
}
52+
const queryString = params.toString();
53+
router.push(`${pathname}${queryString ? `?${queryString}` : ""}`, { scroll: false });
54+
},
55+
[initialPage, paramName, pathname, router, searchParams]
56+
);
57+
58+
const setPageAndSync = useCallback(
59+
(page: number) => {
60+
setPage(page);
61+
pushPageToUrl(page);
62+
},
63+
[setPage, pushPageToUrl]
64+
);
65+
66+
const reset = useCallback(() => {
67+
pagination.reset();
68+
pushPageToUrl(initialPage);
69+
}, [pagination, pushPageToUrl, initialPage]);
70+
71+
const controls = useMemo(
72+
() => ({
73+
...pagination,
74+
setPage,
75+
setPageAndSync,
76+
reset,
77+
pushPageToUrl,
78+
}),
79+
[pagination, setPage, setPageAndSync, reset, pushPageToUrl]
80+
);
81+
82+
return controls;
83+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"use client";
2+
3+
import { useCallback, useRef } from "react";
4+
import axios, { AxiosError, AxiosRequestConfig } from "axios";
5+
6+
export const IDEMPOTENT_METHODS = ["GET", "HEAD", "OPTIONS"];
7+
8+
export interface RetryOptions {
9+
maxRetries?: number;
10+
initialDelayMs?: number;
11+
maxDelayMs?: number;
12+
backoffMultiplier?: number;
13+
onRetry?: (attempt: number, error: AxiosError, delayMs: number) => void;
14+
onMaxRetriesExceeded?: (error: AxiosError) => void;
15+
}
16+
17+
export interface RequestOptions extends RetryOptions {
18+
silent?: boolean;
19+
}
20+
21+
function calculateBackoff(delay: number, multiplier: number, jitter: number, maxDelay: number): number {
22+
const next = Math.min(delay * multiplier, maxDelay);
23+
return Math.round(next + jitter * next);
24+
}
25+
26+
export function useRequestRetry() {
27+
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
28+
29+
const isIdempotent = useCallback((method?: string): boolean => {
30+
return IDEMPOTENT_METHODS.includes((method ?? "").toUpperCase());
31+
}, []);
32+
33+
const requestWithRetry = useCallback(
34+
async <T>(
35+
config: AxiosRequestConfig,
36+
options: RequestOptions = {}
37+
): Promise<T> => {
38+
const {
39+
maxRetries = 3,
40+
initialDelayMs = 1000,
41+
maxDelayMs = 30000,
42+
backoffMultiplier = 2,
43+
onRetry,
44+
onMaxRetriesExceeded,
45+
silent = false,
46+
} = options;
47+
48+
let attempt = 0;
49+
let delayMs = initialDelayMs;
50+
let lastError: AxiosError | undefined;
51+
52+
while (attempt <= maxRetries) {
53+
try {
54+
const response = await axios.request<T>({ ...config });
55+
return response.data;
56+
} catch (error) {
57+
if (!axios.isAxiosError(error)) {
58+
throw error;
59+
}
60+
61+
lastError = error as AxiosError;
62+
63+
if (attempt === maxRetries) {
64+
if (!silent) {
65+
console.error(`[RequestRetry] Max retries (${maxRetries}) exceeded:`, error);
66+
}
67+
onMaxRetriesExceeded?.(lastError);
68+
throw error;
69+
}
70+
71+
const isServerError = (error.response?.status ?? 0) >= 500;
72+
const isNetworkError = !error.response;
73+
const isTimeout = axios.isAxiosError(error) && error.code === "ECONNABORTED";
74+
75+
if (!isServerError && !isNetworkError && !isTimeout) {
76+
if (!silent) {
77+
console.warn(`[RequestRetry] Non-retryable error (${error.response?.status}):`, error.message);
78+
}
79+
throw error;
80+
}
81+
82+
const jitter = Math.random() * 0.2;
83+
onRetry?.(attempt + 1, lastError, delayMs);
84+
85+
await new Promise((resolve) => setTimeout(resolve, delayMs));
86+
delayMs = calculateBackoff(delayMs, backoffMultiplier, jitter, maxDelayMs);
87+
attempt++;
88+
}
89+
}
90+
91+
throw lastError;
92+
},
93+
[]
94+
);
95+
96+
const abortAll = useCallback(() => {
97+
abortControllersRef.current.forEach((controller) => controller.abort());
98+
abortControllersRef.current.clear();
99+
}, []);
100+
101+
const abort = useCallback((key: string) => {
102+
const controller = abortControllersRef.current.get(key);
103+
if (controller) {
104+
controller.abort();
105+
abortControllersRef.current.delete(key);
106+
}
107+
}, []);
108+
109+
const trackedRequest = useCallback(
110+
async <T>(
111+
key: string,
112+
config: AxiosRequestConfig,
113+
options: RequestOptions = {}
114+
): Promise<T> => {
115+
const controller = new AbortController();
116+
abortControllersRef.current.set(key, controller);
117+
try {
118+
return await requestWithRetry<T>({ ...config, signal: controller.signal }, options);
119+
} finally {
120+
abortControllersRef.current.delete(key);
121+
}
122+
},
123+
[requestWithRetry]
124+
);
125+
126+
return { requestWithRetry, trackedRequest, isIdempotent, abortAll, abort };
127+
}
128+
129+
export function isRetryableError(error: unknown): boolean {
130+
if (!axios.isAxiosError(error)) return false;
131+
const status = error.response?.status ?? 0;
132+
return status >= 500 || status === 0 || error.code === "ECONNABORTED";
133+
}

frontend/src/lib/queryClient.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import { QueryClient } from "@tanstack/react-query";
22

3+
function isRetryableError(error: unknown): boolean {
4+
if (!error || typeof error !== "object") return false;
5+
const status = (error as { response?: { status?: number }; code?: string }).response?.status;
6+
if (status === undefined) return true;
7+
return status >= 500;
8+
}
9+
310
export const queryClient = new QueryClient({
411
defaultOptions: {
512
queries: {
613
staleTime: 30_000,
7-
retry: 2,
814
refetchOnWindowFocus: false,
15+
retry: (failureCount, error) => {
16+
if (failureCount > 3) return false;
17+
return isRetryableError(error);
18+
},
19+
retryDelay: (attemptIndex: number) => Math.min(1000 * Math.pow(2, attemptIndex), 30000),
920
},
1021
mutations: {
11-
retry: 1,
22+
retry: (failureCount, error) => {
23+
if (failureCount > 2) return false;
24+
return isRetryableError(error);
25+
},
26+
retryDelay: (attemptIndex: number) => Math.min(1000 * Math.pow(2, attemptIndex), 30000),
1227
},
1328
},
1429
});

0 commit comments

Comments
 (0)