Refactor Clash component to use GameModal for game interactions, remove Hello World page, and implement toast notifications for error handling

This commit is contained in:
averel10
2025-11-22 09:44:36 +01:00
parent 3bd61490f1
commit 197d9e6e22
16 changed files with 976 additions and 1088 deletions

View File

@@ -2,6 +2,8 @@ import { useState, useEffect } from "react";
import Web3 from "web3";
import { Button } from "./Button";
import { Input } from "./Input";
import { GameDetails } from "./GameModal";
import { showToast } from "@/app/lib/toast";
interface CommitProps {
account: string;
@@ -12,11 +14,12 @@ interface CommitProps {
selectedMove: string | null;
setSelectedMove: (move: string | null) => void;
secret: string;
whoAmI: "player1" | "player2" | "";
gameDetails: GameDetails | null;
setSecret: (secret: string) => void;
onBothPlayersCommitted?: () => void;
savePlayMove: (playMove: string) => void;
}
type Move = "1" | "2" | "3" | null;
type MoveName = "Rock" | "Paper" | "Scissors";
const MOVES: Record<string, { name: MoveName; icon: string }> = {
@@ -35,22 +38,17 @@ export default function Commit({
setSelectedMove,
secret,
setSecret,
onBothPlayersCommitted,
savePlayMove,
whoAmI,
gameDetails
}: Readonly<CommitProps>) {
const [loading, setLoading] = useState(false);
const [playMove, setPlayMove] = useState<string>("");
const [selfPlayed, setSelfPlayed] = useState<string>("");
const [bothPlayed, setBothPlayed] = useState<string>("");
const [autoCheckInterval, setAutoCheckInterval] = useState<NodeJS.Timeout | null>(null);
const [moveSubmitted, setMoveSubmitted] = useState(false);
// Generate random secret on mount if not already set
useEffect(() => {
if (!secret) {
const randomHex = Math.random().toString(16).slice(2, 18);
setSecret(randomHex);
}
}, []);
// Update encrypted move when move or secret changes
useEffect(() => {
if (selectedMove && secret) {
@@ -58,27 +56,42 @@ export default function Commit({
// Use keccak256 (Ethereum's standard hash function)
const hash = Web3.utils.keccak256(clearMove);
setPlayMove(hash);
// Persist to sessionStorage through parent
savePlayMove(hash);
}
}, [selectedMove, secret]);
}, [selectedMove, secret, savePlayMove]);
// Auto-check if both players have committed and trigger callback
useEffect(() => {
if (!contract || !account || !playMove || bothPlayed === "true") {
if (!contract || !account || !whoAmI || !gameDetails) {
// Clear interval if conditions not met or already both played
if (autoCheckInterval) clearInterval(autoCheckInterval);
setAutoCheckInterval(null);
return;
}
const checkSelfPlayed = async () => {
try {
const encrMove = gameDetails[whoAmI === "player1" ? "playerA" : "playerB"].encrMove;
setSelfPlayed(Number(encrMove) !== 0 ? "true" : "false");
} catch (err: any) {
console.error("Auto-check self played failed:", err.message);
}
};
checkSelfPlayed();
// Check immediately on mount or when dependencies change
const checkBothPlayed = async () => {
try {
const res = await contract.methods.bothPlayed().call({ from: account });
const playerAEncrMove = gameDetails.playerA.encrMove;
const playerBEncrMove = gameDetails.playerB.encrMove;
const res = Number(playerAEncrMove) !== 0 && Number(playerBEncrMove) !== 0;
console.log("Both played check:", res);
if (res) {
setBothPlayed("true");
if (onBothPlayersCommitted) {
onBothPlayersCommitted();
}
}
} catch (err: any) {
console.error("Auto-check failed:", err.message);
@@ -94,16 +107,15 @@ export default function Commit({
return () => {
if (interval) clearInterval(interval);
};
}, [contract, account, playMove, bothPlayed, onBothPlayersCommitted]);
}, [contract, account, playMove, bothPlayed, gameDetails]);
// Commit phase read-only handlers
const handlePlay = async () => {
if (!contract || !web3 || !account || !playMove) return;
setLoading(true);
setStatus("");
try {
// playMove should be a hex string (bytes32)
const tx = contract.methods.play(playMove);
const tx = contract.methods.play(gameDetails?.returnGameId, playMove);
const gas = await tx.estimateGas({ from: account });
const result = await (globalThis as any).ethereum.request({
method: "eth_sendTransaction",
@@ -117,10 +129,10 @@ export default function Commit({
},
],
});
setStatus("Play tx sent: " + result);
showToast("Play tx sent: " + result, "success");
setMoveSubmitted(true);
} catch (err: any) {
setStatus("Play failed: " + err.message);
showToast("Play failed: " + err.message, "error");
} finally {
setLoading(false);
}
@@ -131,13 +143,21 @@ export default function Commit({
setSecret(randomHex);
};
const handleSecretChange = (value: string) => {
setSecret(value);
};
const handleMoveSelect = (move: string) => {
setSelectedMove(move);
};
return (
<div className="border p-6 rounded-lg bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-700 dark:to-slate-800">
<h2 className="font-semibold text-lg mb-6 text-slate-900 dark:text-white">
Select Your Move
</h2>
{moveSubmitted ? (
{moveSubmitted || selfPlayed === "true" ? (
// Waiting animation after move is submitted
<div className="flex flex-col items-center justify-center py-16">
<div className="mb-6">
@@ -161,7 +181,7 @@ export default function Commit({
{(["1", "2", "3"] as const).map((move) => (
<button
key={move}
onClick={() => setSelectedMove(move)}
onClick={() => handleMoveSelect(move)}
className={`flex flex-col items-center justify-center p-6 rounded-lg transition-all transform ${
selectedMove === move
? "bg-blue-500 text-white shadow-lg scale-110"
@@ -184,7 +204,7 @@ export default function Commit({
<Input
type="text"
value={secret}
onChange={(e) => setSecret(e.target.value)}
onChange={(e) => handleSecretChange(e.target.value)}
placeholder="Your secret passphrase"
className="flex-1"
/>

View File

@@ -2,6 +2,8 @@ import { useState, useEffect } from "react";
import Web3 from "web3";
import { Button } from "./Button";
import { Input } from "./Input";
import { GameDetails } from "./GameModal";
import { showSuccessToast, showErrorToast } from "@/app/lib/toast";
interface GameListProps {
account: string;
@@ -12,15 +14,6 @@ interface GameListProps {
onPlayClick?: (gameId: number) => void;
}
interface GameInfo {
gameId: number;
playerA: string;
playerB: string;
initialBet: string;
isActive: boolean;
outcome: number;
}
export default function GameList({
account,
contract,
@@ -29,7 +22,7 @@ export default function GameList({
setStatus,
onPlayClick,
}: Readonly<GameListProps>) {
const [games, setGames] = useState<GameInfo[]>([]);
const [games, setGames] = useState<GameDetails[]>([]);
const [loading, setLoading] = useState(false);
const [newGameBet, setNewGameBet] = useState<string>("0.01");
const [refreshInterval, setRefreshInterval] = useState<NodeJS.Timeout | null>(null);
@@ -40,18 +33,11 @@ export default function GameList({
if (!contract || !web3) return;
try {
const activeGameIds = await contract.methods.getActiveGameIds().call();
const gameDetails: GameInfo[] = [];
const gameDetails: GameDetails[] = [];
for (const gameId of activeGameIds) {
const details = await contract.methods.getGameDetails(gameId).call();
gameDetails.push({
gameId: Number(gameId),
playerA: details.playerAAddr,
playerB: details.playerBAddr,
initialBet: web3.utils.fromWei(details.initialBet, "ether"),
isActive: details.isActive,
outcome: Number(details.outcome),
});
gameDetails.push(details);
}
setGames(gameDetails);
@@ -60,10 +46,10 @@ export default function GameList({
const userGames = new Set<number>();
for (const game of gameDetails) {
if (
game.playerA.toLowerCase() === account.toLowerCase() ||
game.playerB.toLowerCase() === account.toLowerCase()
game.playerA.addr.toLowerCase() === account.toLowerCase() ||
game.playerB.addr.toLowerCase() === account.toLowerCase()
) {
userGames.add(game.gameId);
userGames.add(game.returnGameId);
}
}
setUserGameIds(userGames);
@@ -90,9 +76,8 @@ export default function GameList({
const handleJoinGame = async (gameId: number, bet: string) => {
if (!contract || !web3 || !account) return;
setLoading(true);
setStatus("");
try {
const betWei = web3.utils.toWei(bet || "0.01", "ether");
const betWei = bet;
const tx = contract.methods.register(gameId);
const gas = await tx.estimateGas({ from: account, value: betWei });
const result = await (globalThis as any).ethereum.request({
@@ -108,11 +93,11 @@ export default function GameList({
},
],
});
setStatus("Joined game! Transaction: " + result);
showSuccessToast("Joined game! Transaction: " + result);
await new Promise((resolve) => setTimeout(resolve, 2000));
fetchActiveGames();
} catch (err: any) {
setStatus("Failed to join game: " + err.message);
showErrorToast("Failed to join game: " + err.message);
console.error(err);
} finally {
setLoading(false);
@@ -123,7 +108,6 @@ export default function GameList({
const handleCreateGame = async () => {
if (!contract || !web3 || !account) return;
setLoading(true);
setStatus("");
try {
const betWei = web3.utils.toWei(newGameBet || "0.01", "ether");
const tx = contract.methods.register(0); // 0 means create new game
@@ -141,12 +125,12 @@ export default function GameList({
},
],
});
setStatus("Created new game! Transaction: " + result);
showSuccessToast("Created new game! Transaction: " + result);
setNewGameBet("0.01");
await new Promise((resolve) => setTimeout(resolve, 2000));
fetchActiveGames();
} catch (err: any) {
setStatus("Failed to create game: " + err.message);
showErrorToast("Failed to create game: " + err.message);
console.error(err);
} finally {
setLoading(false);
@@ -158,6 +142,21 @@ export default function GameList({
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
};
const getGamePhase = (game: GameDetails) => {
const playerARevealed = Number(game.playerA.move) !== 0;
const playerBRevealed = Number(game.playerB.move) !== 0;
const playerACommitted = Number(game.playerA.encrMove) !== 0;
const playerBCommitted = Number(game.playerB.encrMove) !== 0;
if (playerARevealed && playerBRevealed) {
return { phase: "Outcome", color: "bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200" };
} else if (playerACommitted && playerBCommitted) {
return { phase: "Reveal", color: "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200" };
} else {
return { phase: "Commit", color: "bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200" };
}
};
return (
<div className="space-y-6">
{/* Create New Game Section */}
@@ -185,8 +184,7 @@ export default function GameList({
</Button>
</div>
<p className="text-xs text-slate-600 dark:text-slate-400 mt-2">
Enter the bet amount in ETH (e.g., 0.01 for 0.01 ETH). The first
player to join with the same or higher bet will play against you.
Enter the bet amount in ETH (e.g., 0.01 for 0.01 ETH).
</p>
</div>
@@ -203,90 +201,102 @@ export default function GameList({
</div>
) : (
<div className="space-y-2">
{games.map((game) => (
{games.map((game) => {
const isUserInGame = userGameIds.has(game.returnGameId);
return (
<div
key={game.gameId}
className="flex items-center gap-4 bg-white dark:bg-slate-700 p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow border border-gray-200 dark:border-slate-600"
key={game.returnGameId}
className={`flex flex-col p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow border ${
isUserInGame
? "bg-green-50 dark:bg-green-900/30 border-green-300 dark:border-green-600 ring-2 ring-green-400 dark:ring-green-500"
: "bg-white dark:bg-slate-700 border-gray-200 dark:border-slate-600"
}`}
>
{/* Game ID */}
<div className="min-w-[80px]">
<p className="text-xs text-slate-500 dark:text-slate-400">
Game ID
</p>
{/* Game ID Header */}
<div className="mb-4 flex items-center justify-between">
<p className="font-semibold text-lg text-indigo-600 dark:text-indigo-400">
#{game.gameId}
Game #{game.returnGameId}
</p>
</div>
{/* Players Info */}
<div className="flex-1 min-w-[200px]">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">
Players
</p>
<div className="space-y-1">
<p className="font-mono text-sm text-slate-700 dark:text-slate-300">
<span className="text-xs text-slate-500">A:</span> {formatAddress(game.playerA)}
</p>
<p className="font-mono text-sm text-slate-700 dark:text-slate-300">
<span className="text-xs text-slate-500">B:</span> {game.playerB === "0x0000000000000000000000000000000000000000"
? "Waiting..."
: formatAddress(game.playerB)}
<div className="flex gap-3 items-center">
<span className={`text-xs font-semibold px-2 py-1 rounded ${getGamePhase(game).color}`}>
{getGamePhase(game).phase}
</span>
<p className="text-sm text-slate-600 dark:text-slate-400">
{web3 ? web3.utils.fromWei(game.initialBet, "ether") : "-"} ETH
</p>
</div>
</div>
{/* Bet Amount */}
<div className="min-w-[100px]">
<p className="text-xs text-slate-500 dark:text-slate-400">
Bet
</p>
<p className="font-semibold text-slate-900 dark:text-white">
{game.initialBet} ETH
</p>
{/* Players VS Layout */}
<div className="flex items-center justify-between gap-4">
{/* Player A */}
<div className="flex-1 text-center">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1 font-semibold">
Player A
</p>
<p className="font-mono text-sm text-slate-700 dark:text-slate-300 break-all">
{formatAddress(game.playerA.addr)}
</p>
</div>
{/* VS */}
<div className="flex flex-col items-center">
<p className="text-xl font-bold text-slate-400 dark:text-slate-500">
VS
</p>
</div>
{/* Player B */}
<div className="flex-1 text-center">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1 font-semibold">
Player B
</p>
<p className="font-mono text-sm text-slate-700 dark:text-slate-300 break-all">
{game.playerB.addr === "0x0000000000000000000000000000000000000000"
? "⏳ Waiting..."
: formatAddress(game.playerB.addr)}
</p>
</div>
</div>
{/* Join/Play Button */}
<div className="flex gap-2">
{userGameIds.has(game.gameId) ? (
<div className="mt-4 flex justify-center">
{userGameIds.has(game.returnGameId) ? (
<Button
onClick={() => onPlayClick?.(game.gameId)}
onClick={() => onPlayClick?.(game.returnGameId)}
variant="primary"
className="whitespace-nowrap bg-emerald-600 hover:bg-emerald-500 focus-visible:outline-emerald-600"
className="bg-emerald-600 hover:bg-emerald-500 focus-visible:outline-emerald-600"
>
Play
</Button>
) : (
<Button
onClick={() =>
handleJoinGame(game.gameId, game.initialBet)
handleJoinGame(game.returnGameId, game.initialBet)
}
disabled={
loading ||
!account ||
!contract ||
game.playerB !==
game.playerB.addr !==
"0x0000000000000000000000000000000000000000"
}
variant="primary"
className="whitespace-nowrap"
>
{game.playerB ===
{game.playerB.addr ===
"0x0000000000000000000000000000000000000000"
? "Join"
? "Join Game"
: "Full"}
</Button>
)}
</div>
</div>
))}
);
})}
</div>
)}
</div>
{/* Refresh Info */}
<div className="text-center text-xs text-slate-500 dark:text-slate-400">
<p>🔄 Games refresh automatically every 2 seconds</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useEffect, useState } from "react";
import Web3 from "web3";
import Commit from "./Commit";
import Reveal from "./Reveal";
import { showErrorToast } from "@/app/lib/toast";
export type Player = {
addr: string;
bet: string;
encrMove: string;
move: number;
nickname: string;
};
export type GameDetails = {
playerA: Player;
playerB: Player;
initialBet: string;
outcome: number;
isActive: boolean;
returnGameId: number;
};
interface GameModalProps {
gameId?: number;
isOpen: boolean;
onClose: () => void;
account: string;
contract: any;
config: Config | null;
web3: Web3 | null;
setStatus: (status: string) => void;
}
export default function GameModal({
gameId,
isOpen,
onClose,
account,
contract,
config,
web3,
setStatus,
}: Readonly<GameModalProps>) {
const [phase, setPhase] = useState<"commit" | "reveal">("commit");
const [whoAmI, setWhoAmI] = useState<"player1" | "player2" | "">("");
const [gameDetails, setGameDetails] = useState<GameDetails | null>(null);
const [selectedMove, setSelectedMove] = useState<string | null>(null);
const [secret, setSecret] = useState<string>("");
// Helper function to generate game-specific storage key
const getGameStorageKey = () => `game_${gameDetails?.returnGameId}`;
// Game storage object structure
type GameStorage = {
secret: string;
selectedMove: string | null;
playMove: string;
};
// Storage helper functions
const loadFromStorage = () => {
if (!gameDetails) return;
const storedData = sessionStorage.getItem(getGameStorageKey());
if (storedData) {
try {
const parsed: GameStorage = JSON.parse(storedData);
if (parsed.secret) setSecret(parsed.secret);
if (parsed.selectedMove) setSelectedMove(parsed.selectedMove);
} catch (err) {
console.error("Failed to parse stored game data:", err);
}
}
};
const saveGameData = (updates: Partial<GameStorage>) => {
const storedData = sessionStorage.getItem(getGameStorageKey());
let currentData: GameStorage = { secret: "", selectedMove: null, playMove: "" };
if (storedData) {
try {
currentData = JSON.parse(storedData);
} catch (err) {
console.error("Failed to parse stored game data:", err);
}
}
const updatedData = { ...currentData, ...updates };
sessionStorage.setItem(getGameStorageKey(), JSON.stringify(updatedData));
};
const saveSecret = (value: string) => {
setSecret(value);
saveGameData({ secret: value });
};
const saveMoveSelection = (move: string | null) => {
setSelectedMove(move);
if (move !== null) {
saveGameData({ selectedMove: move });
}
};
const savePlayMove = (playMove: string) => {
saveGameData({ playMove });
};
useEffect(() => {
const fetchPlayerInfo = async () => {
if (contract && account && gameId !== undefined) {
try {
let player = await contract.methods.whoAmI(gameId).call({ from: account });
if(player == 1) player = "player1";
else if(player == 2) player = "player2";
else player = "";
setWhoAmI(player);
} catch (err: any) {
showErrorToast("Error fetching player info: " + err.message);
}
}
}
const fetchGameDetails = async () => {
if (contract && gameId !== undefined) {
try {
const details = await contract.methods.getGameDetails(gameId).call();
console.log("Game details:", details);
setGameDetails(details);
// Determine the correct phase based on game state
const playerAHasMove = Number(details.playerA.encrMove) !== 0;
const playerBHasMove = Number(details.playerB.encrMove) !== 0;
const playerARevealed = Number(details.playerA.move) !== 0;
const playerBRevealed = Number(details.playerB.move) !== 0;
// If both players have revealed their moves, show reveal phase (with results)
if (playerARevealed && playerBRevealed) {
setPhase("reveal");
}
// If both players have committed but not revealed, show reveal phase
else if (playerAHasMove && playerBHasMove) {
setPhase("reveal");
}
// Otherwise, show commit phase
else {
setPhase("commit");
}
} catch (err: any) {
showErrorToast("Error fetching game details: " + err.message);
}
}
};
// Only reset state when game ID actually changes (not on first render)
if (gameDetails) {
setSelectedMove(null);
setSecret("");
}
fetchGameDetails();
fetchPlayerInfo();
// Refetch game details periodically every 2 seconds
const intervalId = setInterval(fetchGameDetails, 2000);
return () => clearInterval(intervalId);
}, [contract, account, gameId]);
// Load from storage after game details are fetched
useEffect(() => {
loadFromStorage();
}, [gameDetails]);
const handleClose = () => {
// Reset state when closing
setPhase("commit");
setSelectedMove(null);
setSecret("");
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 p-6 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">
{gameId ? `Game #${gameId}` : "Game"}
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 mt-1">
{phase === "commit"
? "Commit your move"
: "Reveal your move"}
</p>
</div>
<button
onClick={handleClose}
className="text-2xl text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
>
</button>
</div>
{/* Content */}
<div className="p-6">
{/* Phase Content */}
<div>
{phase === "commit" && (
<Commit
account={account}
contract={contract}
config={config}
web3={web3}
whoAmI={whoAmI}
gameDetails={gameDetails}
setStatus={setStatus}
selectedMove={selectedMove}
setSelectedMove={saveMoveSelection}
secret={secret}
setSecret={saveSecret}
savePlayMove={savePlayMove}
/>
)}
{phase === "reveal" && (
<Reveal
account={account}
contract={contract}
config={config}
web3={web3}
setStatus={setStatus}
selectedMove={selectedMove}
secret={secret}
gameDetails={gameDetails}
whoAmI={whoAmI}
/>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from "react";
import Web3 from "web3";
import { Button } from "./Button";
import { GameDetails } from "./GameModal";
import { showSuccessToast, showErrorToast } from "@/app/lib/toast";
interface RevealProps {
account: string;
@@ -10,6 +12,8 @@ interface RevealProps {
setStatus: (status: string) => void;
selectedMove: string | null;
secret: string;
gameDetails: GameDetails | null;
whoAmI: "player1" | "player2" | "";
}
type MoveName = "Rock" | "Paper" | "Scissors";
@@ -36,54 +40,50 @@ export default function Reveal({
setStatus,
selectedMove,
secret,
gameDetails,
whoAmI,
}: Readonly<RevealProps>) {
const [loading, setLoading] = useState(false);
const [selfRevealed, setSelfRevealed] = useState(false);
const [opponentRevealed, setOpponentRevealed] = useState(false);
const [bothRevealed, setBothRevealed] = useState(false);
const [playerARevealed, setPlayerARevealed] = useState(false);
const [playerBRevealed, setPlayerBRevealed] = useState(false);
const [revealTimeLeft, setRevealTimeLeft] = useState<number>(0);
const [outcome, setOutcome] = useState<number>(0);
const [revealed, setRevealed] = useState(false);
const clearMove = selectedMove && secret ? `${selectedMove}-${secret}` : "";
// Check game status on mount
useEffect(() => {
const checkStatus = async () => {
if (!contract) return;
try {
const [br, par, pbr, rtl, out] = await Promise.all([
await contract.methods.bothRevealed().call({ from : account}),
await contract.methods.playerARevealed().call({ from : account}),
await contract.methods.playerBRevealed().call({ from : account}),
await contract.methods.revealTimeLeft().call({ from : account}),
await contract.methods.getLastWinner().call({ from : account}),
]);
const setStateFromGameDetails = () => {
if (!gameDetails) return;
const playerARevealed = Number(gameDetails.playerA.move) !== 0;
const playerBRevealed = Number(gameDetails.playerB.move) !== 0;
console.log("Status:", {
br, par, pbr, rtl, out
});
setBothRevealed(br);
setPlayerARevealed(par);
setPlayerBRevealed(pbr);
setRevealTimeLeft(Number(rtl));
setOutcome(Number(out));
} catch (err: any) {
console.error("Failed to check status:", err);
setSelfRevealed(
(whoAmI === "player1" && playerARevealed) ||
(whoAmI === "player2" && playerBRevealed)
);
setOpponentRevealed(
(whoAmI === "player1" && playerBRevealed) ||
(whoAmI === "player2" && playerARevealed)
);
setBothRevealed(playerARevealed && playerBRevealed);
if(bothRevealed){
if(Number(gameDetails.outcome) === 1 && whoAmI === "player1") setOutcome(1);
else if(Number(gameDetails.outcome) === 2 && whoAmI === "player2") setOutcome(1);
else if(Number(gameDetails.outcome) === 1 && whoAmI === "player2") setOutcome(2);
else if(Number(gameDetails.outcome) === 2 && whoAmI === "player1") setOutcome(2);
else setOutcome(3);
}
};
const interval = setInterval(checkStatus, 3000);
checkStatus();
return () => clearInterval(interval);
}, [contract, account]);
setStateFromGameDetails();
}, [gameDetails, contract, account, whoAmI]);
const handleReveal = async () => {
if (!contract || !web3 || !account || !clearMove) return;
setLoading(true);
setStatus("");
try {
const tx = contract.methods.reveal(clearMove);
const tx = contract.methods.reveal(gameDetails?.returnGameId, clearMove);
const gas = await tx.estimateGas({ from: account });
const result = await (globalThis as any).ethereum.request({
method: "eth_sendTransaction",
@@ -97,10 +97,9 @@ export default function Reveal({
},
],
});
setStatus("Reveal tx sent: " + result);
setRevealed(true);
showSuccessToast("Reveal tx sent: " + result);
} catch (err: any) {
setStatus("Reveal failed: " + err.message);
showErrorToast("Reveal failed: " + err.message);
} finally {
setLoading(false);
}
@@ -109,9 +108,8 @@ export default function Reveal({
const handleGetOutcome = async () => {
if (!contract || !web3 || !account) return;
setLoading(true);
setStatus("");
try {
const tx = contract.methods.getOutcome();
const tx = contract.methods.getOutcome(gameDetails?.returnGameId);
const gas = await tx.estimateGas({ from: account });
const result = await (globalThis as any).ethereum.request({
method: "eth_sendTransaction",
@@ -125,9 +123,10 @@ export default function Reveal({
},
],
});
setStatus("Claim tx sent: " + result);
showSuccessToast("Claim tx sent: " + result);
} catch (err: any) {
setStatus("❌ Claim failed: " + err.message);
console.error(err);
showErrorToast("Claim failed: " + err.message);
} finally {
setLoading(false);
}
@@ -137,156 +136,201 @@ export default function Reveal({
return (
<div className="space-y-6">
{/* Your Move Section */}
<div className="border p-6 rounded-lg bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-700 dark:to-slate-800">
<h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white">
Your Move
</h2>
{selectedMove ? (
<div className="flex items-center justify-center gap-4">
<div className="flex flex-col items-center">
<span className="text-6xl mb-2">{MOVES[selectedMove].icon}</span>
<span className="font-semibold text-lg">
{MOVES[selectedMove].name}
</span>
</div>
<div className="text-3xl text-slate-400"></div>
<div className="bg-white dark:bg-slate-600 p-4 rounded-lg">
<p className="text-xs text-slate-600 dark:text-slate-300 mb-1">
Clear Move:
</p>
<code className="text-sm font-mono text-slate-700 dark:text-slate-200">
{clearMove}
</code>
{/* Your Move Section - Hidden when both revealed */}
{!bothRevealed && (
<div className="border p-6 rounded-lg bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-700 dark:to-slate-800">
<h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white">
Your Move
</h2>
{selectedMove ? (
<div className="flex items-center justify-center gap-4">
<div className="flex flex-col items-center">
<span className="text-6xl mb-2">{MOVES[selectedMove].icon}</span>
<span className="font-semibold text-lg">
{MOVES[selectedMove].name}
</span>
</div>
<div className="text-3xl text-slate-400"></div>
<div className="bg-white dark:bg-slate-600 p-4 rounded-lg">
<p className="text-xs text-slate-600 dark:text-slate-300 mb-1">
Clear Move:
</p>
<code className="text-sm font-mono text-slate-700 dark:text-slate-200">
{clearMove}
</code>
</div>
</div>
) : (
<p className="text-center text-slate-600 dark:text-slate-400">
No move selected yet
</p>
)}
</div>
)}
{/* Game Status Section - Hidden when both revealed */}
{!bothRevealed && (
<div className="grid grid-cols-3 gap-4">
<div
className={`p-4 rounded-lg text-center ${
selfRevealed
? "bg-green-50 dark:bg-green-900"
: "bg-slate-100 dark:bg-slate-700"
}`}
>
<p className="text-2xl mb-1">{selfRevealed ? "✅" : "⏳"}</p>
<p className="text-xs font-semibold text-slate-600 dark:text-slate-300">
Me
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{selfRevealed ? "Revealed" : "Waiting"}
</p>
</div>
) : (
<p className="text-center text-slate-600 dark:text-slate-400">
No move selected yet
</p>
)}
</div>
{/* Game Status Section */}
<div className="grid grid-cols-3 gap-4">
<div
className={`p-4 rounded-lg text-center ${
playerARevealed
? "bg-green-50 dark:bg-green-900"
: "bg-slate-100 dark:bg-slate-700"
}`}
>
<p className="text-2xl mb-1">{playerARevealed ? "✅" : "⏳"}</p>
<p className="text-xs font-semibold text-slate-600 dark:text-slate-300">
Player A
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{playerARevealed ? "Revealed" : "Waiting"}
</p>
</div>
<div
className={`p-4 rounded-lg text-center ${
playerBRevealed
? "bg-green-50 dark:bg-green-900"
: "bg-slate-100 dark:bg-slate-700"
}`}
>
<p className="text-2xl mb-1">{playerBRevealed ? "✅" : "⏳"}</p>
<p className="text-xs font-semibold text-slate-600 dark:text-slate-300">
Player B
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{playerBRevealed ? "Revealed" : "Waiting"}
</p>
</div>
<div className="p-4 rounded-lg text-center bg-blue-50 dark:bg-blue-900">
<p className="text-sm font-mono text-slate-600 dark:text-slate-300">
</p>
<p className="text-xs font-semibold text-slate-600 dark:text-slate-300">
Time Left
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{revealTimeLeft > 0 ? `${revealTimeLeft}s` : "Expired"}
</p>
</div>
</div>
{/* Reveal Section */}
<div className="border-2 border-blue-300 dark:border-blue-600 p-6 rounded-lg bg-blue-50 dark:bg-slate-700">
<h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white">
Reveal Your Move
</h2>
<p className="text-sm text-slate-600 dark:text-slate-300 mb-4">
Submit your clear move and secret to the blockchain. This proves you
didn't cheat!
</p>
<Button
onClick={handleReveal}
disabled={loading || !account || !contract || !clearMove || revealed}
variant="primary"
className="w-full py-3 text-lg"
>
{loading ? "Submitting..." : revealed ? "✅ Revealed" : "Reveal Move"}
</Button>
</div>
{/* Winner Section - Only show if both revealed */}
{bothRevealed && (
<div
className={`border-2 p-6 rounded-lg text-center ${
outcomeData.color === "green"
? "border-green-400 bg-green-50 dark:bg-green-900 dark:border-green-600"
: outcomeData.color === "red"
? "border-red-400 bg-red-50 dark:bg-red-900 dark:border-red-600"
: outcomeData.color === "yellow"
? "border-yellow-400 bg-yellow-50 dark:bg-yellow-900 dark:border-yellow-600"
: "border-slate-400 bg-slate-100 dark:bg-slate-700 dark:border-slate-600"
}`}
>
<p
className={`text-6xl mb-3 ${
outcomeData.color === "green"
? "text-green-600 dark:text-green-400"
: outcomeData.color === "red"
? "text-red-600 dark:text-red-400"
: outcomeData.color === "yellow"
? "text-yellow-600 dark:text-yellow-400"
: "text-slate-600 dark:text-slate-400"
<div
className={`p-4 rounded-lg text-center ${
opponentRevealed
? "bg-green-50 dark:bg-green-900"
: "bg-slate-100 dark:bg-slate-700"
}`}
>
{outcomeData.emoji}
<p className="text-2xl mb-1">{opponentRevealed ? "✅" : "⏳"}</p>
<p className="text-xs font-semibold text-slate-600 dark:text-slate-300">
Opponent
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{opponentRevealed ? "Revealed" : "Waiting"}
</p>
</div>
<div className="p-4 rounded-lg text-center bg-blue-50 dark:bg-blue-900">
<p className="text-sm font-mono text-slate-600 dark:text-slate-300">
</p>
<p className="text-xs font-semibold text-slate-600 dark:text-slate-300">
Time Left
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{0}
</p>
</div>
</div>
)}
{/* Reveal Section - Hidden when both revealed */}
{!bothRevealed && (
<div className="border-2 border-blue-300 dark:border-blue-600 p-6 rounded-lg bg-blue-50 dark:bg-slate-700">
<h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white">
Reveal Your Move
</h2>
<p className="text-sm text-slate-600 dark:text-slate-300 mb-4">
Submit your clear move and secret to the blockchain. This proves you
didn't cheat!
</p>
<h3
className={`text-2xl font-bold mb-2 ${
outcomeData.color === "green"
? "text-green-700 dark:text-green-300"
: outcomeData.color === "red"
? "text-red-700 dark:text-red-300"
: outcomeData.color === "yellow"
? "text-yellow-700 dark:text-yellow-300"
: "text-slate-700 dark:text-slate-300"
}`}
>
{outcomeData.name}
</h3>
<Button
onClick={handleGetOutcome}
disabled={loading || !account || !contract}
onClick={handleReveal}
disabled={loading || !account || !contract || !clearMove || selfRevealed}
variant="primary"
className="mt-4 w-full py-3 text-lg"
className="w-full py-3 text-lg"
>
{loading ? "Processing..." : "💰 Claim Coins"}
{loading ? "Submitting..." : selfRevealed ? "✅ Revealed" : "Reveal Move"}
</Button>
</div>
)}
{/* Status Messages */}
{!bothRevealed && !revealed && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900 rounded-lg">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Waiting for both players to reveal...
</p>
{/* Winner Section - Only show if both revealed */}
{bothRevealed && (
<div className="space-y-4">
{/* Moves Comparison */}
<div className="border-2 border-slate-300 dark:border-slate-600 p-6 rounded-lg bg-slate-50 dark:bg-slate-800">
<h2 className="font-semibold text-lg mb-4 text-slate-900 dark:text-white text-center">
Final Moves
</h2>
<div className="flex items-center justify-center gap-8">
{/* Your Move (always on left) */}
<div className="flex flex-col items-center">
<span className="text-6xl mb-2">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move : gameDetails.playerB.move)]?.icon}
</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">
You
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerA.move : gameDetails.playerB.move)]?.name}
</span>
</div>
{/* VS */}
<div className="flex flex-col items-center">
<span className="text-4xl text-slate-400 dark:text-slate-500">
VS
</span>
</div>
{/* Opponent Move (always on right) */}
<div className="flex flex-col items-center">
<span className="text-6xl mb-2">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move : gameDetails.playerA.move)]?.icon}
</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">
Opponent
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{gameDetails && MOVES[String(whoAmI === "player1" ? gameDetails.playerB.move : gameDetails.playerA.move)]?.name}
</span>
</div>
</div>
</div>
{/* Outcome Section */}
<div
className={`border-2 p-6 rounded-lg text-center ${
outcomeData.color === "green"
? "border-green-400 bg-green-50 dark:bg-green-900 dark:border-green-600"
: outcomeData.color === "red"
? "border-red-400 bg-red-50 dark:bg-red-900 dark:border-red-600"
: outcomeData.color === "yellow"
? "border-yellow-400 bg-yellow-50 dark:bg-yellow-900 dark:border-yellow-600"
: "border-slate-400 bg-slate-100 dark:bg-slate-700 dark:border-slate-600"
}`}
>
<p
className={`text-6xl mb-3 ${
outcomeData.color === "green"
? "text-green-600 dark:text-green-400"
: outcomeData.color === "red"
? "text-red-600 dark:text-red-400"
: outcomeData.color === "yellow"
? "text-yellow-600 dark:text-yellow-400"
: "text-slate-600 dark:text-slate-400"
}`}
>
{outcomeData.emoji}
</p>
<h3
className={`text-2xl font-bold ${
outcomeData.color === "green"
? "text-green-700 dark:text-green-300"
: outcomeData.color === "red"
? "text-red-700 dark:text-red-300"
: outcomeData.color === "yellow"
? "text-yellow-700 dark:text-yellow-300"
: "text-slate-700 dark:text-slate-300"
}`}
>
{outcomeData.name}
</h3>
{/* Show Claim Coins button only on win or draw and if game is active */}
{(outcome === 1 || outcome === 3) && gameDetails?.isActive && (
<Button
onClick={handleGetOutcome}
disabled={loading || !account || !contract}
variant="primary"
className="mt-4 w-full py-3 text-lg"
>
{loading ? "Processing..." : "💰 Claim Coins"}
</Button>
)}
</div>
</div>
)}
</div>

View File

@@ -3,32 +3,26 @@
import { useEffect, useState } from "react";
import Web3 from "web3";
import GameList from "./GameList";
import Commit from "./Commit";
import Reveal from "./Reveal";
import GameModal from "./GameModal";
import { showErrorToast } from "@/app/lib/toast";
export default function Clash() {
const [config, setConfig] = useState<Config | null>(null);
const [web3, setWeb3] = useState<Web3 | null>(null);
const [contract, setContract] = useState<any>(null);
const [account, setAccount] = useState<string>("");
const [status, setStatus] = useState<string>("");
// Inputs for contract functions
const [phase, setPhase] = useState<"games" | "commit" | "reveal">("games");
const [selectedMove, setSelectedMove] = useState<string | null>(null);
const [secret, setSecret] = useState<string>("");
// Modal state
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedGameId, setSelectedGameId] = useState<number | undefined>();
const [availableAccounts, setAvailableAccounts] = useState<string[]>([]);
const [selectedAccount, setSelectedAccount] = useState<string>("");
const handlePlayClick = (gameId: number) => {
setPhase("commit");
setSelectedGameId(gameId);
setIsModalOpen(true);
};
// Clear status when phase changes
useEffect(() => {
setStatus("");
}, [phase]);
// Load config and contract
useEffect(() => {
const loadConfig = async () => {
@@ -50,16 +44,13 @@ export default function Clash() {
method: "eth_requestAccounts",
});
setAvailableAccounts(accounts);
setAccount(accounts[0]);
setSelectedAccount(accounts[0]);
} catch (err: any) {
setStatus(
"MetaMask not available or user denied access: " + err.message
);
showErrorToast("MetaMask not available or user denied access: " + err.message);
}
}
} catch (err: any) {
setStatus("Failed to load config: " + err.message);
showErrorToast("Failed to load config: " + err.message);
}
};
loadConfig();
@@ -78,9 +69,7 @@ export default function Clash() {
Crypto Clash
</h1>
<p className="text-center text-slate-600 dark:text-slate-300 mb-8">
{phase === "games" && "Browse and join games."}
{phase === "commit" && "Commit your move."}
{phase === "reveal" && "Reveal your move."}
Browse and join games.
</p>
<div className="mb-8 p-4 bg-slate-100 dark:bg-slate-700 rounded-lg">
<div className="mb-4">
@@ -92,7 +81,6 @@ export default function Clash() {
value={selectedAccount}
onChange={(e) => {
setSelectedAccount(e.target.value);
setAccount(e.target.value);
}}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
@@ -117,100 +105,29 @@ export default function Clash() {
{config?.GAME_CONTRACT_ADDRESS}
</p>
</div>
<div className="flex justify-center mb-6 space-x-4">
<button
onClick={() => setPhase("games")}
className={`px-4 py-2 rounded ${
phase === "games"
? "bg-blue-600 text-white"
: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
}`}
>
Games
</button>
<button
onClick={() => setPhase("commit")}
className={`px-4 py-2 rounded ${
phase === "commit"
? "bg-blue-600 text-white"
: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
}`}
>
Commit
</button>
<button
onClick={() => setPhase("reveal")}
className={`px-4 py-2 rounded ${
phase === "reveal"
? "bg-blue-600 text-white"
: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200"
}`}
>
Reveal
</button>
</div>
<div className="space-y-6">
{phase === "games" && (
<GameList
account={selectedAccount}
contract={contract}
config={config}
web3={web3}
setStatus={setStatus}
onPlayClick={handlePlayClick}
/>
)}
{phase === "commit" && (
<Commit
account={selectedAccount}
contract={contract}
config={config}
web3={web3}
setStatus={setStatus}
selectedMove={selectedMove}
setSelectedMove={setSelectedMove}
secret={secret}
setSecret={setSecret}
onBothPlayersCommitted={() => setPhase("reveal")}
/>
)}
{phase === "reveal" && (
<Reveal
account={selectedAccount}
contract={contract}
config={config}
web3={web3}
setStatus={setStatus}
selectedMove={selectedMove}
secret={secret}
/>
)}
</div>
{status && (
<div
className={`mt-6 p-4 rounded-lg ${
status.includes("✅") || status.includes("tx sent")
? "bg-green-50 dark:bg-green-900 text-green-800 dark:text-green-200"
: "bg-red-50 dark:bg-red-900 text-red-800 dark:text-red-200"
}`}
>
<p className="text-sm break-words">{status}</p>
</div>
)}
<div className="mt-8 p-4 bg-yellow-50 dark:bg-yellow-900 rounded-lg text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-semibold mb-2"> Note:</p>
<ul className="list-disc list-inside space-y-1">
<li>
MetaMask or a compatible Web3 wallet is required for write
operations
</li>
<li>
Use bytes32 for encrypted move (see contract docs for details)
</li>
<li>ETH values are in Ether (not Wei)</li>
</ul>
<GameList
account={selectedAccount}
contract={contract}
config={config}
web3={web3}
setStatus={setStatus}
onPlayClick={handlePlayClick}
/>
</div>
</div>
{/* Game Modal */}
<GameModal
gameId={selectedGameId}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
account={selectedAccount}
contract={contract}
config={config}
web3={web3}
setStatus={setStatus}
/>
</main>
</div>
);