diff --git a/web/app/loto-game-logic.ts b/web/app/loto-game-logic.ts index 98b872d..845f3a8 100644 --- a/web/app/loto-game-logic.ts +++ b/web/app/loto-game-logic.ts @@ -76,44 +76,88 @@ export function generateGrid(): number[][] { return cell; } +function safeParse(raw: string | null, validate: (v: unknown) => v is T): T | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as unknown; + return validate(parsed) ? parsed : null; + } catch { + return null; + } +} + +function isNumberMatrix(v: unknown): v is number[][] { + return ( + Array.isArray(v) && + v.length === NUM_ROWS && + v.every( + (row) => + Array.isArray(row) && + row.length === NUM_COLS && + row.every((n) => typeof n === "number") + ) + ); +} + +function isBoolMatrix(v: unknown): v is boolean[][] { + return ( + Array.isArray(v) && + v.length === NUM_ROWS && + v.every( + (row) => + Array.isArray(row) && + row.length === NUM_COLS && + row.every((b) => typeof b === "boolean") + ) + ); +} + export function saveGrid(grid: number[][], prefix = "loto"): void { - localStorage.setItem(`${prefix}_grid`, JSON.stringify(grid)); + try { + localStorage.setItem(`${prefix}_grid`, JSON.stringify(grid)); + } catch { + // localStorage disabled or quota exceeded — ignore, app still works in-memory + } } export function loadGrid(prefix = "loto"): number[][] | null { - const data = localStorage.getItem(`${prefix}_grid`); - if (!data) return null; try { - return JSON.parse(data); + return safeParse(localStorage.getItem(`${prefix}_grid`), isNumberMatrix); } catch { return null; } } export function saveCrossedState(crossed: boolean[][], prefix = "loto"): void { - localStorage.setItem(`${prefix}_crossed`, JSON.stringify(crossed)); + try { + localStorage.setItem(`${prefix}_crossed`, JSON.stringify(crossed)); + } catch { + // see saveGrid + } } export function loadCrossedState(prefix = "loto"): boolean[][] | null { - const data = localStorage.getItem(`${prefix}_crossed`); - if (!data) return null; try { - return JSON.parse(data); + return safeParse(localStorage.getItem(`${prefix}_crossed`), isBoolMatrix); } catch { return null; } } -/** Check if a row has all its numbers crossed */ +/** Check if a row has all its numbers crossed (and has at least one number) */ export function isRowComplete( grid: number[][], crossed: boolean[][], row: number ): boolean { - for (let col = 0; col < 9; col++) { - if (grid[row][col] > 0 && !crossed[row]?.[col]) return false; + let hasNumber = false; + for (let col = 0; col < NUM_COLS; col++) { + if (grid[row][col] > 0) { + hasNumber = true; + if (!crossed[row]?.[col]) return false; + } } - return true; + return hasNumber; } /** Find the single remaining uncrossed number in a row, or null if != 1 remaining */ @@ -123,7 +167,7 @@ export function getWaitingNumber( row: number ): number | null { let remaining: number | null = null; - for (let col = 0; col < 9; col++) { + for (let col = 0; col < NUM_COLS; col++) { if (grid[row][col] > 0 && !crossed[row]?.[col]) { if (remaining !== null) return null; remaining = grid[row][col]; diff --git a/web/app/loto-player-board.tsx b/web/app/loto-player-board.tsx index 68c06ca..ba83148 100644 --- a/web/app/loto-player-board.tsx +++ b/web/app/loto-player-board.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { generateGrid, getWaitingNumber, @@ -72,30 +72,36 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps useEffect(() => { if (!grid || crossed.length === 0) return; + // Pass 1: at most one bingo popup per render for (let i = 0; i < grid.length; i++) { if (!celebratedRows.current.has(i) && isRowComplete(grid, crossed, i)) { celebratedRows.current.add(i); notifiedWaitingRows.current.add(i); setCongratsRow(i + 1); setShowCongrats(true); - return; + break; } + } + + // Pass 2: update waiting state for every non-celebrated row + for (let i = 0; i < grid.length; i++) { + if (celebratedRows.current.has(i)) continue; const waitNum = getWaitingNumber(grid, crossed, i); if (waitNum !== null && !notifiedWaitingRows.current.has(i)) { notifiedWaitingRows.current.add(i); showToast(`Chờ ${waitNum}`); - return; - } - if ( - waitNum === null && - notifiedWaitingRows.current.has(i) && - !celebratedRows.current.has(i) - ) { + } else if (waitNum === null && notifiedWaitingRows.current.has(i)) { notifiedWaitingRows.current.delete(i); } } }, [grid, crossed, showToast]); + // Precompute per-row completeness so we don't call isRowComplete 81×/render + const rowCompleteness = useMemo(() => { + if (!grid || crossed.length === 0) return []; + return grid.map((_, row) => isRowComplete(grid, crossed, row)); + }, [grid, crossed]); + const handleGenerate = useCallback(() => { if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return; const newGrid = generateGrid(); @@ -133,40 +139,52 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps {grid ? (
-
+
{grid.flat().map((num, idx) => { const row = Math.floor(idx / 9); const col = idx % 9; const hasNumber = num > 0; - const isCrossed = hasNumber && crossed[row]?.[col]; - const rowComplete = - hasNumber && isRowComplete(grid, crossed, row); + const isCrossed = hasNumber && !!crossed[row]?.[col]; + const rowComplete = hasNumber && rowCompleteness[row]; + + if (!hasNumber) { + return ( + @@ -174,6 +192,8 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps {toast && (
@@ -191,8 +211,15 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps {showCongrats && (
setShowCongrats(false)} + onKeyDown={(e) => { + if (e.key === "Escape") setShowCongrats(false); + }} + tabIndex={-1} >
-

+

Kinh!

diff --git a/web/app/master/page.tsx b/web/app/master/page.tsx index 2aa0f5f..36c616f 100644 --- a/web/app/master/page.tsx +++ b/web/app/master/page.tsx @@ -58,7 +58,10 @@ function loadState(): MasterState | null { } } -const BOARD = buildBoard(); +const BOARD: ReadonlyArray> = Object.freeze( + buildBoard().map((row) => Object.freeze(row)) +); +const BOARD_FLAT: ReadonlyArray = Object.freeze(BOARD.flatMap((r) => r)); export default function MasterPage() { const [state, setState] = useState(null); @@ -179,9 +182,12 @@ export default function MasterPage() { {/* Master board 9x10 */} {state ? ( -

+
- {BOARD.flat().map((num, idx) => { + {BOARD_FLAT.map((num, idx) => { const hasNumber = num > 0; const isCalled = hasNumber && calledSet.has(num); diff --git a/web/next-env.d.ts b/web/next-env.d.ts index c4b7818..9edff1c 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/next.config.ts b/web/next.config.ts index 2462dd4..b496f8c 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -20,7 +20,9 @@ function codeserverConfig() { } const cs = isCodeserver ? codeserverConfig() : null; -const basePath = cs?.basePath ?? (isProd ? "/loto" : ""); +// NEXT_BASE_PATH wins so custom-domain / fork deploys don't have to edit code. +const basePath = + process.env.NEXT_BASE_PATH ?? cs?.basePath ?? (isProd ? "/loto" : ""); const nextConfig: NextConfig = { output: "export",