);
}
-function RulesTab({ game }: { game: Game }) {
+function OverviewTab({ game }: { game: GameCatalogDto }) {
return (
-
-
{game.rules}
-
- - 1 Place your bet on time or skill — never both.
- - 2 Forfeit is allowed after 30 seconds.
- - 3 Server validates every move; cheating ends a session immediately.
- - 4 Disconnect for >60s = forfeit unless both sides agree to pause.
-
+
+
{game.summary}
+
+
+
+
+
);
}
-function YourStatsTab() {
+function RulesTab({ game }: { game: GameCatalogDto }) {
return (
-
-
-
-
+
+
{game.rulesSummary}
+
+ Detailed rules — coming soon.
+
);
}
-function FriendLeaderboardTab() {
- const rows = [
- { name: 'Sara Lindqvist', elo: 2104 },
- { name: 'Priya Raman', elo: 1910 },
- { name: 'You', elo: 1842, you: true },
- { name: 'Anya Volkov', elo: 1990 },
- { name: 'Mateus Oliveira', elo: 1675 },
- ].sort((a, b) => b.elo - a.elo);
-
+function ComingSoonTab({ ownerModule, what }: { ownerModule: number; what: string }) {
+ const moduleName = MODULE_NAMES[ownerModule];
return (
-
- {rows.map((r, i) => (
-
-
#{i + 1}
-
s[0]).join(''), color: PALETTE[i % 8] }} size="sm" />
- {r.name}
- {r.elo}
-
- ))}
-
+
);
}
@@ -259,3 +349,20 @@ function Capsule({ title, value }: { title: string; value: string }) {
);
}
+
+function TombstonePage({ tombstone, onBack }: { tombstone: GameTombstoneDto; onBack: () => void }) {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/games/LibraryPage.tsx b/src/features/games/LibraryPage.tsx
index 7f9f2e2..7f41e6e 100644
--- a/src/features/games/LibraryPage.tsx
+++ b/src/features/games/LibraryPage.tsx
@@ -1,73 +1,219 @@
'use client';
-import React, { useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { GameArt } from '@/components/ui/GameArt';
import { Icon } from '@/components/ui/Icons';
-import { EmptyState } from '@/components/ui/EmptyState';
+import { EmptyState, Skeleton } from '@/components/ui/EmptyState';
import { Tabs } from '@/components/ui/Tabs';
-import { GAMES } from '@/mock/games';
import { ROUTES } from '@/lib/routes';
-import type { Game } from '@/types';
+import { ApiError } from '@/lib/api-client';
+import { gamesApi } from '@/features/games/gamesApi';
+import { GAME_SORTS, type GameCatalogDto, type GameSort } from '@/features/games/types';
-const FILTERS = ['All', 'Multiplayer', 'Solo vs AI', 'Strategy', 'Puzzle', 'Quick Match'];
+const FILTERS = ['All', 'Multiplayer', 'Solo vs AI', 'Strategy', 'Puzzle', 'Quick Match'] as const;
+type Filter = typeof FILTERS[number];
-export function LibraryPage() {
- const router = useRouter();
- const [filter, setFilter] = useState('All');
- const [q, setQ] = useState('');
+// Allow-listed tags not already covered by a primary filter tab above.
+const EXTRA_TAGS = ['logic', 'arcade', 'reaction', 'classic', 'vocabulary', 'memory'];
+
+const SORT_LABELS: Record
= {
+ default: 'Featured', name: 'Name', difficulty: 'Difficulty', duration: 'Duration',
+};
- let games = GAMES;
- if (filter !== 'All') {
- if (filter === 'Solo vs AI') games = games.filter(g => g.solo);
- else games = games.filter(g => g.cats.includes(filter));
+function filterParams(filter: Filter): { category?: string[]; mode?: string[] } {
+ switch (filter) {
+ case 'Multiplayer': return { mode: ['multiplayer'] };
+ case 'Solo vs AI': return { mode: ['ai'] };
+ case 'Strategy': return { category: ['strategy'] };
+ case 'Puzzle': return { category: ['puzzle'] };
+ case 'Quick Match': return { mode: ['quick-match'] };
+ default: return {};
+ }
+}
+
+function nextSort(s: GameSort): GameSort {
+ return GAME_SORTS[(GAME_SORTS.indexOf(s) + 1) % GAME_SORTS.length];
+}
+
+function titleCase(s: string) {
+ return s.length ? s.charAt(0).toUpperCase() + s.slice(1) : s;
+}
+
+function subtitleFor(g: GameCatalogDto): string {
+ const parts = [titleCase(g.category)];
+ const extraTag = g.tags.find(t => t !== g.category);
+ if (extraTag) parts.push(titleCase(extraTag));
+ return parts.join(' · ');
+}
+
+function formatDuration(g: GameCatalogDto): string {
+ return g.estimatedDurationMinMinutes === g.estimatedDurationMaxMinutes
+ ? `${g.estimatedDurationMinMinutes} min`
+ : `${g.estimatedDurationMinMinutes}–${g.estimatedDurationMaxMinutes} min`;
+}
+
+export function LibraryPage({ initialQuery = '' }: { initialQuery?: string }) {
+ const [filter, setFilter] = useState('All');
+ const [query, setQuery] = useState(initialQuery);
+ const [tags, setTags] = useState([]);
+ const [sort, setSort] = useState('default');
+ const [retryTick, setRetryTick] = useState(0);
+
+ const [games, setGames] = useState([]);
+ const [cursor, setCursor] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [featured, setFeatured] = useState(undefined);
+
+ const seq = useRef(0);
+
+ useEffect(() => {
+ gamesApi.getFeatured().then(setFeatured).catch(() => setFeatured(undefined));
+ }, []);
+
+ useEffect(() => {
+ const mySeq = ++seq.current;
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setLoading(true);
+ setError(null);
+ const timer = setTimeout(() => {
+ const { category, mode } = filterParams(filter);
+ gamesApi.list({ query: query || undefined, category, mode, tag: tags.length ? tags : undefined, sort, limit: 24 })
+ .then(page => {
+ if (seq.current !== mySeq) return;
+ setGames(page.items);
+ setCursor(page.nextCursor);
+ })
+ .catch(e => {
+ if (seq.current !== mySeq) return;
+ setError(e instanceof ApiError ? e.message : 'Failed to load games.');
+ })
+ .finally(() => {
+ if (seq.current !== mySeq) return;
+ setLoading(false);
+ });
+ }, 300);
+ return () => clearTimeout(timer);
+ }, [filter, query, tags, sort, retryTick]);
+
+ function loadMore() {
+ if (!cursor) return;
+ setLoadingMore(true);
+ const { category, mode } = filterParams(filter);
+ gamesApi.list({ query: query || undefined, category, mode, tag: tags.length ? tags : undefined, sort, limit: 24, cursor })
+ .then(page => {
+ setGames(g => [...g, ...page.items]);
+ setCursor(page.nextCursor);
+ })
+ .catch(e => setError(e instanceof ApiError ? e.message : 'Failed to load games.'))
+ .finally(() => setLoadingMore(false));
+ }
+
+ const hasActiveFilter = filter !== 'All' || query.length > 0 || tags.length > 0;
+
+ function resetFilters() {
+ setFilter('All');
+ setQuery('');
+ setTags([]);
}
- if (q) games = games.filter(g => g.name.toLowerCase().includes(q.toLowerCase()));
return (
Game Library
-
{GAMES.length} games · 6,432 players online right now
+
Browse, filter, and favorite games.
setQ(e.target.value)}
+ value={query}
+ onChange={e => setQuery(e.target.value)}
style={{ paddingLeft: 34 }}
/>
-
({ value: f, label: f, count: f === 'All' ? GAMES.length : undefined }))}
- />
+ setFilter(v as Filter)} items={FILTERS.map(f => ({ value: f, label: f }))} />
-
-
+
+
-
router.push(ROUTES.game(GAMES[3].id))} />
+ {featured && }
- {games.length === 0 ? (
+
+ {!loading && !error ? `${games.length} game${games.length === 1 ? '' : 's'} found.` : ''}
+
+
+ {loading ? (
+
+ {Array.from({ length: 6 }).map((_, i) => )}
+
+ ) : error ? (
{ setQ(''); setFilter('All'); }}>Reset filters}
+ icon="signal"
+ title="Couldn't load games."
+ body={error}
+ action={}
/>
+ ) : games.length === 0 ? (
+ hasActiveFilter ? (
+ Reset filters}
+ />
+ ) : (
+
+ )
) : (
-
- {games.map(g => (
-
router.push(ROUTES.game(g.id))} />
+ <>
+
+ {games.map(g => )}
+
+ {cursor && (
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+function MoreFiltersMenu({ selected, onChange }: { selected: string[]; onChange: (tags: string[]) => void }) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+ {open && (
+
+ {EXTRA_TAGS.map(tag => (
+
))}
)}
@@ -75,57 +221,57 @@ export function LibraryPage() {
);
}
-function FeaturedBanner({ onEnter }: { onEnter: () => void }) {
- const g = GAMES[3];
+function FeaturedBanner({ game }: { game: GameCatalogDto }) {
+ const router = useRouter();
return (
Spotlight
-
Chess Lite · Blitz Season
-
3-minute blitz with bullet endings. Climb a dedicated ladder for the next 4 weeks.
+
{game.name}
+
{game.summary}
-
-
+
-
+
);
}
-function LibraryCard({ game, onClick }: { game: Game; onClick: () => void }) {
+function LibraryCard({ game }: { game: GameCatalogDto }) {
const diffClass = game.difficulty === 'Easy' ? 'chip--success' : game.difficulty === 'Hard' ? 'chip--red' : 'chip--warn';
+ const subtitle = subtitleFor(game);
return (
-
-
-
-
-
-
{game.name}
-
{game.tag}
+
+
+
+
+
+
+
{game.name}
+
{subtitle}
+
+
{game.difficulty}
-
{game.difficulty}
-
-
-
-
- {' '}{game.minPlayers === game.maxPlayers ? game.minPlayers : `${game.minPlayers}–${game.maxPlayers}`}
-
- {game.duration}
- {game.solo && AI}
-
-
-
-
- {game.online.toLocaleString()} online
-
-
+
+
+
+ {' '}{game.minPlayers === game.maxPlayers ? game.minPlayers : `${game.minPlayers}–${game.maxPlayers}`}
+
+ {formatDuration(game)}
+ {game.capabilities.includes('ai') && AI}
+
+ {game.lifecycle !== 'Available' && (
+
+ {game.lifecycle === 'ComingSoon' ? 'Coming soon' : game.lifecycle}
+
+ )}
-
+
);
}
diff --git a/src/features/games/gamesApi.ts b/src/features/games/gamesApi.ts
new file mode 100644
index 0000000..3dd7619
--- /dev/null
+++ b/src/features/games/gamesApi.ts
@@ -0,0 +1,77 @@
+import { apiFetch, ApiError, API_BASE } from '@/lib/api-client';
+import type { CursorPage } from '@/features/friends/types';
+import type { GameCatalogDto, GameFavoriteDto, GameDetailResult, GameSort } from './types';
+
+/** Keyset cursor list params. Default page size 24 (matches the backend default), max 50. */
+interface CursorParams {
+ limit?: number;
+ cursor?: string;
+}
+
+function cursorQuery(qs: URLSearchParams, params?: CursorParams) {
+ if (params?.limit != null) qs.set('limit', String(params.limit));
+ if (params?.cursor) qs.set('after', params.cursor);
+}
+
+interface ListGamesParams extends CursorParams {
+ query?: string;
+ category?: string[];
+ tag?: string[];
+ mode?: string[];
+ lifecycle?: string[];
+ sort?: GameSort;
+}
+
+export const gamesApi = {
+ list: (params?: ListGamesParams) => {
+ const qs = new URLSearchParams();
+ if (params?.query) qs.set('query', params.query);
+ params?.category?.forEach(v => qs.append('category', v));
+ params?.tag?.forEach(v => qs.append('tag', v));
+ params?.mode?.forEach(v => qs.append('mode', v));
+ params?.lifecycle?.forEach(v => qs.append('lifecycle', v));
+ if (params?.sort) qs.set('sort', params.sort);
+ cursorQuery(qs, params);
+ const suffix = qs.toString();
+ return apiFetch
>(`/api/games${suffix ? `?${suffix}` : ''}`);
+ },
+
+ // 204 (no featured game) is handled generically by apiFetch, which returns undefined for it.
+ getFeatured: () => apiFetch('/api/games/featured'),
+
+ // A Retired detail read returns 410 with a GameTombstoneDto body (not the ApiErrorResponse envelope), so
+ // it cannot go through apiFetch's generic error path — that would discard the tombstone's name/reasonCode.
+ getDetail: async (slug: string): Promise => {
+ const response = await fetch(`${API_BASE}/api/games/${encodeURIComponent(slug)}`, {
+ credentials: 'include',
+ cache: 'no-store',
+ headers: { Accept: 'application/json' },
+ });
+
+ if (response.status === 410) {
+ return { kind: 'tombstone', tombstone: await response.json() };
+ }
+ if (!response.ok) {
+ const result = await response.json().catch(() => ({} as { error?: { code?: string; message?: string } }));
+ throw new ApiError(
+ response.status,
+ result.error?.code ?? `http_${response.status}`,
+ result.error?.message ?? 'Something went wrong. Please try again.',
+ );
+ }
+ return { kind: 'game', game: await response.json() };
+ },
+
+ getFavorites: (params?: CursorParams) => {
+ const qs = new URLSearchParams();
+ cursorQuery(qs, params);
+ const suffix = qs.toString();
+ return apiFetch>(`/api/games/me/favorites${suffix ? `?${suffix}` : ''}`);
+ },
+
+ favorite: (slug: string) =>
+ apiFetch(`/api/games/me/favorites/${encodeURIComponent(slug)}`, 'PUT'),
+
+ unfavorite: (slug: string) =>
+ apiFetch(`/api/games/me/favorites/${encodeURIComponent(slug)}`, 'DELETE'),
+};
diff --git a/src/features/games/types.ts b/src/features/games/types.ts
new file mode 100644
index 0000000..c46e7b0
--- /dev/null
+++ b/src/features/games/types.ts
@@ -0,0 +1,63 @@
+// Module 4 — Game Library & Discovery frontend contract.
+// Mirrors SimPLe.Backend Games DTOs exactly (camelCase). Catalog DTOs are auth-independent — deliberately
+// no isFavorited, online count, lastPlayed, or stats (see spec deviation D1); favorite state comes only
+// from the private favorites endpoint and is merged client-side.
+
+export type GameLifecycle = 'Draft' | 'ComingSoon' | 'Available' | 'Maintenance' | 'Retired';
+export type GameDifficulty = 'Easy' | 'Medium' | 'Hard';
+
+export const GAME_SORTS = ['default', 'name', 'difficulty', 'duration'] as const;
+export type GameSort = typeof GAME_SORTS[number];
+
+export interface GameEntryActionDto {
+ action: 'play-vs-ai' | 'quick-match' | 'create-lobby' | 'invite-friend' | 'enter-match-room';
+ status: 'deferred' | 'enabled';
+ reasonCode: string;
+ ownerModule: number;
+}
+
+export interface GameCatalogDto {
+ slug: string;
+ name: string;
+ summary: string;
+ rulesSummary: string;
+ category: string;
+ tags: string[];
+ difficulty: GameDifficulty;
+ estimatedDurationMinMinutes: number;
+ estimatedDurationMaxMinutes: number;
+ minPlayers: number;
+ maxPlayers: number;
+ lifecycle: GameLifecycle;
+ capabilities: string[];
+ featuredRank: number | null;
+ artToken: string;
+ artColorA: string;
+ artColorB: string;
+ artAltText: string;
+ entryActions: GameEntryActionDto[];
+}
+
+/** Minimal 410 body for a Retired game detail read — no summary, tags, capabilities, or art. */
+export interface GameTombstoneDto {
+ slug: string;
+ name: string;
+ lifecycle: GameLifecycle;
+ reasonCode: string;
+}
+
+/** Private favorites-list / favorite-mutation DTO. Never shared-cached. */
+export interface GameFavoriteDto {
+ slug: string;
+ name: string;
+ lifecycle: GameLifecycle;
+ artToken: string;
+ artColorA: string;
+ artColorB: string;
+ artAltText: string;
+ favoritedAt: string;
+}
+
+export type GameDetailResult =
+ | { kind: 'game'; game: GameCatalogDto }
+ | { kind: 'tombstone'; tombstone: GameTombstoneDto };
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts
index 84709fd..c7b36d1 100644
--- a/src/lib/api-client.ts
+++ b/src/lib/api-client.ts
@@ -1,6 +1,6 @@
import type { AuthUser } from '@/types';
-const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5147';
+export const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:5147';
const CSRF_HEADER = { 'X-Requested-With': 'XMLHttpRequest' };
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
diff --git a/src/lib/routes.ts b/src/lib/routes.ts
index 71d31d0..fbd81e8 100644
--- a/src/lib/routes.ts
+++ b/src/lib/routes.ts
@@ -10,7 +10,7 @@ export const ROUTES = {
u: (username: string) => `/u/${encodeURIComponent(username)}`,
uFriends: (username: string) => `/u/${encodeURIComponent(username)}/friends`,
uMutualFriends: (username: string) => `/u/${encodeURIComponent(username)}/mutual-friends`,
- search: (params: { type?: 'people'; q?: string }) => {
+ search: (params: { type?: 'people' | 'games'; q?: string }) => {
const qs = new URLSearchParams();
qs.set('type', params.type ?? 'people');
if (params.q) qs.set('q', params.q);
From 5ce4f34ffd9f5f7c6f7cd1ef56e241f5341d92a1 Mon Sep 17 00:00:00 2001
From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:21:50 +0300
Subject: [PATCH 3/5] feat(module-04-games): wire games catalog into dashboard,
search, and profile
Connects the dashboard featured card, the composed /search Games tab,
and the profile Favorite games tab to the live gamesApi client;
updates the legacy mock Game/GameArt call sites in CreateLobbyModal
and LandingPage to the tightened prop shape.
---
src/components/lobby/CreateLobbyModal.tsx | 2 +-
src/features/dashboard/DashboardPage.tsx | 101 +++++++---------
src/features/landing/LandingPage.tsx | 2 +-
src/features/profile/ProfilePage.tsx | 82 ++++++++++++-
src/features/search/SearchResultsPage.tsx | 136 ++++++++++++++++++++--
5 files changed, 250 insertions(+), 73 deletions(-)
diff --git a/src/components/lobby/CreateLobbyModal.tsx b/src/components/lobby/CreateLobbyModal.tsx
index 822d657..3232480 100644
--- a/src/components/lobby/CreateLobbyModal.tsx
+++ b/src/components/lobby/CreateLobbyModal.tsx
@@ -43,7 +43,7 @@ export function CreateLobbyModal({ open, onClose }: Props) {
{GAMES.slice(0,8).map(g => (
))}
diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx
index 7f8f52b..ef30efb 100644
--- a/src/features/dashboard/DashboardPage.tsx
+++ b/src/features/dashboard/DashboardPage.tsx
@@ -1,5 +1,6 @@
'use client';
import React, { useEffect, useState } from 'react';
+import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ApiError } from '@/lib/api-client';
import { Button } from '@/components/ui/Button';
@@ -11,13 +12,14 @@ import { CreateLobbyModal } from '@/components/lobby/CreateLobbyModal';
import { InviteFriendModal } from '@/components/friends/InviteFriendModal';
import { PlayerIdentity } from '@/components/identity/PlayerIdentity';
import { CURRENT_USER } from '@/mock/users';
-import { GAMES } from '@/mock/games';
import { RECENT_MATCHES } from '@/mock/matches';
import { NOTIFICATIONS } from '@/mock/notifications';
import { ROUTES } from '@/lib/routes';
import { friendsApi } from '@/features/friends/friendsApi';
import { useFriendSummary } from '@/features/friends/FriendSummaryContext';
import type { FriendDto } from '@/features/friends/types';
+import { gamesApi } from '@/features/games/gamesApi';
+import type { GameCatalogDto } from '@/features/games/types';
export function DashboardPage() {
const u = CURRENT_USER;
@@ -40,6 +42,16 @@ export function DashboardPage() {
return () => { cancelled = true; };
}, []);
+ const [featured, setFeatured] = useState
(undefined);
+
+ useEffect(() => {
+ let cancelled = false;
+ gamesApi.getFeatured()
+ .then(g => { if (!cancelled) setFeatured(g); })
+ .catch(() => { if (!cancelled) setFeatured(undefined); });
+ return () => { cancelled = true; };
+ }, []);
+
const inviteCount = NOTIFICATIONS.filter(n => n.kind === 'invite').length;
const subtitleFriends = summaryLoading || (!summary && !summaryError)
@@ -80,54 +92,24 @@ export function DashboardPage() {
- {/* Continue playing */}
-
-
-
-
Continue playing
-
Pick up an unfinished match or jump into a quick one.
-
-
-
-
- {[3,0,7,5].map(i => {
- const g = GAMES[i];
- return (
-
router.push(ROUTES.game(g.id))}>
-
-
-
-
- {g.duration}
- {g.difficulty}
-
-
-
-
-
- );
- })}
-
-
-
{/* Friends + Matches */}
- {/* Recommended */}
-
-
-
Recommended for you
-
-
Based on your last 50 matches
+ {/* Featured */}
+ {featured && (
+
+
+
Featured
+
-
-
- {[1,2,6].map(i => )}
-
-
+
+
+
+
+ )}
setLobbyOpen(false)} />
setInviteOpen(false)} />
@@ -143,8 +125,7 @@ function HeroPanel() {
-
-
Season 4 · ranked open
+
Region: {u.region}
@@ -345,23 +326,27 @@ function RecentMatchesPanel() {
);
}
-function GameCardBig({ game }: { game: typeof GAMES[0] }) {
- const router = useRouter();
+function GameCardBig({ game }: { game: GameCatalogDto }) {
+ const duration = game.estimatedDurationMinMinutes === game.estimatedDurationMaxMinutes
+ ? `${game.estimatedDurationMinMinutes} min`
+ : `${game.estimatedDurationMinMinutes}–${game.estimatedDurationMaxMinutes} min`;
+ const chips = [game.category, ...game.tags.filter(t => t !== game.category)].slice(0, 2);
return (
-
router.push(ROUTES.game(game.id))}>
-
-
-
-
-
{game.name}
-
{game.duration} · {game.difficulty}
+
+
+
+
+
+
+
{game.name}
+
{duration} · {game.difficulty}
+
+
+
+ {chips.map(c => {c})}
-
-
-
- {game.cats.slice(0,2).map(c => {c})}
-
+
);
}
diff --git a/src/features/landing/LandingPage.tsx b/src/features/landing/LandingPage.tsx
index a188248..16aef22 100644
--- a/src/features/landing/LandingPage.tsx
+++ b/src/features/landing/LandingPage.tsx
@@ -261,7 +261,7 @@ function DashboardPreview() {
{[GAMES[3], GAMES[1]].map(g => (
-
+
{g.duration}
diff --git a/src/features/profile/ProfilePage.tsx b/src/features/profile/ProfilePage.tsx
index cccec02..c40e1c8 100644
--- a/src/features/profile/ProfilePage.tsx
+++ b/src/features/profile/ProfilePage.tsx
@@ -1,8 +1,10 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
+import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { Avatar } from '@/components/ui/Avatar';
+import { GameArt } from '@/components/ui/GameArt';
import { Icon } from '@/components/ui/Icons';
import { StatCard } from '@/components/ui/StatCard';
import { Tabs } from '@/components/ui/Tabs';
@@ -13,6 +15,8 @@ import { profileApi, type UserProfile, type ProfileViewerContext, type Relations
import { friendsApi } from '@/features/friends/friendsApi';
import { friendsErrorMessage } from '@/features/friends/friendsErrors';
import { useAuth } from '@/features/auth/AuthProvider';
+import { gamesApi } from '@/features/games/gamesApi';
+import type { GameFavoriteDto } from '@/features/games/types';
import { ApiError } from '@/lib/api-client';
import { ROUTES } from '@/lib/routes';
@@ -39,6 +43,7 @@ function VisibilityBadge({ visibility }: { visibility: string }) {
export function ProfilePage({ username }: { username: string }) {
const { user: authUser } = useAuth();
+ const router = useRouter();
const toast = useToast();
const [tab, setTab] = useState('overview');
const [editing, setEditing] = useState(false);
@@ -89,6 +94,32 @@ export function ProfilePage({ username }: { username: string }) {
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { load(); }, [load]);
+ const [favItems, setFavItems] = useState
([]);
+ const [favCursor, setFavCursor] = useState(null);
+ const [favLoading, setFavLoading] = useState(false);
+ const [favLoadingMore, setFavLoadingMore] = useState(false);
+ const [favError, setFavError] = useState(null);
+ const favLoadedRef = useRef(false);
+
+ const loadFavorites = useCallback((cur: string | null, append: boolean) => {
+ if (append) setFavLoadingMore(true); else setFavLoading(true);
+ setFavError(null);
+ gamesApi.getFavorites({ limit: 24, cursor: cur ?? undefined })
+ .then(r => {
+ setFavItems(prev => append ? [...prev, ...r.items] : r.items);
+ setFavCursor(r.nextCursor);
+ })
+ .catch(e => setFavError(e instanceof ApiError ? e.message : 'Failed to load favorite games.'))
+ .finally(() => { setFavLoading(false); setFavLoadingMore(false); });
+ }, []);
+
+ useEffect(() => {
+ if (tab === 'games' && isOwn && !favLoadedRef.current) {
+ favLoadedRef.current = true;
+ loadFavorites(null, false);
+ }
+ }, [tab, isOwn, loadFavorites]);
+
const handleSave = async () => {
if (!profile) return;
setSaveLoading(true);
@@ -410,7 +441,7 @@ export function ProfilePage({ username }: { username: string }) {
{!editing ? (
<>
-
{profile.displayName}
+
{profile.displayName}
@{profile.username} · joined {joinedYear}{regionText ? ` · ${regionText}` : ''}
@@ -535,7 +566,36 @@ export function ProfilePage({ username }: { username: string }) {
)}
{tab === 'games' && (
-
+ !isOwn ? (
+
+ ) : favLoading ? (
+
Loading…
+ ) : favError ? (
+
+
{favError}
+
+
+ ) : favItems.length === 0 ? (
+
router.push(ROUTES.games)}>Browse games}
+ />
+ ) : (
+
+
+ {favItems.map(g => )}
+
+ {favCursor && (
+
+
+
+ )}
+
+ )
)}
@@ -673,3 +733,21 @@ function MoreMenu({ onBlock }: { onBlock: () => void }) {
);
}
+
+function FavoriteGameCard({ favorite }: { favorite: GameFavoriteDto }) {
+ return (
+
+
+
+
+
{favorite.name}
+ {favorite.lifecycle !== 'Available' && (
+
+ {favorite.lifecycle === 'ComingSoon' ? 'Coming soon' : favorite.lifecycle}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/features/search/SearchResultsPage.tsx b/src/features/search/SearchResultsPage.tsx
index ad97bab..ef0ffa8 100644
--- a/src/features/search/SearchResultsPage.tsx
+++ b/src/features/search/SearchResultsPage.tsx
@@ -1,5 +1,6 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { ApiError } from '@/lib/api-client';
import { Button } from '@/components/ui/Button';
@@ -7,8 +8,11 @@ import { Icon } from '@/components/ui/Icons';
import { Tabs } from '@/components/ui/Tabs';
import { EmptyState } from '@/components/ui/EmptyState';
import { PlayerIdentity } from '@/components/identity/PlayerIdentity';
+import { GameArt } from '@/components/ui/GameArt';
import { peopleApi } from '@/features/people/peopleApi';
import type { PeopleSearchResultDto } from '@/features/people/types';
+import { gamesApi } from '@/features/games/gamesApi';
+import type { GameCatalogDto } from '@/features/games/types';
import { ROUTES } from '@/lib/routes';
type SearchTab = 'people' | 'games' | 'lobbies';
@@ -20,6 +24,17 @@ const RELATIONSHIP_LABEL: Record
t !== g.category);
+ if (extraTag) parts.push(titleCase(extraTag));
+ return parts.join(' · ');
+}
+
export function SearchResultsPage({ initialQuery, initialType }: { initialQuery: string; initialType: string }) {
const router = useRouter();
const [tab, setTab] = useState(initialType === 'games' || initialType === 'lobbies' ? initialType : 'people');
@@ -32,7 +47,15 @@ export function SearchResultsPage({ initialQuery, initialType }: { initialQuery:
const [error, setError] = useState(null);
const [searched, setSearched] = useState(false);
+ const [gameItems, setGameItems] = useState([]);
+ const [gameCursor, setGameCursor] = useState(null);
+ const [gameLoading, setGameLoading] = useState(false);
+ const [gameLoadingMore, setGameLoadingMore] = useState(false);
+ const [gameError, setGameError] = useState(null);
+ const [gameSearched, setGameSearched] = useState(false);
+
const seq = useRef(0);
+ const gameSeq = useRef(0);
const debounceRef = useRef | null>(null);
const didMount = useRef(false);
@@ -63,32 +86,68 @@ export function SearchResultsPage({ initialQuery, initialType }: { initialQuery:
}
}, []);
- // eslint-disable-next-line react-hooks/set-state-in-effect
- useEffect(() => { load(initialQuery, null, false); }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ const loadGames = useCallback(async (q: string, cur: string | null, append: boolean) => {
+ const trimmed = q.trim();
+ if (!trimmed) {
+ gameSeq.current++;
+ setGameItems([]);
+ setGameCursor(null);
+ setGameSearched(false);
+ setGameLoading(false);
+ return;
+ }
+ const mySeq = ++gameSeq.current;
+ if (append) setGameLoadingMore(true); else setGameLoading(true);
+ setGameError(null);
+ try {
+ const r = await gamesApi.list({ query: trimmed, limit: 20, cursor: cur ?? undefined });
+ if (mySeq !== gameSeq.current) return;
+ setGameItems(prev => append ? [...prev, ...r.items] : r.items);
+ setGameCursor(r.nextCursor);
+ setGameSearched(true);
+ } catch (e) {
+ if (mySeq !== gameSeq.current) return;
+ setGameError(e instanceof ApiError ? e.message : 'Failed to search games.');
+ } finally {
+ if (mySeq === gameSeq.current) { setGameLoading(false); setGameLoadingMore(false); }
+ }
+ }, []);
+
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ if (initialType === 'games') loadGames(initialQuery, null, false);
+ else load(initialQuery, null, false);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
useEffect(() => {
if (!didMount.current) { didMount.current = true; return; }
+ if (tab === 'lobbies') return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
- router.replace(ROUTES.search({ type: 'people', q: query.trim() }));
- load(query, null, false);
+ router.replace(ROUTES.search({ type: tab, q: query.trim() }));
+ if (tab === 'games') loadGames(query, null, false);
+ else load(query, null, false);
}, 300);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [query]);
+ }, [query, tab]);
+
+ const searchPlaceholder = tab === 'games' ? 'Search games…' : 'Search people…';
+ const searchLabel = tab === 'games' ? 'Search games' : 'Search people';
return (
Search
-
Find players by username or display name.
+
Find players by username or display name, or browse games.
setQuery(e.target.value)}
/>
@@ -107,12 +166,48 @@ export function SearchResultsPage({ initialQuery, initialType }: { initialQuery:
- {tab === 'games' && (
-
- )}
{tab === 'lobbies' && (
)}
+ {tab === 'games' && (
+ gameLoading && gameItems.length === 0 ? (
+
Searching…
+ ) : gameError ? (
+
+
{gameError}
+
+
+ ) : !query.trim() ? (
+
+ ) : gameSearched && gameItems.length === 0 ? (
+
+ ) : (
+
+
+ {gameSearched ? `${gameItems.length} result${gameItems.length === 1 ? '' : 's'} for ${query.trim()}.` : ''}
+
+ {gameSearched && gameItems.length > 0 && (
+
+
+ See all games
+
+
+ )}
+ {gameItems.map(g =>
)}
+ {gameCursor && (
+
+
+
+ )}
+
+ )
+ )}
{tab === 'people' && (
loading && items.length === 0 ? (
Searching…
@@ -161,3 +256,22 @@ export function SearchResultsPage({ initialQuery, initialType }: { initialQuery:
);
}
+
+function GameSearchRow({ game }: { game: GameCatalogDto }) {
+ return (
+
+
+
+
+
+
+
+
{game.name}
+
{subtitleFor(game)}
+
+
{game.difficulty}
+
+
+
+ );
+}
From 52aa81ba8053095a989d30496b39a9b0aaed186c Mon Sep 17 00:00:00 2001
From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:21:56 +0300
Subject: [PATCH 4/5] fix(a11y): fix app-shell landmark, heading, and contrast
violations
First module to enforce the required axe-core accessibility policy
surfaced 6 pre-existing violations in the shared shell: icon-only
sidebar nav links losing their accessible name in rail mode, a
duplicate nested banner landmark, a missing page
, and a
muted-text color-contrast failure below AA. Also fixes a Module 1
AuthPage label-association bug and an accessibility-fixture bug that
skipped the axe scan on API-only test pages.
---
src/components/layout/AppShell.tsx | 4 ++--
src/features/auth/AuthPage.tsx | 9 ++++++---
src/styles/globals.css | 10 ++++++++--
tests/e2e/fixtures/accessibility.ts | 3 +++
4 files changed, 19 insertions(+), 7 deletions(-)
diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx
index a193c8d..2134d25 100644
--- a/src/components/layout/AppShell.tsx
+++ b/src/components/layout/AppShell.tsx
@@ -15,9 +15,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
-