Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions frontend/components/ui/StateDisplay.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"use client";

import LoadingSpinner from "./LoadingSpinner";
import { AlertCircle, Inbox, RefreshCw } from "lucide-react";

/**
* Consistent loading state shown while API requests are in flight.
*/
export function LoadingState({ message = "Loading...", size = "md", className }) {
return (
<div className={`flex flex-col items-center justify-center gap-3 py-12 ${className ?? ""}`}>
<LoadingSpinner size={size} />
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}

/**
* Consistent error state with retry support.
*/
export function ErrorState({ error, onRetry, className }) {
const message =
typeof error === "string"
? error
: error?.message || "Something went wrong. Please try again.";

return (
<div className={`flex flex-col items-center justify-center gap-4 py-12 ${className ?? ""}`}>
<div className="rounded-full bg-red-50 p-3 dark:bg-red-950">
<AlertCircle className="h-6 w-6 text-red-500" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-foreground">Error</p>
<p className="mt-1 text-sm text-muted-foreground">{message}</p>
</div>
{onRetry && (
<button
onClick={onRetry}
className="inline-flex items-center gap-2 rounded-md bg-red-50 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:bg-red-950 dark:text-red-400 dark:hover:bg-red-900"
>
<RefreshCw className="h-4 w-4" />
Try again
</button>
)}
</div>
);
}

/**
* Consistent empty state when data returns an empty array / no results.
*/
export function EmptyState({ title = "Nothing here yet", description, icon: Icon, action, className }) {
return (
<div className={`flex flex-col items-center justify-center gap-4 py-12 ${className ?? ""}`}>
<div className="rounded-full bg-gray-100 p-3 dark:bg-gray-800">
{Icon ? <Icon className="h-6 w-6 text-gray-400" /> : <Inbox className="h-6 w-6 text-gray-400" />}
</div>
<div className="text-center">
<p className="text-sm font-medium text-foreground">{title}</p>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{action}
</div>
);
}

/**
* Composite component that picks the right state based on loading / error / data.
*
* Usage:
* <ApiStateDisplay
* isLoading={isLoading}
* error={error}
* data={items}
* onRetry={refetch}
* emptyTitle="No reviews"
* emptyDescription="There are no reviews to display."
* >
* {(items) => <ReviewList reviews={items} />}
* </ApiStateDisplay>
*/
export default function ApiStateDisplay({
isLoading,
error,
data,
onRetry,
loadingMessage,
emptyTitle,
emptyDescription,
emptyIcon,
emptyAction,
children,
}) {
if (isLoading) return <LoadingState message={loadingMessage} />;
if (error) return <ErrorState error={error} onRetry={onRetry} />;

const isEmpty =
data === null ||
data === undefined ||
(Array.isArray(data) && data.length === 0);

if (isEmpty) {
return (
<EmptyState
title={emptyTitle}
description={emptyDescription}
icon={emptyIcon}
action={emptyAction}
/>
);
}

return typeof children === "function" ? children(data) : children;
}
84 changes: 84 additions & 0 deletions frontend/hooks/useApiMutation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"use client";

import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";

/**
* Shared mutation hook with consistent error handling and cache invalidation.
*
* Features:
* - Automatic retry (configurable)
* - Query invalidation after successful mutation
* - User-facing error message extraction
* - Loading / error / data state tracking
* - Optional optimistic update support
*
* @param {Object} options
* @param {Function} options.fn – Async mutation function
* @param {string[]} [options.invalidate] – Query key prefix to invalidate on success
* @param {Function} [options.onSuccess] – Additional callback after success
* @param {Function} [options.onError] – Additional callback after error
* @param {number} [options.retry=1] – Number of retries
* @param {Object} [options.mutationOptions] – Extra options forwarded to useMutation
*
* @returns {{ mutate, mutateAsync, isLoading, error, data, reset }}
*/
export function useApiMutation({
fn,
invalidate,
onSuccess,
onError,
retry = 1,
...mutationOptions
}) {
const queryClient = useQueryClient();
const [userError, setUserError] = useState(null);

const mutation = useMutation({
mutationFn: async (variables) => {
setUserError(null);
return fn(variables);
},
retry,
onSuccess: async (data, variables, context) => {
// Invalidate related queries so they refetch
if (invalidate) {
const keys = Array.isArray(invalidate) ? invalidate : [invalidate];
for (const key of keys) {
await queryClient.invalidateQueries({ queryKey: [key] });
}
}
onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
const message = extractErrorMessage(error);
setUserError(message);
onError?.(error, variables, context);
},
...mutationOptions,
});

const reset = useCallback(() => {
setUserError(null);
mutation.reset();
}, [mutation]);

return {
...mutation,
userError,
reset,
};
}

/**
* Extract a human-readable error message from various error shapes.
*/
function extractErrorMessage(error) {
if (typeof error === "string") return error;
if (error?.response?.data?.message) return error.response.data.message;
if (error?.response?.data?.error) return error.response.data.error;
if (error?.message) return error.message;
return "An unexpected error occurred. Please try again.";
}

export default useApiMutation;
70 changes: 70 additions & 0 deletions frontend/hooks/useApiQuery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import { useQuery, useQueryClient } from "@tanstack/react-query";

/**
* Shared query hook with sensible defaults for the StellarHunt frontend.
*
* Features:
* - Automatic retry with exponential backoff (3 attempts)
* - Query-key based caching with stale-data revalidation
* - Abort/cancellation support via React Query internals
* - Configurable stale-time and refetch behaviour
*
* @param {Object} options
* @param {string[]} options.key – Query key (unique identifier)
* @param {Function} options.fn – Async function that returns data
* @param {number} [options.staleTime=60_000] – Ms before data is considered stale
* @param {number} [options.gcTime=300_000] – Ms before inactive cache is garbage-collected
* @param {boolean} [options.enabled=true] – Whether the query should run
* @param {number} [options.retry=3] – Number of retries
* @param {number} [options.retryDelay] – Custom retry delay (ms); defaults to exponential
* @param {Object} [options.queryOptions] – Extra options forwarded to useQuery
*
* @returns {import("@tanstack/react-query").UseQueryResult}
*/
export function useApiQuery({
key,
fn,
staleTime = 60_000,
gcTime = 300_000,
enabled = true,
retry = 3,
retryDelay,
...queryOptions
}) {
return useQuery({

Check failure on line 36 in frontend/hooks/useApiQuery.js

View workflow job for this annotation

GitHub Actions / Frontend tests

tests/useReferral.test.js > useReferral > getRewardTier > returns 10 tier for NaN invites

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../node_modules/@tanstack/react-query/src/useBaseQuery.ts:53:17 ❯ useQuery ../node_modules/@tanstack/react-query/src/useQuery.ts:46:9 ❯ useApiQuery hooks/useApiQuery.js:36:10 ❯ Module.useReferral hooks/useReferral.js:17:25 ❯ tests/useReferral.test.js:97:43 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:339:27 ❯ renderWithHooks ../node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent ../node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork ../node_modules/react-dom/cjs/react-dom.development.js:21626:16

Check failure on line 36 in frontend/hooks/useApiQuery.js

View workflow job for this annotation

GitHub Actions / Frontend tests

tests/useReferral.test.js > useReferral > getRewardTier > returns 8 tier for NaN invites

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../node_modules/@tanstack/react-query/src/useBaseQuery.ts:53:17 ❯ useQuery ../node_modules/@tanstack/react-query/src/useQuery.ts:46:9 ❯ useApiQuery hooks/useApiQuery.js:36:10 ❯ Module.useReferral hooks/useReferral.js:17:25 ❯ tests/useReferral.test.js:97:43 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:339:27 ❯ renderWithHooks ../node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent ../node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork ../node_modules/react-dom/cjs/react-dom.development.js:21626:16

Check failure on line 36 in frontend/hooks/useApiQuery.js

View workflow job for this annotation

GitHub Actions / Frontend tests

tests/useReferral.test.js > useReferral > getRewardTier > returns 5 tier for NaN invites

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../node_modules/@tanstack/react-query/src/useBaseQuery.ts:53:17 ❯ useQuery ../node_modules/@tanstack/react-query/src/useQuery.ts:46:9 ❯ useApiQuery hooks/useApiQuery.js:36:10 ❯ Module.useReferral hooks/useReferral.js:17:25 ❯ tests/useReferral.test.js:97:43 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:339:27 ❯ renderWithHooks ../node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent ../node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork ../node_modules/react-dom/cjs/react-dom.development.js:21626:16

Check failure on line 36 in frontend/hooks/useApiQuery.js

View workflow job for this annotation

GitHub Actions / Frontend tests

tests/useReferral.test.js > useReferral > getRewardTier > returns 3 tier for NaN invites

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../node_modules/@tanstack/react-query/src/useBaseQuery.ts:53:17 ❯ useQuery ../node_modules/@tanstack/react-query/src/useQuery.ts:46:9 ❯ useApiQuery hooks/useApiQuery.js:36:10 ❯ Module.useReferral hooks/useReferral.js:17:25 ❯ tests/useReferral.test.js:97:43 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:339:27 ❯ renderWithHooks ../node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent ../node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork ../node_modules/react-dom/cjs/react-dom.development.js:21626:16

Check failure on line 36 in frontend/hooks/useApiQuery.js

View workflow job for this annotation

GitHub Actions / Frontend tests

tests/useReferral.test.js > useReferral > getRewardTier > returns 0 tier for NaN invites

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../node_modules/@tanstack/react-query/src/useBaseQuery.ts:53:17 ❯ useQuery ../node_modules/@tanstack/react-query/src/useQuery.ts:46:9 ❯ useApiQuery hooks/useApiQuery.js:36:10 ❯ Module.useReferral hooks/useReferral.js:17:25 ❯ tests/useReferral.test.js:97:43 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:339:27 ❯ renderWithHooks ../node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent ../node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork ../node_modules/react-dom/cjs/react-dom.development.js:21626:16

Check failure on line 36 in frontend/hooks/useApiQuery.js

View workflow job for this annotation

GitHub Actions / Frontend tests

tests/useReferral.test.js > useReferral > fetchReferralData > does nothing when no userId is provided

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../node_modules/@tanstack/react-query/src/useBaseQuery.ts:53:17 ❯ useQuery ../node_modules/@tanstack/react-query/src/useQuery.ts:46:9 ❯ useApiQuery hooks/useApiQuery.js:36:10 ❯ Module.useReferral hooks/useReferral.js:17:25 ❯ tests/useReferral.test.js:73:43 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:339:27 ❯ renderWithHooks ../node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent ../node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork ../node_modules/react-dom/cjs/react-dom.development.js:21626:16

Check failure on line 36 in frontend/hooks/useApiQuery.js

View workflow job for this annotation

GitHub Actions / Frontend tests

tests/useReferral.test.js > useReferral > fetchReferralData > sets an error when the API call fails

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../node_modules/@tanstack/react-query/src/useBaseQuery.ts:53:17 ❯ useQuery ../node_modules/@tanstack/react-query/src/useQuery.ts:46:9 ❯ useApiQuery hooks/useApiQuery.js:36:10 ❯ Module.useReferral hooks/useReferral.js:17:25 ❯ tests/useReferral.test.js:59:43 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:339:27 ❯ renderWithHooks ../node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent ../node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork ../node_modules/react-dom/cjs/react-dom.development.js:21626:16

Check failure on line 36 in frontend/hooks/useApiQuery.js

View workflow job for this annotation

GitHub Actions / Frontend tests

tests/useReferral.test.js > useReferral > fetchReferralData > sets loading and fetches referral data successfully

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../node_modules/@tanstack/react-query/src/useBaseQuery.ts:53:17 ❯ useQuery ../node_modules/@tanstack/react-query/src/useQuery.ts:46:9 ❯ useApiQuery hooks/useApiQuery.js:36:10 ❯ Module.useReferral hooks/useReferral.js:17:25 ❯ tests/useReferral.test.js:40:43 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:339:27 ❯ renderWithHooks ../node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent ../node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork ../node_modules/react-dom/cjs/react-dom.development.js:21626:16

Check failure on line 36 in frontend/hooks/useApiQuery.js

View workflow job for this annotation

GitHub Actions / Frontend tests

tests/useReferral.test.js > useReferral > generateReferralLink > generates the correct referral link for a given user ID

Error: No QueryClient set, use QueryClientProvider to set one ❯ useQueryClient ../node_modules/@tanstack/react-query/src/QueryClientProvider.tsx:18:10 ❯ useBaseQuery ../node_modules/@tanstack/react-query/src/useBaseQuery.ts:53:17 ❯ useQuery ../node_modules/@tanstack/react-query/src/useQuery.ts:46:9 ❯ useApiQuery hooks/useApiQuery.js:36:10 ❯ Module.useReferral hooks/useReferral.js:17:25 ❯ tests/useReferral.test.js:24:43 ❯ TestComponent ../node_modules/@testing-library/react/dist/pure.js:339:27 ❯ renderWithHooks ../node_modules/react-dom/cjs/react-dom.development.js:15486:18 ❯ mountIndeterminateComponent ../node_modules/react-dom/cjs/react-dom.development.js:20103:13 ❯ beginWork ../node_modules/react-dom/cjs/react-dom.development.js:21626:16
queryKey: key,
queryFn: async ({ signal }) => {
// Pass the AbortSignal so the caller can cancel via Axios / fetch
return fn({ signal });
},
staleTime,
gcTime,
enabled,
retry,
retryDelay: retryDelay ?? ((attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30_000)),
refetchOnWindowFocus: false,
...queryOptions,
});
}

/**
* Convenience hook to invalidate (refetch) queries by key prefix.
*
* Usage:
* const { invalidateQueries } = useInvalidateQueries();
* await invalidateQueries(["reviews"]);
*/
export function useInvalidateQueries() {
const queryClient = useQueryClient();

return {
invalidateQueries: (keyPrefix) =>
queryClient.invalidateQueries({
queryKey: Array.isArray(keyPrefix) ? keyPrefix : [keyPrefix],
}),
};
}

export default useApiQuery;
Loading
Loading