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 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, + }; +};