diff --git a/frontend/components/ui/StateDisplay.jsx b/frontend/components/ui/StateDisplay.jsx
new file mode 100644
index 00000000..d2b92028
--- /dev/null
+++ b/frontend/components/ui/StateDisplay.jsx
@@ -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 (
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+ {onRetry && (
+
+
+ Try again
+
+ )}
+
+ );
+}
+
+/**
+ * Consistent empty state when data returns an empty array / no results.
+ */
+export function EmptyState({ title = "Nothing here yet", description, icon: Icon, action, className }) {
+ return (
+
+
+ {Icon ? : }
+
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ {action}
+
+ );
+}
+
+/**
+ * Composite component that picks the right state based on loading / error / data.
+ *
+ * Usage:
+ *
+ * {(items) => }
+ *
+ */
+export default function ApiStateDisplay({
+ isLoading,
+ error,
+ data,
+ onRetry,
+ loadingMessage,
+ emptyTitle,
+ emptyDescription,
+ emptyIcon,
+ emptyAction,
+ children,
+}) {
+ if (isLoading) return ;
+ if (error) return ;
+
+ const isEmpty =
+ data === null ||
+ data === undefined ||
+ (Array.isArray(data) && data.length === 0);
+
+ if (isEmpty) {
+ return (
+
+ );
+ }
+
+ return typeof children === "function" ? children(data) : children;
+}
diff --git a/frontend/hooks/useApiMutation.js b/frontend/hooks/useApiMutation.js
new file mode 100644
index 00000000..e4c5382b
--- /dev/null
+++ b/frontend/hooks/useApiMutation.js
@@ -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;
diff --git a/frontend/hooks/useApiQuery.js b/frontend/hooks/useApiQuery.js
new file mode 100644
index 00000000..8e3549a6
--- /dev/null
+++ b/frontend/hooks/useApiQuery.js
@@ -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({
+ 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;
diff --git a/frontend/hooks/usePuzzleReviews.js b/frontend/hooks/usePuzzleReviews.js
index 5b61fda3..3e04f887 100644
--- a/frontend/hooks/usePuzzleReviews.js
+++ b/frontend/hooks/usePuzzleReviews.js
@@ -1,111 +1,160 @@
-import { useState, useCallback } from 'react';
-import {
- usePuzzleReviewsQuery,
- useReviewStatsQuery,
- useApproveReviewMutation,
- useRejectReviewMutation,
- useBulkApproveReviewsMutation,
- useBulkRejectReviewsMutation,
-} from '../services/puzzleReviewHooks';
+"use client";
+
+import { useState, useCallback } from "react";
+import puzzleReviewService from "../services/puzzleReviewService";
+import { useApiQuery } from "./useApiQuery";
+import { useApiMutation } from "./useApiMutation";
/**
- * Thin glue hook — wraps TanStack Query hooks for the puzzle review dashboard.
- * The page component uses this hook so it doesn't need useState/useCallback itself.
+ * Hook for managing puzzle review data with consistent loading / error / empty states.
+ *
+ * Built on the shared useApiQuery / useApiMutation wrappers so all requests get
+ * automatic retry, cancellation, stale-data handling, and user-visible errors.
*/
export const usePuzzleReviews = () => {
+ const [pagination, setPagination] = useState({
+ page: 1,
+ limit: 20,
+ total: 0,
+ totalPages: 0,
+ });
const [filters, setFilters] = useState({
- status: 'PENDING',
- sortBy: 'createdAt',
- sortOrder: 'DESC',
+ status: "PENDING",
+ sortBy: "createdAt",
+ sortOrder: "DESC",
});
const [page, setPage] = useState(1);
const [limit] = useState(20);
- const queryFilters = { ...filters, page, limit };
+ // ── Queries ──────────────────────────────────────────────────────────────
- const {
- data: queryData,
- isLoading: loading,
- error: queryError,
- } = usePuzzleReviewsQuery(queryFilters);
+ const reviewsQuery = useApiQuery({
+ key: ["reviews", filters, pagination.page, pagination.limit],
+ fn: async ({ signal }) => {
+ const response = await puzzleReviewService.getPuzzleReviews({
+ ...filters,
+ page: pagination.page,
+ limit: pagination.limit,
+ });
+ if (!response.success) throw new Error(response.message || "Failed to fetch reviews");
+ return response.data;
+ },
+ staleTime: 30_000,
+ });
- const {
- data: stats,
- isLoading: statsLoading,
- } = useReviewStatsQuery();
+ const statsQuery = useApiQuery({
+ key: ["reviewStats"],
+ fn: async () => {
+ const response = await puzzleReviewService.getReviewStats();
+ if (!response.success) throw new Error(response.message || "Failed to fetch stats");
+ return response.data;
+ },
+ staleTime: 60_000,
+ });
- const approveReviewMutation = useApproveReviewMutation();
- const rejectReviewMutation = useRejectReviewMutation();
- const bulkApproveMutation = useBulkApproveReviewsMutation();
- const bulkRejectMutation = useBulkRejectReviewsMutation();
+ // ── Mutations ────────────────────────────────────────────────────────────
- const error = queryError?.message || null;
+ const approveMutation = useApiMutation({
+ fn: async ({ reviewId, moderationReason }) => {
+ const response = await puzzleReviewService.updateReviewStatus(
+ reviewId,
+ "APPROVED",
+ moderationReason,
+ );
+ if (!response.success) throw new Error(response.message);
+ return response.data;
+ },
+ invalidate: ["reviews", "reviewStats"],
+ });
- const reviews = queryData?.reviews ?? [];
- const pagination = {
- page,
- limit,
- total: queryData?.total ?? 0,
- totalPages: queryData?.totalPages ?? 0,
- };
+ const rejectMutation = useApiMutation({
+ fn: async ({ reviewId, moderationReason }) => {
+ const response = await puzzleReviewService.updateReviewStatus(
+ reviewId,
+ "REJECTED",
+ moderationReason,
+ );
+ if (!response.success) throw new Error(response.message);
+ return response.data;
+ },
+ invalidate: ["reviews", "reviewStats"],
+ });
+
+ const bulkApproveMutation = useApiMutation({
+ fn: async ({ reviewIds, moderationReason }) => {
+ const response = await puzzleReviewService.bulkUpdateReviewStatuses(
+ reviewIds,
+ "APPROVED",
+ moderationReason,
+ );
+ if (!response.success) throw new Error(response.message);
+ return response.data;
+ },
+ invalidate: ["reviews", "reviewStats"],
+ });
+
+ const bulkRejectMutation = useApiMutation({
+ fn: async ({ reviewIds, moderationReason }) => {
+ const response = await puzzleReviewService.bulkUpdateReviewStatuses(
+ reviewIds,
+ "REJECTED",
+ moderationReason,
+ );
+ if (!response.success) throw new Error(response.message);
+ return response.data;
+ },
+ invalidate: ["reviews", "reviewStats"],
+ });
+
+ // ── Actions ──────────────────────────────────────────────────────────────
const updateFilters = useCallback((newFilters) => {
setFilters((prev) => ({ ...prev, ...newFilters }));
- setPage(1);
+ setPagination((prev) => ({ ...prev, page: 1 }));
}, []);
const updatePagination = useCallback((newPagination) => {
- if (newPagination.page) setPage(newPagination.page);
+ setPagination((prev) => ({ ...prev, ...newPagination }));
}, []);
const approveReview = useCallback(
- async (reviewId, moderationReason = '') => {
- try {
- await approveReviewMutation.mutateAsync({ reviewId, moderationReason });
- return { success: true, message: 'Review approved successfully' };
- } catch (err) {
- return { success: false, message: err.message };
- }
- },
- [approveReviewMutation],
+ (reviewId, moderationReason = "") =>
+ approveMutation.mutateAsync({ reviewId, moderationReason }),
+ [approveMutation],
);
const rejectReview = useCallback(
- async (reviewId, moderationReason = '') => {
- try {
- await rejectReviewMutation.mutateAsync({ reviewId, moderationReason });
- return { success: true, message: 'Review rejected successfully' };
- } catch (err) {
- return { success: false, message: err.message };
- }
- },
- [rejectReviewMutation],
+ (reviewId, moderationReason = "") =>
+ rejectMutation.mutateAsync({ reviewId, moderationReason }),
+ [rejectMutation],
);
const bulkApproveReviews = useCallback(
- async (reviewIds, moderationReason = '') => {
- try {
- await bulkApproveMutation.mutateAsync({ reviewIds, moderationReason });
- return { success: true, message: `${reviewIds.length} reviews approved successfully` };
- } catch (err) {
- return { success: false, message: err.message };
- }
- },
+ (reviewIds, moderationReason = "") =>
+ bulkApproveMutation.mutateAsync({ reviewIds, moderationReason }),
[bulkApproveMutation],
);
const bulkRejectReviews = useCallback(
- async (reviewIds, moderationReason = '') => {
- try {
- await bulkRejectMutation.mutateAsync({ reviewIds, moderationReason });
- return { success: true, message: `${reviewIds.length} reviews rejected successfully` };
- } catch (err) {
- return { success: false, message: err.message };
- }
- },
+ (reviewIds, moderationReason = "") =>
+ bulkRejectMutation.mutateAsync({ reviewIds, moderationReason }),
[bulkRejectMutation],
);
+ // ── Derived state ────────────────────────────────────────────────────────
+
+ const reviews = reviewsQuery.data?.reviews ?? [];
+ const stats = statsQuery.data ?? null;
+ const loading = reviewsQuery.isLoading;
+ const error = reviewsQuery.error;
+ const statsLoading = statsQuery.isLoading;
+
+ // Sync pagination totals from the last successful query
+ if (reviewsQuery.data) {
+ pagination.total = reviewsQuery.data.total;
+ pagination.totalPages = reviewsQuery.data.totalPages;
+ }
+
return {
// State
reviews,
@@ -117,11 +166,13 @@ export const usePuzzleReviews = () => {
statsLoading,
// Actions
+ fetchReviews: reviewsQuery.refetch,
updateFilters,
updatePagination,
approveReview,
rejectReview,
bulkApproveReviews,
bulkRejectReviews,
+ fetchStats: statsQuery.refetch,
};
};
diff --git a/frontend/hooks/useReferral.js b/frontend/hooks/useReferral.js
index e09a240d..f8cbdb95 100644
--- a/frontend/hooks/useReferral.js
+++ b/frontend/hooks/useReferral.js
@@ -1,157 +1,136 @@
-import { useState, useEffect } from "react";
-import axios from "axios";
-
-export const useReferral = (userId = null) => {
- const [referralStats, setReferralStats] = useState({
- totalInvites: 0,
- activeUsers: 0,
- totalRewards: 0,
- totalXPEarned: 0,
- nextMilestone: ""
- });
-
- const [invitedUsers, setInvitedUsers] = useState([]);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- const controller = new AbortController();
- if (userId) {
- const fetchReferralData = async (userId) => {
- if (!userId) return;
- setLoading(true);
- setError(null);
- try {
- const response = await axios.get(`/api/referrals/${userId}`, {
- withCredentials: true,
- signal: controller.signal
- });
- setReferralStats(response.data.stats);
- setInvitedUsers(response.data.invitedUsers);
- } catch (err) {
- if (!axios.isCancel(err)) {
- console.error("Failed to fetch referral data:", err);
- setError("Failed to load referral data");
- }
- } finally {
- setLoading(false);
- }
- };
- fetchReferralData(userId);
- }
- return () => {
- controller.abort();
- };
- }, [userId]);
-
- // Generate referral link for current user
- const generateReferralLink = (userId) => {
- const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://nft-hunt.com";
- return `${baseUrl}/ref/${userId}`;
- };
-
- // Fetch referral data
- const fetchReferralData = async (userId) => {
- if (!userId) return;
-
- setLoading(true);
- setError(null);
-
- try {
- const response = await axios.get(`/api/referrals/${userId}`, {
- withCredentials: true
- });
-
- setReferralStats(response.data.stats);
- setInvitedUsers(response.data.invitedUsers);
- } catch (err) {
- console.error("Failed to fetch referral data:", err);
- setError("Failed to load referral data");
- } finally {
- setLoading(false);
- }
- };
-
- // Track new referral
- const trackReferral = async (referrerId, newUserId) => {
- try {
- await axios.post("/api/referrals/track", {
- referrerId,
- newUserId
- }, {
- withCredentials: true
- });
-
- // Refresh referral data
- await fetchReferralData(referrerId);
- } catch (err) {
- console.error("Failed to track referral:", err);
- }
- };
-
- // Get reward tier info
- const getRewardTier = (totalInvites) => {
- if (totalInvites >= 50) return { tier: "Mythic", reward: "Mythic NFT", color: "pink" };
- if (totalInvites >= 25) return { tier: "Legendary", reward: "Legendary NFT", color: "yellow" };
- if (totalInvites >= 10) return { tier: "Epic", reward: "Epic NFT", color: "purple" };
- if (totalInvites >= 5) return { tier: "Rare", reward: "Rare NFT", color: "green" };
- return { tier: "Common", reward: "Common NFT", color: "gray" };
- };
-
- // Calculate progress to next milestone
- const getProgressToNextMilestone = (currentInvites) => {
- const milestones = [5, 10, 25, 50];
- const nextMilestone = milestones.find(m => m > currentInvites) || 50;
- const progress = (currentInvites / nextMilestone) * 100;
-
- return {
- current: currentInvites,
- next: nextMilestone,
- progress: Math.min(progress, 100),
- remaining: nextMilestone - currentInvites
- };
- };
-
- // Share referral link
- const shareReferral = async (referralLink) => {
- if (navigator.share) {
- try {
- await navigator.share({
- title: "Join StellarHunts!",
- text: "I'm playing this amazing StellarHunts game. Join me and earn exclusive rewards!",
- url: referralLink
- });
- return true;
- } catch (err) {
- console.error("Error sharing:", err);
- return false;
- }
- }
- return false;
- };
-
- // Copy referral link to clipboard
- const copyReferralLink = async (referralLink) => {
- try {
- await navigator.clipboard.writeText(referralLink);
- return true;
- } catch (err) {
- console.error("Failed to copy:", err);
- return false;
- }
- };
-
- return {
- referralStats,
- invitedUsers,
- loading,
- error,
- generateReferralLink,
- fetchReferralData,
- trackReferral,
- getRewardTier,
- getProgressToNextMilestone,
- shareReferral,
- copyReferralLink
- };
-};
\ No newline at end of file
+"use client";
+
+import { useCallback } from "react";
+import axios from "axios";
+import { useApiQuery } from "./useApiQuery";
+import { useApiMutation } from "./useApiMutation";
+
+/**
+ * Hook for managing referral data with consistent loading / error / empty states.
+ *
+ * Built on the shared useApiQuery / useApiMutation wrappers so all requests get
+ * automatic retry, cancellation, stale-data handling, and user-visible errors.
+ */
+export const useReferral = (userId) => {
+ // ── Queries ──────────────────────────────────────────────────────────────
+
+ const referralQuery = useApiQuery({
+ key: ["referral", userId],
+ fn: async ({ signal }) => {
+ if (!userId) return null;
+ const response = await axios.get(`/api/referrals/${userId}`, {
+ withCredentials: true,
+ signal,
+ });
+ return response.data;
+ },
+ enabled: !!userId,
+ staleTime: 60_000,
+ });
+
+ // ── Mutations ────────────────────────────────────────────────────────────
+
+ const trackMutation = useApiMutation({
+ fn: async ({ referrerId, newUserId }) => {
+ await axios.post(
+ "/api/referrals/track",
+ { referrerId, newUserId },
+ { withCredentials: true },
+ );
+ },
+ invalidate: ["referral"],
+ });
+
+ // ── Actions ──────────────────────────────────────────────────────────────
+
+ const fetchReferralData = useCallback(
+ (id) => {
+ if (id) referralQuery.refetch();
+ },
+ [referralQuery],
+ );
+
+ const trackReferral = useCallback(
+ (referrerId, newUserId) =>
+ trackMutation.mutateAsync({ referrerId, newUserId }),
+ [trackMutation],
+ );
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ const generateReferralLink = useCallback((id) => {
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://nft-hunt.com";
+ return `${baseUrl}/ref/${id}`;
+ }, []);
+
+ const getRewardTier = useCallback((totalInvites) => {
+ if (totalInvites >= 50) return { tier: "Mythic", reward: "Mythic NFT", color: "pink" };
+ if (totalInvites >= 25) return { tier: "Legendary", reward: "Legendary NFT", color: "yellow" };
+ if (totalInvites >= 10) return { tier: "Epic", reward: "Epic NFT", color: "purple" };
+ if (totalInvites >= 5) return { tier: "Rare", reward: "Rare NFT", color: "green" };
+ return { tier: "Common", reward: "Common NFT", color: "gray" };
+ }, []);
+
+ const getProgressToNextMilestone = useCallback((currentInvites) => {
+ const milestones = [5, 10, 25, 50];
+ const nextMilestone = milestones.find((m) => m > currentInvites) || 50;
+ const progress = (currentInvites / nextMilestone) * 100;
+ return {
+ current: currentInvites,
+ next: nextMilestone,
+ progress: Math.min(progress, 100),
+ remaining: nextMilestone - currentInvites,
+ };
+ }, []);
+
+ const shareReferral = useCallback(async (referralLink) => {
+ if (navigator.share) {
+ try {
+ await navigator.share({
+ title: "Join StellarHunt!",
+ text: "I'm playing this amazing StellarHunt game. Join me and earn exclusive rewards!",
+ url: referralLink,
+ });
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ return false;
+ }, []);
+
+ const copyReferralLink = useCallback(async (referralLink) => {
+ try {
+ await navigator.clipboard.writeText(referralLink);
+ return true;
+ } catch {
+ return false;
+ }
+ }, []);
+
+ // ── Derived state ────────────────────────────────────────────────────────
+
+ const data = referralQuery.data;
+ const referralStats = data?.stats ?? {
+ totalInvites: 0,
+ activeUsers: 0,
+ totalRewards: 0,
+ totalXPEarned: 0,
+ nextMilestone: "",
+ };
+ const invitedUsers = data?.invitedUsers ?? [];
+
+ return {
+ referralStats,
+ invitedUsers,
+ loading: referralQuery.isLoading,
+ error: referralQuery.error,
+ generateReferralLink,
+ fetchReferralData,
+ trackReferral,
+ getRewardTier,
+ getProgressToNextMilestone,
+ shareReferral,
+ copyReferralLink,
+ };
+};