From 03d4c743618e99529f70cf29dc3e03da836a1b9a Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:19:38 +0300 Subject: [PATCH 1/5] chore(module-04-prep): add shared axe accessibility E2E fixture Module 4 is the first module whose E2E manifest requires accessibility checks. Adds the axe-core devDependency and shared fixture, and ignores generated test-results output. --- .gitignore | 1 + package-lock.json | 20 +++++++++++++++++--- package.json | 1 + tests/e2e/fixtures/accessibility.ts | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/fixtures/accessibility.ts diff --git a/.gitignore b/.gitignore index b721bff..dcc409c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +/test-results # next.js /.next/ diff --git a/package-lock.json b/package-lock.json index ce70c34..5964f19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "react-dom": "19.2.4" }, "devDependencies": { + "@axe-core/playwright": "4.12.1", "@playwright/test": "^1.49.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", @@ -103,6 +104,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -3369,9 +3383,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", - "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", "dev": true, "license": "MPL-2.0", "engines": { diff --git a/package.json b/package.json index a5f9a30..e872560 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "devDependencies": { "@playwright/test": "^1.49.1", + "@axe-core/playwright": "4.12.1", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/tests/e2e/fixtures/accessibility.ts b/tests/e2e/fixtures/accessibility.ts new file mode 100644 index 0000000..6b993ef --- /dev/null +++ b/tests/e2e/fixtures/accessibility.ts @@ -0,0 +1,18 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test as base } from '@playwright/test'; + +export { expect }; +export const test = base.extend({}); + +// Module E2E specs that import this fixture automatically scan their exercised page after each passing test. +// This is a focused regression check, not a replacement for the Module 14 manual/screen-reader review. +test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status !== 'passed') return; + + const results = await new AxeBuilder({ page }).analyze(); + const summary = results.violations + .map((violation) => `${violation.id}: ${violation.help} (${violation.nodes.length} node(s))`) + .join('\n'); + + expect(results.violations, `axe accessibility violations:\n${summary}`).toEqual([]); +}); From 5182d390be3617f5e1e322200a0115d2b60e7f8d Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:21:43 +0300 Subject: [PATCH 2/5] feat(module-04-games): add game library and detail pages Wires /games (browse, filter, tag/mode/sort, search, load-more) and /games/[gameId] (detail, ComingSoon/tombstone handling, favorite toggle, honestly-disabled entry actions) to the real backend catalog API via a new gamesApi client, and tightens GameArt's prop contract to the real DTO shape. --- src/app/(app)/games/page.tsx | 5 +- src/components/ui/GameArt.tsx | 42 ++- src/features/games/GameDetailPage.tsx | 419 ++++++++++++++++---------- src/features/games/LibraryPage.tsx | 276 +++++++++++++---- src/features/games/gamesApi.ts | 77 +++++ src/features/games/types.ts | 63 ++++ src/lib/api-client.ts | 2 +- src/lib/routes.ts | 2 +- 8 files changed, 645 insertions(+), 241 deletions(-) create mode 100644 src/features/games/gamesApi.ts create mode 100644 src/features/games/types.ts diff --git a/src/app/(app)/games/page.tsx b/src/app/(app)/games/page.tsx index 1e20491..5897fde 100644 --- a/src/app/(app)/games/page.tsx +++ b/src/app/(app)/games/page.tsx @@ -1,5 +1,6 @@ import { LibraryPage } from '@/features/games/LibraryPage'; -export default function GamesPage() { - return ; +export default async function GamesPage({ searchParams }: { searchParams: Promise<{ query?: string }> }) { + const { query } = await searchParams; + return ; } diff --git a/src/components/ui/GameArt.tsx b/src/components/ui/GameArt.tsx index 5611613..9e573b5 100644 --- a/src/components/ui/GameArt.tsx +++ b/src/components/ui/GameArt.tsx @@ -1,6 +1,5 @@ 'use client'; import React from 'react'; -import type { Game } from '@/types'; function ArtPattern({ kind, a }: { kind: string; a: string }) { if (kind === 'sudoku') return ( @@ -13,14 +12,14 @@ function ArtPattern({ kind, a }: { kind: string; a: string }) { 3 ); - if (kind === 'tetris') return ( + if (kind === 'falling-blocks') return ( {[[20,60],[40,60],[60,60],[40,40]].map(([x,y],i) => )} {[[100,80],[120,80],[120,60],[140,60]].map(([x,y],i) => )} {[[160,30],[160,50],[160,70],[160,90]].map(([x,y],i) => )} ); - if (kind === 'c4') return ( + if (kind === 'four-in-a-row') return ( {Array.from({length:5}).map((_,r) => Array.from({length:7}).map((_2,c) => { const v = (r+c) % 5; @@ -48,7 +47,7 @@ function ArtPattern({ kind, a }: { kind: string; a: string }) { }))} ); - if (kind === 'word') { + if (kind === 'five-letter') { const word = ['S','I','M','P','L','E']; return ( @@ -75,29 +74,40 @@ function ArtPattern({ kind, a }: { kind: string; a: string }) { ); - return null; + // Neutral fallback for an unrecognized art token — never renders nothing (broken-media state). + return ( + + {Array.from({ length: 5 }).map((_, r) => Array.from({ length: 8 }).map((_2, c) => ( + + )))} + + ); +} + +interface GameArtGame { + artToken: string; + artColorA: string; + artColorB: string; + artAltText: string; + name: string; } interface GameArtProps { - game: Pick; + game: GameArtGame; + /** Short derived line under the name, e.g. "Puzzle · Logic" — never a fabricated stat. */ + subtitle?: string; h?: number | string; } -export function GameArt({ game, h = 140 }: GameArtProps) { - const { kind, a, b } = game.art; +export function GameArt({ game, subtitle, h = 140 }: GameArtProps) { + const { artToken: kind, artColorA: a, artColorB: b, artAltText } = game; const bg = `radial-gradient(120% 80% at 80% 10%, ${a}33, transparent 60%), linear-gradient(180deg, ${b}, #07090F)`; return ( -
+
{game.name}
-
{game.tag}
-
-
- - - {game.online.toLocaleString()} online - + {subtitle &&
{subtitle}
}
); diff --git a/src/features/games/GameDetailPage.tsx b/src/features/games/GameDetailPage.tsx index 4191a72..463cafa 100644 --- a/src/features/games/GameDetailPage.tsx +++ b/src/features/games/GameDetailPage.tsx @@ -1,55 +1,220 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; 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'; -import { InviteFriendModal } from '@/components/friends/InviteFriendModal'; -import { GAMES } from '@/mock/games'; -import { FRIENDS } from '@/mock/friends'; -import { PALETTE } from '@/mock/users'; +import { EmptyState, Skeleton } from '@/components/ui/EmptyState'; import { ROUTES } from '@/lib/routes'; -import type { Game } from '@/types'; +import { ApiError } from '@/lib/api-client'; +import { useAuth } from '@/features/auth/AuthProvider'; +import { gamesApi } from '@/features/games/gamesApi'; +import type { GameCatalogDto, GameDetailResult, GameEntryActionDto, GameTombstoneDto } from '@/features/games/types'; + +// Real module names (docs/ai-workflow/module-registry.md) for honest "coming later" explanations — +// never invent a name for a module not in the registry. +const MODULE_NAMES: Record = { + 6: 'Lobby & Matchmaking System', + 8: 'Generic Match Room & Match State', + 9: 'Solo vs AI Platform Flow', + 10: 'Stats, Achievements & Leaderboards', +}; + +const ACTION_META: Record = { + 'play-vs-ai': { icon: 'ai', title: 'Play vs AI', color: '#F0394B' }, + 'quick-match': { icon: 'bolt', title: 'Quick match', color: '#34D399' }, + 'create-lobby': { icon: 'plus', title: 'Create lobby', color: '#A78BFA' }, + 'invite-friend': { icon: 'users', title: 'Invite friend', color: '#38BDF8' }, + 'enter-match-room': { icon: 'controller', title: 'Enter match room', color: '#FBBF24' }, +}; + +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`; +} + +function reasonFor(a: GameEntryActionDto): string { + const moduleName = MODULE_NAMES[a.ownerModule]; + return moduleName + ? `Available once Module ${a.ownerModule} — ${moduleName} ships.` + : `Available once Module ${a.ownerModule} ships.`; +} export function GameDetailPage({ gameId }: { gameId: string }) { const router = useRouter(); - const game = GAMES.find(g => g.id === gameId) ?? GAMES[0]; + const { status } = useAuth(); + + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notFoundMessage, setNotFoundMessage] = useState(null); + const [retryTick, setRetryTick] = useState(0); const [tab, setTab] = useState('overview'); - const [aiLevel, setAiLevel] = useState(game.aiLevels[1]); - const [inviteOpen, setInviteOpen] = useState(false); + + const [isFavorited, setIsFavorited] = useState(null); + const [favoriteBusy, setFavoriteBusy] = useState(false); + const [showSignInPrompt, setShowSignInPrompt] = useState(false); + const [favoriteError, setFavoriteError] = useState(null); + + useEffect(() => { + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoading(true); + setError(null); + setNotFoundMessage(null); + gamesApi.getDetail(gameId) + .then(r => { if (!cancelled) setResult(r); }) + .catch(e => { + if (cancelled) return; + if (e instanceof ApiError && e.status === 404) { + setNotFoundMessage("This game isn't available."); + return; + } + setError(e instanceof ApiError ? e.message : 'Failed to load this game.'); + }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [gameId, retryTick]); + + useEffect(() => { + if (status !== 'authenticated' || !result || result.kind !== 'game') { + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsFavorited(status === 'authenticated' ? null : false); + return; + } + let cancelled = false; + (async () => { + let cursor: string | undefined; + for (let page = 0; page < 20; page++) { + const res = await gamesApi.getFavorites({ limit: 50, cursor }).catch(() => null); + if (!res || cancelled) return; + if (res.items.some(f => f.slug === gameId)) { if (!cancelled) setIsFavorited(true); return; } + if (!res.nextCursor) break; + cursor = res.nextCursor; + } + if (!cancelled) setIsFavorited(false); + })(); + return () => { cancelled = true; }; + }, [status, result, gameId]); + + async function toggleFavorite() { + if (status !== 'authenticated') { setShowSignInPrompt(true); return; } + if (isFavorited === null || favoriteBusy) return; + const was = isFavorited; + setFavoriteError(null); + setIsFavorited(!was); + setFavoriteBusy(true); + try { + if (was) await gamesApi.unfavorite(gameId); + else await gamesApi.favorite(gameId); + } catch (e) { + setIsFavorited(was); + if (e instanceof ApiError && e.status === 401) setShowSignInPrompt(true); + else setFavoriteError(e instanceof ApiError ? e.message : 'Failed to update favorite.'); + } finally { + setFavoriteBusy(false); + } + } + + if (loading) { + return ( +
+ +
+
+ ); + } + + if (notFoundMessage) { + return ( +
+
+ {notFoundMessage} +
+
+ ); + } + + if (error) { + return ( +
+ setRetryTick(t => t + 1)}>Retry} + /> +
+ ); + } + + if (!result) return null; + + if (result.kind === 'tombstone') { + return router.push(ROUTES.games)} />; + } + + const game = result.game; return (
-
- +
{game.name}
-
{game.tag}
+
{subtitleFor(game)}
-
- {game.cats.map(c => {c})} + +
+ + {showSignInPrompt && ( +
+ + Sign in to favorite games. + +
+ )} + {favoriteError && ( +
{favoriteError}
+ )} + +
+ {titleCase(game.category)} + {game.tags.map(t => {titleCase(t)})} + {game.lifecycle !== 'Available' && ( + {game.lifecycle === 'ComingSoon' ? 'Coming soon' : game.lifecycle} + )}
- - + + -
@@ -66,8 +231,8 @@ export function GameDetailPage({ gameId }: { gameId: string }) {
{tab === 'overview' && } {tab === 'rules' && } - {tab === 'stats' && } - {tab === 'leader' && } + {tab === 'stats' && } + {tab === 'leader' && }
@@ -76,178 +241,103 @@ export function GameDetailPage({ gameId }: { gameId: string }) {
Start a match
-
Choose a mode
+
Actions below unlock as later modules ship.
- router.push(ROUTES.room(`${game.id}-ai`))} - /> -
- {game.aiLevels.map(lvl => ( - - ))} -
- setInviteOpen(true)} - /> - router.push(ROUTES.lobby('SP-7F-29'))} - /> - router.push(ROUTES.room(`${game.id}-quick`))} - /> -
-
- -
-
-
-
- -
-
-
Friends playing
-
3 right now
-
-
- -
-
- {FRIENDS.slice(0, 3).map(f => ( -
- -
-
{f.display}
-
{f.activity}
-
- -
- ))} + {game.entryActions.map(a => )}
- - setInviteOpen(false)} />
); } -function InlineStat({ label, value, icon }: { label: string; value: string; icon: string }) { +function FavoriteToggle({ isFavorited, busy, onToggle }: { isFavorited: boolean | null; busy: boolean; onToggle: () => void }) { + const known = isFavorited !== null; return ( -
- -
-
{label}
-
{value}
-
-
+ ); } -function ActionButton({ color, icon, title, sub, onClick }: { - color: string; icon: string; title: string; sub: string; onClick: () => void; -}) { +function DisabledActionRow({ action }: { action: GameEntryActionDto }) { + const meta = ACTION_META[action.action]; + const reason = reasonFor(action); return ( ); } -function OverviewTab({ game }: { game: Game }) { +function InlineStat({ label, value, icon }: { label: string; value: string; icon: string }) { return ( -
-

{game.rules} A lightweight refresh of a classic — same rules you know, polished for fast online play.

-
- - - +
+ +
+
{label}
+
{value}
); } -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 }) {
-
+
setDrawerOpen(true)} /> -
+

{children}
diff --git a/src/features/auth/AuthPage.tsx b/src/features/auth/AuthPage.tsx index c87f642..07b66d5 100644 --- a/src/features/auth/AuthPage.tsx +++ b/src/features/auth/AuthPage.tsx @@ -471,13 +471,16 @@ function ProfileSetup({ draft, onDone: _onDone }: { draft: RegisterDraft; onDone } function Field({ label, hint, children }: { label:string; hint?: React.ReactNode; children: React.ReactNode }) { + // `children` (the real input) is placed before the hint in DOM order so the browser's implicit + // label-to-control association resolves to the input, not to an interactive hint (e.g. "Forgot?"). + // CSS `order` restores the original label-then-input visual layout. return ( -