From c89548b82a2b92dade5b0430e34980eb42df498e Mon Sep 17 00:00:00 2001 From: Mohannad Ehab <157999295+MohanEhab@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:45:34 +0300 Subject: [PATCH 1/3] feat(module-06-lobby-matchmaking): add lobby and matchmaking client features Adds LobbyPage, CreateLobbyModal, QuickMatchModal, and the lobby/ matchmaking API clients, error mappers, and types. Extends routes and shared types for lobby/matchmaking flows, gated behind the M8 match runtime dependency. --- src/components/lobby/CreateLobbyModal.tsx | 282 +++++++--- src/components/lobby/QuickMatchModal.tsx | 238 ++++++++ src/features/lobby/LobbyPage.tsx | 513 +++++++++++++----- src/features/lobby/lobbyApi.ts | 82 +++ src/features/lobby/lobbyErrors.ts | 49 ++ src/features/lobby/types.ts | 170 ++++++ src/features/matchmaking/matchmakingApi.ts | 13 + src/features/matchmaking/matchmakingErrors.ts | 20 + src/features/matchmaking/types.ts | 40 ++ src/lib/routes.ts | 2 +- src/mock/lobbies.ts | 13 +- src/types/index.ts | 8 - 12 files changed, 1186 insertions(+), 244 deletions(-) create mode 100644 src/components/lobby/QuickMatchModal.tsx create mode 100644 src/features/lobby/lobbyApi.ts create mode 100644 src/features/lobby/lobbyErrors.ts create mode 100644 src/features/lobby/types.ts create mode 100644 src/features/matchmaking/matchmakingApi.ts create mode 100644 src/features/matchmaking/matchmakingErrors.ts create mode 100644 src/features/matchmaking/types.ts diff --git a/src/components/lobby/CreateLobbyModal.tsx b/src/components/lobby/CreateLobbyModal.tsx index 3232480..1e25d9c 100644 --- a/src/components/lobby/CreateLobbyModal.tsx +++ b/src/components/lobby/CreateLobbyModal.tsx @@ -1,97 +1,231 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { GameArt } from '@/components/ui/GameArt'; import { Toggle } from '@/components/ui/Toggle'; import { Icon } from '@/components/ui/Icons'; -import { GAMES } from '@/mock/games'; +import { gamesApi } from '@/features/games/gamesApi'; +import type { GameCatalogDto } from '@/features/games/types'; +import { lobbyApi } from '@/features/lobby/lobbyApi'; +import { lobbyErrorMessage } from '@/features/lobby/lobbyErrors'; +import type { GameCapabilityProfileDto } from '@/features/lobby/types'; import { ROUTES } from '@/lib/routes'; -import { TIME_CONTROLS } from '@/lib/constants'; interface Props { open: boolean; onClose: () => void; + /** Pre-selects this game in the picker (e.g. from a game's detail page), if it's eligible to host. */ + preselectedGameSlug?: string; } -export function CreateLobbyModal({ open, onClose }: Props) { +type Step = 'loading' | 'form' | 'error'; + +export function CreateLobbyModal({ open, onClose, preselectedGameSlug }: Props) { const router = useRouter(); - const [game, setGame] = useState(GAMES[3]); - const [privacy, setPrivacy] = useState<'private'|'public'>('private'); - const [seats, setSeats] = useState(4); - const [time, setTime] = useState(TIME_CONTROLS[1]); - const [rated, setRated] = useState(true); - const handleCreate = () => { - onClose(); - router.push(ROUTES.lobby('SP-7F-29')); - }; + const [step, setStep] = useState('loading'); + const [loadError, setLoadError] = useState(null); + const [games, setGames] = useState([]); + const [game, setGame] = useState(null); + + const [profile, setProfile] = useState(null); + const [profileLoading, setProfileLoading] = useState(false); + + const [privacy, setPrivacy] = useState<'Private' | 'Public'>('Private'); + const [seats, setSeats] = useState(2); + const [timeControlId, setTimeControlId] = useState(''); + const [rated, setRated] = useState(false); + + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect + setStep('loading'); + setLoadError(null); + setCreateError(null); + gamesApi.list({ lifecycle: ['Available'], sort: 'name', limit: 50 }) + .then(page => { + if (cancelled) return; + // Create Lobby needs more than one seat — single-player games have no lobby to create. + const eligible = page.items.filter(g => g.maxPlayers > 1); + setGames(eligible); + const preselected = preselectedGameSlug ? eligible.find(g => g.slug === preselectedGameSlug) : undefined; + setGame(preselected ?? eligible[0] ?? null); + setStep('form'); + }) + .catch(e => { + if (cancelled) return; + setLoadError(lobbyErrorMessage(e)); + setStep('error'); + }); + return () => { cancelled = true; }; + }, [open, preselectedGameSlug]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (!game) { setProfile(null); return; } + let cancelled = false; + setProfileLoading(true); + setProfile(null); + lobbyApi.getCapabilityProfile(game.slug) + .then(p => { + if (cancelled) return; + setProfile(p); + setSeats(Math.min(Math.max(2, p.minPlayers), p.maxPlayers)); + setTimeControlId(p.timeControls[0] ?? ''); + setRated(false); + }) + .catch(e => { if (!cancelled) setCreateError(lobbyErrorMessage(e)); }) + .finally(() => { if (!cancelled) setProfileLoading(false); }); + return () => { cancelled = true; }; + }, [game]); + + async function handleCreate() { + if (!game || !profile || creating) return; + setCreating(true); + setCreateError(null); + try { + const result = await lobbyApi.create({ + gameSlug: game.slug, + capabilityVersion: profile.capabilityVersion, + privacy, + maxPlayers: seats, + timeControlId, + rated, + spectatorPolicy: profile.spectatorPolicies[0] ?? '', + tieBreakRuleId: profile.tieBreakRules[0] ?? '', + aiFillRequested: false, + }); + try { + sessionStorage.setItem( + `lobby-credential:${result.lobby.lobbyId}`, + JSON.stringify({ code: result.credential.code, linkToken: result.credential.linkToken }), + ); + } catch { /* sessionStorage unavailable — the host can still re-reveal via rotate. */ } + onClose(); + router.push(ROUTES.lobby(result.lobby.lobbyId)); + } catch (e) { + setCreateError(lobbyErrorMessage(e)); + } finally { + setCreating(false); + } + } + + const seatOptions = profile + ? Array.from({ length: profile.maxPlayers - profile.minPlayers + 1 }, (_, i) => profile.minPlayers + i) + : []; return ( - - - - - }> -
- - -
- - - -