Skip to content
Open
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
97 changes: 84 additions & 13 deletions frontend/src/app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import React, { useState, useEffect, useCallback, useMemo } from "react";
import React, { useState, useEffect, useCallback, useMemo, Suspense } from "react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
Expand All @@ -12,8 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/Select";
import { Image as ImageIcon, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Trophy } from "lucide-react";
import type { LeaderboardPlayer } from "@/types/leaderboard";
import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Trophy, Loader2 } from "lucide-react";
import type { LeaderboardCategory } from "@/types/leaderboard";
import { useLeaderboard } from "@/hooks/useLeaderboard";

Expand All @@ -30,6 +30,16 @@ const CATEGORIES: { value: LeaderboardCategory; label: string }[] = [
{ value: "casual", label: "Casual" },
];

const SEASONS: { id: string; label: string }[] = [
{ id: "current", label: "Current Season" },
{ id: "season-5", label: "Season 5" },
{ id: "season-4", label: "Season 4" },
{ id: "season-3", label: "Season 3" },
{ id: "season-2", label: "Season 2" },
{ id: "season-1", label: "Season 1" },
{ id: "all-time", label: "All Time" },
];

const SORT_COLUMNS = ["eloRating", "wins", "winRate", "matchesPlayed"] as const;
type SortColumn = (typeof SORT_COLUMNS)[number];

Expand Down Expand Up @@ -186,17 +196,38 @@ function SkeletonRows({ count }: { count: number }) {
}

// ---------------------------------------------------------------------------
// Main page
// Main page content (inner — uses useSearchParams)
// ---------------------------------------------------------------------------
export default function LeaderboardPage() {
function LeaderboardContent() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

const [category, setCategory] = useState<LeaderboardCategory>("global");
const [season, setSeason] = useState(() => searchParams.get("season") ?? "current");
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [sortBy, setSortBy] = useState<SortColumn>("eloRating");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<PageSize>(25);

const handleSeasonChange = useCallback(
(newSeason: string) => {
setSeason(newSeason);
setPage(1);
const params = new URLSearchParams(searchParams.toString());
if (newSeason === "current") {
params.delete("season");
} else {
params.set("season", newSeason);
}
const qs = params.toString();
router.push(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false });
},
[router, pathname, searchParams],
);

// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 350);
Expand All @@ -211,10 +242,11 @@ export default function LeaderboardPage() {
const offset = (page - 1) * pageSize;

// Fetch from the real API via useLeaderboard
const { data, isLoading, isError, refetch } = useLeaderboard(
const { data, isLoading, isFetching, isError, refetch } = useLeaderboard(
category,
pageSize,
offset
offset,
season,
);

// Derived values
Expand Down Expand Up @@ -275,7 +307,7 @@ export default function LeaderboardPage() {
<CardTitle className="text-base">Filters</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Search */}
<div className="space-y-1.5">
<label htmlFor="search-player" className="text-sm font-medium">
Expand Down Expand Up @@ -311,6 +343,25 @@ export default function LeaderboardPage() {
</Select>
</div>

{/* Season */}
<div className="space-y-1.5">
<label htmlFor="season-filter" className="text-sm font-medium">
Season
</label>
<Select value={season} onValueChange={handleSeasonChange}>
<SelectTrigger id="season-filter">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEASONS.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

{/* Sort */}
<div className="space-y-1.5">
<label htmlFor="sort-by" className="text-sm font-medium">
Expand Down Expand Up @@ -339,10 +390,13 @@ export default function LeaderboardPage() {
{/* Rankings table */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">
<CardTitle className="text-base flex items-center gap-2">
Rankings
{isFetching && !isLoading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-label="Loading season data…" />
)}
{!isLoading && totalCount > 0 && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
<span className="text-sm font-normal text-muted-foreground">
{totalCount.toLocaleString()} players
</span>
)}
Expand Down Expand Up @@ -423,7 +477,7 @@ export default function LeaderboardPage() {
</tr>
</thead>
<tbody>
{isLoading ? (
{isFetching ? (
<SkeletonRows count={pageSize} />
) : visibleEntries.length === 0 ? (
<tr>
Expand Down Expand Up @@ -515,7 +569,7 @@ export default function LeaderboardPage() {
</div>

{/* Pagination */}
{!isLoading && totalCount > 0 && (
{!isFetching && totalCount > 0 && (
<Pagination
page={page}
totalPages={totalPages}
Expand All @@ -528,7 +582,7 @@ export default function LeaderboardPage() {
setPageSize(s);
setPage(1);
}}
isLoading={isLoading}
isLoading={isFetching}
/>
)}
</>
Expand All @@ -538,3 +592,20 @@ export default function LeaderboardPage() {
</div>
);
}

// ---------------------------------------------------------------------------
// Page export — wraps content in Suspense for useSearchParams
// ---------------------------------------------------------------------------
export default function LeaderboardPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center min-h-[200px]">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
}
>
<LeaderboardContent />
</Suspense>
);
}
52 changes: 45 additions & 7 deletions frontend/src/app/leaderboards/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import React, { useState } from "react";
import React, { useState, useCallback, Suspense } from "react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { Loader2 } from "lucide-react";
import { LeaderboardTable } from "@/components/leaderboard/LeaderboardTable";
import { CategorySelector } from "@/components/leaderboard/CategorySelector";
import { SeasonSelector } from "@/components/leaderboard/SeasonSelector";
Expand All @@ -9,13 +11,32 @@ import { LeaderboardFilters } from "@/components/leaderboard/LeaderboardFilters"
import { useLeaderboard, useLeaderboardStats } from "@/hooks/useLeaderboard";
import type { LeaderboardCategory } from "@/types/leaderboard";

export default function LeaderboardsPage() {
function LeaderboardsContent() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

const [category, setCategory] = useState<LeaderboardCategory>("global");
const [season, setSeason] = useState("current");
const [season, setSeason] = useState(() => searchParams.get("season") ?? "current");
const [sortBy, setSortBy] = useState<"points" | "wins" | "winRate">("points");
const [searchQuery, setSearchQuery] = useState("");

const { data: leaderboardData, isLoading } = useLeaderboard(category, 100, 0);
const handleSeasonChange = useCallback(
(newSeason: string) => {
setSeason(newSeason);
const params = new URLSearchParams(searchParams.toString());
if (newSeason === "current") {
params.delete("season");
} else {
params.set("season", newSeason);
}
const qs = params.toString();
router.push(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false });
},
[router, pathname, searchParams],
);

const { data: leaderboardData, isLoading, isFetching } = useLeaderboard(category, 100, 0, season);
const { data: statsData } = useLeaderboardStats(category);

const entries = leaderboardData?.entries || [];
Expand Down Expand Up @@ -65,7 +86,7 @@ export default function LeaderboardsPage() {
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<CategorySelector category={category} onChange={setCategory} />
<SeasonSelector season={season} onChange={setSeason} />
<SeasonSelector season={season} onChange={handleSeasonChange} />
<div className="md:col-span-1">
<LeaderboardFilters onSearch={setSearchQuery} />
</div>
Expand All @@ -77,14 +98,17 @@ export default function LeaderboardsPage() {
{/* Leaderboard Table */}
<div className="bg-surface/50 rounded-lg p-6 backdrop-blur border border-border">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
{category === "global"
? "Global Rankings"
: category === "tournaments"
? "Tournament Rankings"
: category === "casual"
? "Casual Rankings"
: "Ranked Rankings"}
{isFetching && !isLoading && (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-label="Loading season data…" />
)}
</h2>
<div className="text-sm text-muted-foreground">
{filteredEntries.length} players
Expand All @@ -93,7 +117,7 @@ export default function LeaderboardsPage() {

<LeaderboardTable
entries={filteredEntries}
isLoading={isLoading}
isLoading={isFetching}
sortBy={sortBy}
onSortChange={(col) => setSortBy(col as any)}
/>
Expand All @@ -102,3 +126,17 @@ export default function LeaderboardsPage() {
</div>
);
}

export default function LeaderboardsPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center min-h-[200px]">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
}
>
<LeaderboardsContent />
</Suspense>
);
}
43 changes: 33 additions & 10 deletions frontend/src/components/auth/RegisterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import { useState, useEffect } from 'react';
import { CheckCircle2, XCircle, Loader2, AlertCircle } from 'lucide-react';
import Link from 'next/link';
import { useAuth } from '@/hooks/useAuth';
import { useRouter } from 'next/navigation';
import { useUsernameAvailability } from '@/hooks/useUsernameAvailability';
import { registerSchema } from '@/lib/validations/auth';
import { AuthApiError, REGISTER_ERROR_MAP } from '@/lib/authErrors';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { SocialLogin } from './SocialLogin';
Expand Down Expand Up @@ -79,15 +81,23 @@ export function RegisterForm({ className }: RegisterFormProps) {
e.preventDefault();
if (!validate()) return;

await register({
username: formData.username,
email: formData.email,
password: formData.password,
confirmPassword: formData.confirmPassword,
});

if (!error) {
try {
await register({
username: formData.username,
email: formData.email,
password: formData.password,
confirmPassword: formData.confirmPassword,
});
router.push('/auth/verify-email');
} catch (err) {
if (err instanceof AuthApiError) {
const mapped = REGISTER_ERROR_MAP[err.code];
if (mapped) {
setErrors((prev) => ({ ...prev, [mapped.field]: mapped.message }));
return;
}
}
// Unknown errors land in context `error` via useAuth — generic banner shows them
}
};

Expand Down Expand Up @@ -181,8 +191,21 @@ export function RegisterForm({ className }: RegisterFormProps) {
/>
{errors.email && (
<p id="register-email-error" className="flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3 w-3" aria-hidden="true" />
{errors.email}
<AlertCircle className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
<span>
{errors.email}
{errors.email === REGISTER_ERROR_MAP.EMAIL_ALREADY_EXISTS.message && (
<>
{' '}
<Link
href="/auth/login"
className="underline underline-offset-2 hover:text-destructive/80 font-medium"
>
Log in instead
</Link>
</>
)}
</span>
</p>
)}
</div>
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Logo } from "@/components/common/Logo";
import { ToastContainer } from "@/components/notifications/Toast";
import { SkipLink } from "@/components/ui/SkipLink";
import { BottomNav } from "@/components/ui/BottomNav";
import { PageTransition } from "@/components/layout/PageTransition";
import Link from "next/link";

interface AppLayoutProps {
Expand All @@ -21,7 +22,9 @@ export function AppLayout({ children }: AppLayoutProps) {
<MobileHeaderActions />
</div>
</header>
<main id="main-content" className="container py-6 md:py-10 flex-1 pb-20 md:pb-10" role="main">{children}</main>
<main id="main-content" className="container py-6 md:py-10 flex-1 pb-20 md:pb-10" role="main">
<PageTransition>{children}</PageTransition>
</main>
<ToastContainer />
<BottomNav />
<footer className="border-t py-8 md:py-10" role="contentinfo">
Expand Down
Loading