mirror of
https://github.com/tiennm99/loto.git
synced 2026-10-13 16:15:17 +00:00
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:
1 parent
23713d709b
commit
30554d21a2
2 files changed
+218
-34
No files matched your search
@@ -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
@@ -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 “Kinh!” 🎶
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user