From 30554d21a28374ca86613a5da7bbdfdac7421257 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 4 Apr 2026 22:44:12 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20add=20"Kinh!"=20congrats=20popup=20and?= =?UTF-8?q?=20"Ch=E1=BB=9D=20X"=20waiting=20toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show animated congrats when all 5 numbers in a row are crossed. Show "Chờ [number]" toast overlay when 4/5 crossed. Auto-fades after 5s or dismissible by tap. --- app/globals.css | 38 +++++++++ app/page.tsx | 214 ++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 218 insertions(+), 34 deletions(-) diff --git a/app/globals.css b/app/globals.css index c2981b9..a7f5456 100644 --- a/app/globals.css +++ b/app/globals.css @@ -38,6 +38,44 @@ body { gap: 0; } +/* Congratulations popup animations */ +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes pop-in { + 0% { opacity: 0; transform: scale(0.5); } + 70% { transform: scale(1.05); } + 100% { opacity: 1; transform: scale(1); } +} + +@keyframes bounce-slow { + 0%, 100% { transform: translateX(-50%) translateY(0); } + 50% { transform: translateX(-50%) translateY(-12px); } +} + +@keyframes spin-slow { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.animate-fade-in { animation: fade-in 0.2s ease-out; } +.animate-pop-in { animation: pop-in 0.4s cubic-bezier(0.16, 1, 0.3, 1); } +.animate-bounce-slow { animation: bounce-slow 1.5s ease-in-out infinite; } +.animate-spin-slow { animation: spin-slow 3s linear infinite; } +.animate-spin-slow-reverse { animation: spin-slow 3s linear infinite reverse; } + +@keyframes toast { + 0% { opacity: 0; transform: scale(0.8); } + 10% { opacity: 1; transform: scale(1.05); } + 15% { transform: scale(1); } + 80% { opacity: 1; } + 100% { opacity: 0; } +} + +.animate-toast { animation: toast 5s ease-in-out forwards; } + /* Crossed cell diagonal line */ .cell-crossed::after { content: ""; diff --git a/app/page.tsx b/app/page.tsx index 783b7e9..45c91d2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { generateGrid, loadCrossedState, @@ -10,18 +10,78 @@ import { saveGrid, } from "./loto-game-logic"; +/** Check if a row has all its numbers crossed */ +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; + } + return true; +} + +/** Find the single remaining uncrossed number in a row, or null if != 1 remaining */ +function getWaitingNumber( + grid: number[][], + crossed: boolean[][], + row: number +): number | null { + let remaining: number | null = null; + for (let col = 0; col < 9; col++) { + if (grid[row][col] > 0 && !crossed[row]?.[col]) { + if (remaining !== null) return null; // more than 1 remaining + remaining = grid[row][col]; + } + } + return remaining; +} + export default function Home() { const [grid, setGrid] = useState(null); const [crossed, setCrossed] = useState([]); const [showInstructions, setShowInstructions] = useState(false); + const [showCongrats, setShowCongrats] = useState(false); + const [congratsRow, setCongratsRow] = useState(-1); + const [toast, setToast] = useState(null); + const toastTimer = useRef | null>(null); + const celebratedRows = useRef>(new Set()); + const notifiedWaitingRows = useRef>(new Set()); + + const dismissToast = useCallback(() => { + setToast(null); + if (toastTimer.current) { + clearTimeout(toastTimer.current); + toastTimer.current = null; + } + }, []); + + const showToast = useCallback( + (msg: string) => { + dismissToast(); + setToast(msg); + toastTimer.current = setTimeout(() => setToast(null), 5000); + }, + [dismissToast] + ); useEffect(() => { const savedGrid = loadGrid(); if (savedGrid) { setGrid(savedGrid); - setCrossed( - loadCrossedState() ?? savedGrid.map((row) => row.map(() => false)) - ); + const savedCrossed = + loadCrossedState() ?? savedGrid.map((row) => row.map(() => false)); + setCrossed(savedCrossed); + // Mark already-completed and already-waiting rows + for (let i = 0; i < savedGrid.length; i++) { + if (isRowComplete(savedGrid, savedCrossed, i)) { + celebratedRows.current.add(i); + } + if (getWaitingNumber(savedGrid, savedCrossed, i) !== null) { + notifiedWaitingRows.current.add(i); + } + } } }, []); @@ -29,6 +89,33 @@ export default function Home() { if (crossed.length > 0) saveCrossedState(crossed); }, [crossed]); + // Detect newly completed rows and waiting rows + useEffect(() => { + if (!grid || crossed.length === 0) return; + + for (let i = 0; i < grid.length; i++) { + // Check for bingo first + if (!celebratedRows.current.has(i) && isRowComplete(grid, crossed, i)) { + celebratedRows.current.add(i); + notifiedWaitingRows.current.add(i); + setCongratsRow(i + 1); + setShowCongrats(true); + return; + } + // Check for 4/5 (waiting) + const waitNum = getWaitingNumber(grid, crossed, i); + if (waitNum !== null && !notifiedWaitingRows.current.has(i)) { + notifiedWaitingRows.current.add(i); + showToast(`Chờ ${waitNum}`); + return; + } + // Reset waiting notification if row no longer has exactly 1 remaining + if (waitNum === null && notifiedWaitingRows.current.has(i) && !celebratedRows.current.has(i)) { + notifiedWaitingRows.current.delete(i); + } + } + }, [grid, crossed, showToast]); + const handleGenerate = useCallback(() => { if (grid && !confirm("Bạn có muốn tạo lại bảng không?")) return; const newGrid = generateGrid(); @@ -37,7 +124,10 @@ export default function Home() { setCrossed(newCrossed); saveGrid(newGrid); saveCrossedState(newCrossed); - }, [grid]); + celebratedRows.current.clear(); + notifiedWaitingRows.current.clear(); + dismissToast(); + }, [grid, dismissToast]); const handleCellClick = useCallback((row: number, col: number) => { setCrossed((prev) => { @@ -103,38 +193,56 @@ export default function Home() { - {/* Grid */} + {/* Grid with toast overlay */} {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]; +
+
+
+ {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); - return ( -
handleCellClick(row, col) : undefined} - 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 - ${hasNumber - ? isCrossed - ? "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" - } - `} - > - {hasNumber ? num : ""} -
- ); - })} + return ( +
handleCellClick(row, col) : undefined} + 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 + ${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" + } + `} + > + {hasNumber ? num : ""} +
+ ); + })} +
+ + {/* "Chờ X" toast overlay */} + {toast && ( +
+
+ {toast} +
+
+ )}
) : (
@@ -155,6 +263,44 @@ export default function Home() {
+ + {/* Congratulations popup */} + {showCongrats && ( +
setShowCongrats(false)} + > +
e.stopPropagation()} + > +
+ 🎉 +
+
✨
+
🎊
+ +

+ Kinh! +

+

+ Hàng {congratsRow} đã đầy đủ! +

+

+ Hãy hô to “Kinh!” 🎶 +

+ +
+
+ )}
); }