mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-13 18:15:53 +00:00
refactor: harden loto game per code review
- Validate localStorage shape on load (typed parse, guarded set) - Fix isRowComplete to require at least one number per row - Split row-scan effect into bingo + waiting passes so updates to later rows are not skipped by an early return - Memoize per-row completeness; was recomputing 81 times per render - Replace clickable divs with real <button>s; aria-label, aria-pressed, focus ring, dialog role + escape on the bingo modal - Freeze the master tracking board to make module-scope state safer - Allow NEXT_BASE_PATH to override the prod default for forks / custom-domain deploys
This commit is contained in:
1 parent
0844027460
commit
abc97aa87b
5 files changed
+128
-46
No files matched your search
+57
-13
@@ -76,44 +76,88 @@ export function generateGrid(): number[][] {
|
||||
return cell;
|
||||
}
|
||||
|
||||
function safeParse<T>(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];
|
||||
|
||||
+58
-28
@@ -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 ? (
|
||||
<div className="relative">
|
||||
<div className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700">
|
||||
<div
|
||||
aria-label="Bảng lô tô"
|
||||
className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<div className="loto-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 (
|
||||
<div
|
||||
key={idx}
|
||||
aria-hidden="true"
|
||||
className="relative flex items-center justify-center aspect-square border-r border-b border-slate-200/80 dark:border-slate-700/60 bg-slate-50 dark:bg-slate-900/60"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={idx}
|
||||
onClick={
|
||||
hasNumber ? () => handleCellClick(row, col) : undefined
|
||||
}
|
||||
type="button"
|
||||
aria-label={`Số ${num}${isCrossed ? ", đã đánh dấu" : ""}`}
|
||||
aria-pressed={isCrossed}
|
||||
onClick={() => handleCellClick(row, col)}
|
||||
className={`
|
||||
relative flex items-center justify-center
|
||||
aspect-square text-base sm:text-xl font-bold
|
||||
border-r border-b border-slate-200/80 dark:border-slate-700/60
|
||||
transition-all select-none
|
||||
transition-all select-none cursor-pointer
|
||||
focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-400
|
||||
${
|
||||
hasNumber
|
||||
? isCrossed
|
||||
? rowComplete
|
||||
? "cell-crossed bg-emerald-100 dark:bg-emerald-900/40 text-emerald-500 dark:text-emerald-400 cursor-pointer"
|
||||
: "cell-crossed bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500 cursor-pointer"
|
||||
: "bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 cursor-pointer hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400"
|
||||
: "bg-slate-50 dark:bg-slate-900/60"
|
||||
isCrossed
|
||||
? rowComplete
|
||||
? "cell-crossed bg-emerald-100 dark:bg-emerald-900/40 text-emerald-500 dark:text-emerald-400"
|
||||
: "cell-crossed bg-red-50 dark:bg-red-950/30 text-red-400 dark:text-red-500"
|
||||
: "bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100 hover:bg-indigo-50 dark:hover:bg-indigo-950/30 hover:text-indigo-600 dark:hover:text-indigo-400"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{hasNumber ? num : ""}
|
||||
</div>
|
||||
{num}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -174,6 +192,8 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
onClick={dismissToast}
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-auto cursor-pointer z-10"
|
||||
>
|
||||
@@ -191,8 +211,15 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps
|
||||
|
||||
{showCongrats && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="congrats-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||
onClick={() => setShowCongrats(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setShowCongrats(false);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="relative mx-4 max-w-sm w-full rounded-3xl bg-white dark:bg-slate-800 p-8 text-center shadow-2xl animate-pop-in"
|
||||
@@ -208,7 +235,10 @@ export default function PlayerBoard({ storagePrefix = "loto" }: PlayerBoardProps
|
||||
🎊
|
||||
</div>
|
||||
|
||||
<h2 className="mt-6 text-3xl font-black bg-gradient-to-r from-amber-500 via-pink-500 to-purple-500 bg-clip-text text-transparent">
|
||||
<h2
|
||||
id="congrats-title"
|
||||
className="mt-6 text-3xl font-black bg-gradient-to-r from-amber-500 via-pink-500 to-purple-500 bg-clip-text text-transparent"
|
||||
>
|
||||
Kinh!
|
||||
</h2>
|
||||
<p className="mt-3 text-lg text-slate-600 dark:text-slate-300">
|
||||
|
||||
+9
-3
@@ -58,7 +58,10 @@ function loadState(): MasterState | null {
|
||||
}
|
||||
}
|
||||
|
||||
const BOARD = buildBoard();
|
||||
const BOARD: ReadonlyArray<ReadonlyArray<number>> = Object.freeze(
|
||||
buildBoard().map((row) => Object.freeze(row))
|
||||
);
|
||||
const BOARD_FLAT: ReadonlyArray<number> = Object.freeze(BOARD.flatMap((r) => r));
|
||||
|
||||
export default function MasterPage() {
|
||||
const [state, setState] = useState<MasterState | null>(null);
|
||||
@@ -179,9 +182,12 @@ export default function MasterPage() {
|
||||
|
||||
{/* Master board 9x10 */}
|
||||
{state ? (
|
||||
<div className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700">
|
||||
<div
|
||||
aria-label="Bảng theo dõi số đã xổ"
|
||||
className="rounded-2xl overflow-hidden shadow-xl shadow-slate-200/50 dark:shadow-black/30 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<div className="master-grid">
|
||||
{BOARD.flat().map((num, idx) => {
|
||||
{BOARD_FLAT.map((num, idx) => {
|
||||
const hasNumber = num > 0;
|
||||
const isCalled = hasNumber && calledSet.has(num);
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
+3
-1
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user