feat: add "Kinh!" congrats popup and "Chờ X" waiting toast

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.
This commit is contained in:
tiennm99 committed 2026-04-04 22:44:12 +07:00
1 parent 23713d709b
commit 30554d21a2
2 files changed
+218 -34

No files matched your search

+38
View File
@@ -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: "";
+180 -34
View File
@@ -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<number[][] | null>(null);
const [crossed, setCrossed] = useState<boolean[][]>([]);
const [showInstructions, setShowInstructions] = useState(false);
const [showCongrats, setShowCongrats] = useState(false);
const [congratsRow, setCongratsRow] = useState<number>(-1);
const [toast, setToast] = useState<string | null>(null);
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const celebratedRows = useRef<Set<number>>(new Set());
const notifiedWaitingRows = useRef<Set<number>>(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() {
</button>
</div>
{/* Grid */}
{/* Grid with toast overlay */}
{grid ? (
<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 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];
<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 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);
return (
<div
key={idx}
onClick={hasNumber ? () => 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 : ""}
</div>
);
})}
return (
<div
key={idx}
onClick={hasNumber ? () => 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 : ""}
</div>
);
})}
</div>
</div>
{/* "Chờ X" toast overlay */}
{toast && (
<div
onClick={dismissToast}
className="absolute inset-0 flex items-center justify-center pointer-events-auto cursor-pointer z-10"
>
<div className="px-6 py-3 rounded-2xl bg-amber-500/90 dark:bg-amber-600/90 text-white text-xl sm:text-2xl font-black shadow-xl animate-toast">
{toast}
</div>
</div>
)}
</div>
) : (
<div className="text-center text-slate-400 dark:text-slate-500 py-20 text-sm">
@@ -155,6 +263,44 @@ export default function Home() {
</a>
</footer>
</div>
{/* Congratulations popup */}
{showCongrats && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
onClick={() => setShowCongrats(false)}
>
<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"
onClick={(e) => e.stopPropagation()}
>
<div className="absolute -top-6 left-1/2 -translate-x-1/2 text-6xl animate-bounce-slow">
🎉
</div>
<div className="absolute top-2 left-4 text-2xl animate-spin-slow">✨</div>
<div className="absolute top-2 right-4 text-2xl animate-spin-slow-reverse">🎊</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">
Kinh!
</h2>
<p className="mt-3 text-lg text-slate-600 dark:text-slate-300">
Hàng <span className="font-bold text-pink-500">{congratsRow}</span> đã đầy đủ!
</p>
<p className="mt-1 text-sm text-slate-400 dark:text-slate-500">
Hãy hô to &ldquo;Kinh!&rdquo; 🎶
</p>
<button
onClick={() => setShowCongrats(false)}
className="mt-6 px-8 py-2.5 rounded-full font-semibold text-white
bg-gradient-to-r from-pink-500 to-purple-500
hover:from-pink-600 hover:to-purple-600
active:scale-95 transition-all shadow-lg"
>
Tuyệt vời! 🥳
</button>
</div>
</div>
)}
</div>
);
}