diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index c665c551..da4dbd5c 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -8,7 +8,7 @@ import { Breadcrumbs } from "@/components/navbar/Breadcrumbs"; import { MobileBottomTabs } from "@/components/navbar/MobileBottomTabs"; import { ConnectWalletModal } from "@/components/connect-wallet-modal"; import { getBreadcrumbsForPath } from "@/lib/breadcrumbs"; -import { MobileBottomTabs } from "@/components/navbar/MobileBottomTabs"; +import { ErrorBoundary } from "@/components/error-boundary"; export default function DashboardLayout({ children, diff --git a/app/markets/[id]/AboutMarketModalLoader.tsx b/app/markets/[id]/AboutMarketModalLoader.tsx new file mode 100644 index 00000000..65fdf43c --- /dev/null +++ b/app/markets/[id]/AboutMarketModalLoader.tsx @@ -0,0 +1,52 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Info } from "lucide-react"; +import type { AboutMarketModalProps } from "@/app/components/AboutMarketModal"; + +export const AboutMarketModal = dynamic( + () => import("@/app/components/AboutMarketModal").then((mod) => mod.AboutMarketModal), + { + ssr: false, + loading: () => ( + + ), + } +); + +export const PredictionCommentsLoader = dynamic<{ predictionId: string }>( + () => import("@/app/components/PredictionCommentsLoader").then((mod) => mod.default), + { + ssr: false, + loading: () => ( +
+
+ + + +
+
+ ), + } +); + +export const MarketDetailClient = dynamic<{ marketTitle: string; marketId: string }>( + () => import("./MarketDetailClient").then((mod) => mod.MarketDetailClient), + { + ssr: false, + loading: () => null, + } +); + +export default AboutMarketModal; diff --git a/app/markets/[id]/page.tsx b/app/markets/[id]/page.tsx index 8bde1697..2cc3242f 100644 --- a/app/markets/[id]/page.tsx +++ b/app/markets/[id]/page.tsx @@ -1,53 +1,11 @@ import type { Metadata } from "next"; -import dynamic from "next/dynamic"; import MarketHero from "./hero"; import { Tabs } from "@/app/components/Tabs"; -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Info } from "lucide-react"; - -const AboutMarketModal = dynamic( - () => import("@/app/components/AboutMarketModal").then((mod) => mod.AboutMarketModal), - { - ssr: false, - loading: () => ( - - ), - } -); - -const PredictionCommentsLoader = dynamic( - () => import("@/app/components/PredictionCommentsLoader").then((mod) => mod.default), - { - ssr: false, - loading: () => ( -
-
- - - -
-
- ), - } -); - -const MarketDetailClient = dynamic( - () => import("./MarketDetailClient").then((mod) => mod.MarketDetailClient), - { - ssr: false, - loading: () => null, - } -); +import { + AboutMarketModal, + PredictionCommentsLoader, + MarketDetailClient, +} from "./AboutMarketModalLoader"; /** * Market Detail Page — Server Component diff --git a/components/active-bets/ActiveBetCard.tsx b/components/active-bets/ActiveBetCard.tsx index 4c156bff..95a4a3ce 100644 --- a/components/active-bets/ActiveBetCard.tsx +++ b/components/active-bets/ActiveBetCard.tsx @@ -138,7 +138,7 @@ export const ActiveBetCard: React.FC )} -

+

{title}

{t.showDates && bet && ( @@ -171,7 +171,7 @@ export const ActiveBetCard: React.FC
diff --git a/components/claims/ClaimEligibilityStatus.tsx b/components/claims/ClaimEligibilityStatus.tsx index a3b31689..284f0073 100644 --- a/components/claims/ClaimEligibilityStatus.tsx +++ b/components/claims/ClaimEligibilityStatus.tsx @@ -201,7 +201,7 @@ function ErrorAlert({ } return ( - + Couldn't load eligibility {error.message} @@ -217,7 +217,7 @@ function ErrorAlert({ Retry - ))} + )} ); } diff --git a/components/leaderboard/LeaderboardSection.tsx b/components/leaderboard/LeaderboardSection.tsx index af2debd3..23e59569 100644 --- a/components/leaderboard/LeaderboardSection.tsx +++ b/components/leaderboard/LeaderboardSection.tsx @@ -19,21 +19,41 @@ export function LeaderboardSection() { const [activeTab, setActiveTab] = useState("all-time"); const [isUserVisible, setIsUserVisible] = useState(true); - const { status, data, error, refetch, retry } = useLeaderboard({ - fetcher: async () => { - const res = await fetch(`/api/leaderboard?period=${activeTab}`, { - cache: "no-store", - }); - if (!res.ok) { - const body = await res.text(); - throw new Error(body || `Failed to load leaderboard (${res.status})`); + const fetcher = React.useCallback(async () => { + const res = await fetch(`/api/leaderboard?period=${activeTab}`, { + cache: "no-store", + }); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + throw new Error("You do not have permission to view this leaderboard."); + } + let errorMessage = `Failed to load leaderboard (${res.status})`; + try { + const data = await res.json(); + if (data && data.error) errorMessage = data.error; + } catch (e) { + // ignore JSON parse errors } - return res.json(); - }, + throw new Error(errorMessage); + } + return res.json(); + }, [activeTab]); + + const { status, data, error, refetch, retry } = useLeaderboard({ + fetcher, initialData: mockLeaderboardData, staleTime: 2 * 60 * 1000, }); + const isFirstRender = React.useRef(true); + React.useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + refetch(); + }, [activeTab, refetch]); + const users = data.length > 0 ? data : mockLeaderboardData; const topThree = users.slice(0, 3); const others = users.slice(3); diff --git a/components/leaderboard/LeaderboardTable.tsx b/components/leaderboard/LeaderboardTable.tsx index 8d689a55..6815bc86 100644 --- a/components/leaderboard/LeaderboardTable.tsx +++ b/components/leaderboard/LeaderboardTable.tsx @@ -33,7 +33,10 @@ function getResponsiveImageSource(source: string, width: number): string { return source; } -function formatProfit(value: number): string { +function formatProfit(value?: number): string { + if (value === undefined || value === null || isNaN(value)) { + return "+0.00"; + } const formatted = value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, @@ -67,10 +70,25 @@ export function LeaderboardTable({ return [...users].sort((left, right) => { const leftValue = left[sortKey]; const rightValue = right[sortKey]; - const comparison = - typeof leftValue === "string" && typeof rightValue === "string" - ? leftValue.localeCompare(rightValue) - : Number(leftValue) - Number(rightValue); + + let comparison = 0; + if (typeof leftValue === "string" && typeof rightValue === "string") { + comparison = leftValue.localeCompare(rightValue); + } else { + const leftNum = Number(leftValue ?? 0); + const rightNum = Number(rightValue ?? 0); + comparison = (isNaN(leftNum) ? 0 : leftNum) - (isNaN(rightNum) ? 0 : rightNum); + } + + if (comparison === 0) { + // Fallback to name to guarantee deterministic sorting for identical values + comparison = (left.name ?? "").localeCompare(right.name ?? ""); + } + + if (comparison === 0) { + // Fallback to rank as tiebreaker + comparison = (left.rank ?? 0) - (right.rank ?? 0); + } return sortDirection === "desc" ? -comparison : comparison; }); @@ -127,7 +145,7 @@ export function LeaderboardTable({ } setSortKey(nextKey); - setSortDirection("desc"); + setSortDirection(nextKey === "rank" ? "asc" : "desc"); }; const renderSortIcon = (key: SortKey) => { @@ -262,8 +280,8 @@ export function LeaderboardTable({ {user.name} {formatProfit(user.profit)} - {user.winRate}% - {user.predictions} + {user.winRate !== undefined && user.winRate !== null ? `${user.winRate}%` : "—"} + {user.predictions ?? 0} ); })} diff --git a/components/navbar/WalletBalance.tsx b/components/navbar/WalletBalance.tsx index f094da5e..64f6c9dc 100644 --- a/components/navbar/WalletBalance.tsx +++ b/components/navbar/WalletBalance.tsx @@ -29,7 +29,7 @@ export function WalletBalance({ className = "" }: { className?: string }) { if (isLoading && !balance) { return (
@@ -54,7 +54,7 @@ export function WalletBalance({ className = "" }: { className?: string }) { return (
setIsOpen(true)} className="cursor-pointer" aria-label="Switch wallet"> - + Switch { void disconnectWallet(); }} className="cursor-pointer" aria-label="Disconnect wallet"> - + Disconnect diff --git a/components/receipts/Receipt.tsx b/components/receipts/Receipt.tsx index f42b1e6d..ad6031d7 100644 --- a/components/receipts/Receipt.tsx +++ b/components/receipts/Receipt.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CheckCircle2, Printer, ArrowLeft } from 'lucide-react'; -import { ReceitShare } from '@/app/components/ReceiptShare'; +import { ReceiptShare } from '@/app/components/ReceiptShare'; import { usePrivacy } from '@/context/PrivacyContext'; import { maskAmount } from '@/utils/maskAmount'; @@ -41,8 +41,8 @@ export function Receipt({ receiptId, amount, partyA, partyB, timestamp, type }:
-

Predictify

-

Predictify +

Official Record

@@ -136,7 +136,7 @@ export function Receipt({ receiptId, amount, partyA, partyB, timestamp, type }: aria-label="Print this receipt" className="w-full flex items-center justify-center gap-2 bg-foreground text-background py-3.5 px-4 rounded-xl font-semibold hover:opacity-90 transition-opacity" > - +
diff --git a/context/AccessibilityContext.tsx b/context/AccessibilityContext.tsx new file mode 100644 index 00000000..9395331c --- /dev/null +++ b/context/AccessibilityContext.tsx @@ -0,0 +1,71 @@ +'use client'; + +import React, { createContext, useContext, useState, useEffect } from 'react'; + +export interface AccessibilityContextType { + reduceMotion: boolean; + disableParallax: boolean; + disableAutoplay: boolean; + increaseContrast: boolean; + setReduceMotion: (value: boolean) => void; + setDisableParallax: (value: boolean) => void; + setDisableAutoplay: (value: boolean) => void; + setIncreaseContrast: (value: boolean) => void; +} + +const AccessibilityContext = createContext(undefined); + +export function AccessibilityProvider({ children }: { children: React.ReactNode }) { + const [reduceMotion, setReduceMotion] = useState(false); + const [disableParallax, setDisableParallax] = useState(false); + const [disableAutoplay, setDisableAutoplay] = useState(false); + const [increaseContrast, setIncreaseContrast] = useState(false); + + useEffect(() => { + if (typeof window !== 'undefined') { + const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + setReduceMotion(mediaQuery.matches); + + const handleChange = (e: MediaQueryListEvent) => { + setReduceMotion(e.matches); + }; + + mediaQuery.addEventListener?.('change', handleChange); + return () => mediaQuery.removeEventListener?.('change', handleChange); + } + }, []); + + return ( + + {children} + + ); +} + +export function useAccessibility(): AccessibilityContextType { + const context = useContext(AccessibilityContext); + if (!context) { + return { + reduceMotion: false, + disableParallax: false, + disableAutoplay: false, + increaseContrast: false, + setReduceMotion: () => {}, + setDisableParallax: () => {}, + setDisableAutoplay: () => {}, + setIncreaseContrast: () => {}, + }; + } + return context; +} diff --git a/hooks/useLeaderboard.ts b/hooks/useLeaderboard.ts index 2c6dd65a..0b31babb 100644 --- a/hooks/useLeaderboard.ts +++ b/hooks/useLeaderboard.ts @@ -51,6 +51,7 @@ export function useLeaderboard({ const retryCountRef = useRef(0); const mountedRef = useRef(true); + const fetchIdRef = useRef(0); useEffect(() => { mountedRef.current = true; @@ -60,12 +61,13 @@ export function useLeaderboard({ }, []); const executeFetch = useCallback(async () => { + const currentFetchId = ++fetchIdRef.current; setState((prev) => ({ ...prev, status: "loading", error: null })); try { const data = await fetcher(); - if (!mountedRef.current) return; + if (!mountedRef.current || currentFetchId !== fetchIdRef.current) return; const now = Date.now(); setState({ @@ -77,7 +79,7 @@ export function useLeaderboard({ }); retryCountRef.current = 0; } catch (err) { - if (!mountedRef.current) return; + if (!mountedRef.current || currentFetchId !== fetchIdRef.current) return; const message = err instanceof Error ? err.message : "Unknown error"; setState((prev) => ({ @@ -93,16 +95,19 @@ export function useLeaderboard({ }, [executeFetch]); const retry = useCallback(async () => { + retryCountRef.current += 1; if (retryCountRef.current >= maxRetries) { setState((prev) => ({ ...prev, + status: "error", error: "Maximum retry attempts reached. Please try again later.", })); return; } - retryCountRef.current += 1; - await new Promise((resolve) => setTimeout(resolve, retryDelay)); + if (retryDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } await executeFetch(); }, [executeFetch, maxRetries, retryDelay]);