From eaed49c076447086e730053ca3a929c691a09b2d Mon Sep 17 00:00:00 2001 From: Henry Eulam Eliazar <286891514+eulami@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:26:45 +0000 Subject: [PATCH] Handle API loading, error, and empty states consistently (#367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce shared patterns for frontend queries and mutations with retry, cancellation, stale-data handling, and user-visible failure messages. - useApiQuery: React Query wrapper with abort signal, exponential backoff retry (3 attempts), configurable stale/gc time, and disabled-by-default until enabled. - useApiMutation: React Query mutation wrapper with retry, automatic cache invalidation on success, and extractErrorMessage for user-facing errors. - StateDisplay components: LoadingState, ErrorState (with retry button), EmptyState, and composite ApiStateDisplay for consistent UI across pages. - Refactored usePuzzleReviews to use useApiQuery/useApiMutation (queries keyed by filters+pagination, mutations invalidate reviews+stats). - Refactored useReferral to use useApiQuery/useApiMutation (query keyed by userId, mutation invalidates referral cache). Closes #367 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- frontend/components/ui/StateDisplay.jsx | 116 ++++++++++ frontend/hooks/useApiMutation.js | 84 ++++++++ frontend/hooks/useApiQuery.js | 70 ++++++ frontend/hooks/usePuzzleReviews.js | 274 +++++++++++------------- frontend/hooks/useReferral.js | 155 +++++++------- 5 files changed, 475 insertions(+), 224 deletions(-) create mode 100644 frontend/components/ui/StateDisplay.jsx create mode 100644 frontend/hooks/useApiMutation.js create mode 100644 frontend/hooks/useApiQuery.js 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 ( +
+ +

{message}

+
+ ); +} + +/** + * 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 ( +
+
+ +
+
+

Error

+

{message}

+
+ {onRetry && ( + + )} +
+ ); +} + +/** + * 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 aab74c51..b3ddaf34 100644 --- a/frontend/hooks/usePuzzleReviews.js +++ b/frontend/hooks/usePuzzleReviews.js @@ -1,10 +1,17 @@ -import { useState, useEffect, useCallback } from 'react'; -import puzzleReviewService from '../services/puzzleReviewService'; +"use client"; +import { useState, useCallback } from "react"; +import puzzleReviewService from "../services/puzzleReviewService"; +import { useApiQuery } from "./useApiQuery"; +import { useApiMutation } from "./useApiMutation"; + +/** + * 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 [reviews, setReviews] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const [pagination, setPagination] = useState({ page: 1, limit: 20, @@ -12,174 +19,139 @@ export const usePuzzleReviews = () => { totalPages: 0, }); const [filters, setFilters] = useState({ - status: 'PENDING', - sortBy: 'createdAt', - sortOrder: 'DESC', + status: "PENDING", + sortBy: "createdAt", + sortOrder: "DESC", }); - // Fetch reviews with current filters and pagination - const fetchReviews = useCallback(async () => { - setLoading(true); - setError(null); - - try { + // ── Queries ────────────────────────────────────────────────────────────── + + 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) { - setReviews(response.data.reviews); - setPagination(prev => ({ - ...prev, - total: response.data.total, - totalPages: response.data.totalPages, - })); - } else { - setError(response.message || 'Failed to fetch reviews'); - } - } catch (err) { - setError(err.message || 'An error occurred while fetching reviews'); - } finally { - setLoading(false); - } - }, [filters, pagination.page, pagination.limit]); - - // Update filters and reset to first page - const updateFilters = useCallback((newFilters) => { - setFilters(prev => ({ ...prev, ...newFilters })); - setPagination(prev => ({ ...prev, page: 1 })); - }, []); + if (!response.success) throw new Error(response.message || "Failed to fetch reviews"); + return response.data; + }, + staleTime: 30_000, + }); - // Update pagination - const updatePagination = useCallback((newPagination) => { - setPagination(prev => ({ ...prev, ...newPagination })); - }, []); + 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, + }); + + // ── Mutations ──────────────────────────────────────────────────────────── - // Approve a review - const approveReview = useCallback(async (reviewId, moderationReason = '') => { - try { + const approveMutation = useApiMutation({ + fn: async ({ reviewId, moderationReason }) => { const response = await puzzleReviewService.updateReviewStatus( reviewId, - 'APPROVED', - moderationReason + "APPROVED", + moderationReason, ); - - if (response.success) { - // Update the review in the local state - setReviews(prev => - prev.map(review => - review.id === reviewId - ? { ...review, status: 'APPROVED', moderationInfo: response.data.moderationInfo } - : review - ) - ); - return { success: true, message: 'Review approved successfully' }; - } else { - return { success: false, message: response.message }; - } - } catch (err) { - return { success: false, message: err.message }; - } - }, []); + if (!response.success) throw new Error(response.message); + return response.data; + }, + invalidate: ["reviews", "reviewStats"], + }); - // Reject a review - const rejectReview = useCallback(async (reviewId, moderationReason = '') => { - try { + const rejectMutation = useApiMutation({ + fn: async ({ reviewId, moderationReason }) => { const response = await puzzleReviewService.updateReviewStatus( reviewId, - 'REJECTED', - moderationReason + "REJECTED", + moderationReason, ); - - if (response.success) { - // Update the review in the local state - setReviews(prev => - prev.map(review => - review.id === reviewId - ? { ...review, status: 'REJECTED', moderationInfo: response.data.moderationInfo } - : review - ) - ); - return { success: true, message: 'Review rejected successfully' }; - } else { - return { success: false, message: response.message }; - } - } catch (err) { - return { success: false, message: err.message }; - } - }, []); + if (!response.success) throw new Error(response.message); + return response.data; + }, + invalidate: ["reviews", "reviewStats"], + }); - // Bulk approve reviews - const bulkApproveReviews = useCallback(async (reviewIds, moderationReason = '') => { - try { + const bulkApproveMutation = useApiMutation({ + fn: async ({ reviewIds, moderationReason }) => { const response = await puzzleReviewService.bulkUpdateReviewStatuses( reviewIds, - 'APPROVED', - moderationReason + "APPROVED", + moderationReason, ); - - if (response.success) { - // Refresh the reviews list - await fetchReviews(); - return { success: true, message: `${reviewIds.length} reviews approved successfully` }; - } else { - return { success: false, message: response.message }; - } - } catch (err) { - return { success: false, message: err.message }; - } - }, [fetchReviews]); - - // Bulk reject reviews - const bulkRejectReviews = useCallback(async (reviewIds, moderationReason = '') => { - try { + 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 + "REJECTED", + moderationReason, ); - - if (response.success) { - // Refresh the reviews list - await fetchReviews(); - return { success: true, message: `${reviewIds.length} reviews rejected successfully` }; - } else { - return { success: false, message: response.message }; - } - } catch (err) { - return { success: false, message: err.message }; - } - }, [fetchReviews]); - - // Get review statistics - const [stats, setStats] = useState(null); - const [statsLoading, setStatsLoading] = useState(false); - - const fetchStats = useCallback(async () => { - setStatsLoading(true); - try { - const response = await puzzleReviewService.getReviewStats(); - if (response.success) { - setStats(response.data); - } - } catch (err) { - console.error('Failed to fetch stats:', err); - } finally { - setStatsLoading(false); - } + if (!response.success) throw new Error(response.message); + return response.data; + }, + invalidate: ["reviews", "reviewStats"], + }); + + // ── Actions ────────────────────────────────────────────────────────────── + + const updateFilters = useCallback((newFilters) => { + setFilters((prev) => ({ ...prev, ...newFilters })); + setPagination((prev) => ({ ...prev, page: 1 })); }, []); - // Fetch reviews on mount and when filters/pagination change - useEffect(() => { - fetchReviews(); - }, [fetchReviews]); + const updatePagination = useCallback((newPagination) => { + setPagination((prev) => ({ ...prev, ...newPagination })); + }, []); - // Fetch stats on mount - useEffect(() => { - fetchStats(); - }, [fetchStats]); + const approveReview = useCallback( + (reviewId, moderationReason = "") => + approveMutation.mutateAsync({ reviewId, moderationReason }), + [approveMutation], + ); + + const rejectReview = useCallback( + (reviewId, moderationReason = "") => + rejectMutation.mutateAsync({ reviewId, moderationReason }), + [rejectMutation], + ); + + const bulkApproveReviews = useCallback( + (reviewIds, moderationReason = "") => + bulkApproveMutation.mutateAsync({ reviewIds, moderationReason }), + [bulkApproveMutation], + ); + + const bulkRejectReviews = useCallback( + (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 @@ -190,15 +162,15 @@ export const usePuzzleReviews = () => { filters, stats, statsLoading, - + // Actions - fetchReviews, + fetchReviews: reviewsQuery.refetch, updateFilters, updatePagination, approveReview, rejectReview, bulkApproveReviews, bulkRejectReviews, - fetchStats, + fetchStats: statsQuery.refetch, }; -}; \ No newline at end of file +}; diff --git a/frontend/hooks/useReferral.js b/frontend/hooks/useReferral.js index e31f5f92..a74d46a3 100644 --- a/frontend/hooks/useReferral.js +++ b/frontend/hooks/useReferral.js @@ -1,127 +1,136 @@ -import { useState, useEffect } from "react"; +"use client"; + +import { useCallback } from "react"; import axios from "axios"; +import { useApiQuery } from "./useApiQuery"; +import { useApiMutation } from "./useApiMutation"; -export const useReferral = () => { - const [referralStats, setReferralStats] = useState({ - totalInvites: 0, - activeUsers: 0, - totalRewards: 0, - totalXPEarned: 0, - nextMilestone: "" +/** + * 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, }); - const [invitedUsers, setInvitedUsers] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + // ── Mutations ──────────────────────────────────────────────────────────── - // 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}`; - }; + const trackMutation = useApiMutation({ + fn: async ({ referrerId, newUserId }) => { + await axios.post( + "/api/referrals/track", + { referrerId, newUserId }, + { withCredentials: true }, + ); + }, + invalidate: ["referral"], + }); - // Fetch referral data - const fetchReferralData = async (userId) => { - if (!userId) return; + // ── Actions ────────────────────────────────────────────────────────────── - setLoading(true); - setError(null); + const fetchReferralData = useCallback( + (id) => { + if (id) referralQuery.refetch(); + }, + [referralQuery], + ); - try { - const response = await axios.get(`/api/referrals/${userId}`, { - withCredentials: true - }); + const trackReferral = useCallback( + (referrerId, newUserId) => + trackMutation.mutateAsync({ referrerId, newUserId }), + [trackMutation], + ); - 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); - } - }; + // ── Helpers ────────────────────────────────────────────────────────────── - // 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); - } - }; + const generateReferralLink = useCallback((id) => { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://nft-hunt.com"; + return `${baseUrl}/ref/${id}`; + }, []); - // Get reward tier info - const getRewardTier = (totalInvites) => { + 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" }; - }; + }, []); - // Calculate progress to next milestone - const getProgressToNextMilestone = (currentInvites) => { + const getProgressToNextMilestone = useCallback((currentInvites) => { const milestones = [5, 10, 25, 50]; - const nextMilestone = milestones.find(m => m > currentInvites) || 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 + remaining: nextMilestone - currentInvites, }; - }; + }, []); - // Share referral link - const shareReferral = async (referralLink) => { + 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 + url: referralLink, }); return true; - } catch (err) { - console.error("Error sharing:", err); + } catch { return false; } } return false; - }; + }, []); - // Copy referral link to clipboard - const copyReferralLink = async (referralLink) => { + const copyReferralLink = useCallback(async (referralLink) => { try { await navigator.clipboard.writeText(referralLink); return true; - } catch (err) { - console.error("Failed to copy:", err); + } 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, - error, + loading: referralQuery.isLoading, + error: referralQuery.error, generateReferralLink, fetchReferralData, trackReferral, getRewardTier, getProgressToNextMilestone, shareReferral, - copyReferralLink + copyReferralLink, }; -}; \ No newline at end of file +}; \ No newline at end of file