Merge remote-tracking branch 'origin/dev'

This commit is contained in:
Sewmina 2025-08-02 01:01:29 +00:00
commit e2b0b55cb8
29 changed files with 3361 additions and 85 deletions

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

View File

@ -0,0 +1,16 @@
body { padding: 0; margin: 0 }
#unity-container { position: absolute }
#unity-container.unity-desktop { left: 50%; top: 50%; transform: translate(-50%, -50%) }
#unity-container.unity-mobile { position: fixed; width: 100%; height: 100% }
#unity-canvas { background: #231F20 }
.unity-mobile #unity-canvas { width: 100%; height: 100% }
#unity-loading-bar { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: none }
#unity-logo { width: 154px; height: 130px; background: url('unity-logo-dark.png') no-repeat center }
#unity-progress-bar-empty { width: 141px; height: 18px; margin-top: 10px; margin-left: 6.5px; background: url('progress-bar-empty-dark.png') no-repeat center }
#unity-progress-bar-full { width: 0%; height: 18px; margin-top: 10px; background: url('progress-bar-full-dark.png') no-repeat center }
#unity-footer { position: relative }
.unity-mobile #unity-footer { display: none }
#unity-webgl-logo { float:left; width: 204px; height: 38px; background: url('webgl-logo.png') no-repeat center }
#unity-build-title { float: right; margin-right: 10px; line-height: 38px; font-family: arial; font-size: 18px }
#unity-fullscreen-button { cursor:pointer; float: right; width: 38px; height: 38px; background: url('fullscreen-button.png') no-repeat center }
#unity-warning { position: absolute; left: 50%; top: 5%; transform: translate(-50%); background: white; padding: 10px; display: none }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Unity WebGL Player | TetrisMP</title>
<link rel="shortcut icon" href="TemplateData/favicon.ico">
<link rel="stylesheet" href="TemplateData/style.css">
</head>
<body>
<div id="unity-container" class="unity-desktop">
<canvas id="unity-canvas" width=1280 height=720 tabindex="-1"></canvas>
<div id="unity-loading-bar">
<div id="unity-logo"></div>
<div id="unity-progress-bar-empty">
<div id="unity-progress-bar-full"></div>
</div>
</div>
<div id="unity-warning"> </div>
<div id="unity-footer">
<div id="unity-webgl-logo"></div>
<div id="unity-fullscreen-button"></div>
<div id="unity-build-title">TetrisMP</div>
</div>
</div>
<script>
var container = document.querySelector("#unity-container");
var canvas = document.querySelector("#unity-canvas");
var loadingBar = document.querySelector("#unity-loading-bar");
var progressBarFull = document.querySelector("#unity-progress-bar-full");
var fullscreenButton = document.querySelector("#unity-fullscreen-button");
var warningBanner = document.querySelector("#unity-warning");
// Shows a temporary message banner/ribbon for a few seconds, or
// a permanent error message on top of the canvas if type=='error'.
// If type=='warning', a yellow highlight color is used.
// Modify or remove this function to customize the visually presented
// way that non-critical warnings and error messages are presented to the
// user.
function unityShowBanner(msg, type) {
function updateBannerVisibility() {
warningBanner.style.display = warningBanner.children.length ? 'block' : 'none';
}
var div = document.createElement('div');
div.innerHTML = msg;
warningBanner.appendChild(div);
if (type == 'error') div.style = 'background: red; padding: 10px;';
else {
if (type == 'warning') div.style = 'background: yellow; padding: 10px;';
setTimeout(function () {
warningBanner.removeChild(div);
updateBannerVisibility();
}, 5000);
}
updateBannerVisibility();
}
var buildUrl = "Build";
var loaderUrl = buildUrl + "/prod.loader.js";
var config = {
dataUrl: buildUrl + "/prod.data",
frameworkUrl: buildUrl + "/prod.framework.js",
codeUrl: buildUrl + "/prod.wasm",
streamingAssetsUrl: "StreamingAssets",
companyName: "DefaultCompany",
productName: "TetrisMP",
productVersion: "1.0",
showBanner: unityShowBanner,
};
/*UNITY BRIDGE*/
function sendMessageToReact(message) {
if (window.parent) {
window.parent.postMessage(message, "*"); // Replace "*" with your React app's origin for security
} else {
console.error("Parent window not found.");
}
}
// By default, Unity keeps WebGL canvas render target size matched with
// the DOM size of the canvas element (scaled by window.devicePixelRatio)
// Set this to false if you want to decouple this synchronization from
// happening inside the engine, and you would instead like to size up
// the canvas DOM size and WebGL render target sizes yourself.
// config.matchWebGLToCanvasSize = false;
if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) {
// Mobile device style: set canvas to 80% of screen size to account for iframe padding
var meta = document.createElement('meta');
meta.name = 'viewport';
meta.content = 'width=device-width, height=device-height, initial-scale=1.0, user-scalable=no, shrink-to-fit=yes';
document.getElementsByTagName('head')[0].appendChild(meta);
container.className = "unity-mobile";
canvas.className = "unity-mobile";
// Set canvas size to 80% of viewport dimensions
canvas.style.height = "80vh";
// To lower canvas resolution on mobile devices to gain some
// performance, uncomment the following line:
// config.devicePixelRatio = 1;
} else {
// Desktop style: Render the game canvas in a window that can be maximized to fullscreen:
canvas.style.width = "1280px";
canvas.style.height = "720px";
}
loadingBar.style.display = "block";
var script = document.createElement("script");
script.src = loaderUrl;
script.onload = () => {
createUnityInstance(canvas, config, (progress) => {
progressBarFull.style.width = 100 * progress + "%";
}).then((unityInstance) => {
loadingBar.style.display = "none";
fullscreenButton.onclick = () => {
unityInstance.SetFullscreen(1);
};
}).catch((message) => {
alert(message);
});
};
document.body.appendChild(script);
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -3,12 +3,15 @@
import Footer from "@/components/Footer";
import Header from "@/components/Header";
import HeroSection from "@/components/HeroSection";
import BeatHighscoreSection from "@/components/BeatHighscoreSection";
import Leaderboard from "@/components/Leaderboard";
import Activities from "@/components/Activities";
import GlobalChat from "@/components/GlobalChat";
import { PrivyProvider } from "@privy-io/react-auth";
import { toSolanaWalletConnectors } from "@privy-io/react-auth/solana";
import { Toaster } from "sonner";
import { PracticeGameProvider } from "@/contexts/PracticeGameContext";
import TopBanner from "@/components/TopBanner";
export default function Home() {
@ -35,10 +38,17 @@ export default function Home() {
}}
>
<PracticeGameProvider>
<>
<Toaster position="top-right" richColors />
<Header />
<TopBanner/>
<BeatHighscoreSection />
<HeroSection />
<div className="container mt-10"></div>
<Leaderboard />
<div className="container mt-10"></div>
@ -47,6 +57,7 @@ export default function Home() {
<GlobalChat />
<Footer />
</>
</PracticeGameProvider>
</PrivyProvider>
</div>

View File

@ -0,0 +1,672 @@
import { useState, useEffect, useRef } from "react";
import { connection, CLUSTER_URL } from "@/data/shared";
import { usePrivy, useSolanaWallets } from "@privy-io/react-auth";
import { getTicketsBalance, buyTickets, fetchLeaderboard, enterLeaderboard, getReceiptAccount } from "@/shared/solana_leaderboard";
import { toast } from "sonner";
import { Leaderboard } from "@/types/Leaderboard";
import { highscoreGames } from "@/data/games";
import { fetchUserById } from "@/shared/data_fetcher";
import { API_BASE_URL } from "@/data/shared";
import Image from "next/image";
import { clusterApiUrl } from "@solana/web3.js";
import { usePracticeGame } from "@/contexts/PracticeGameContext";
interface UserData {
id: string;
username: string;
bio: string;
x_profile_url: string;
}
export default function BeatHighscoreSection() {
const [leaderboards, setLeaderboards] = useState<Leaderboard[]>([]);
const [loading, setLoading] = useState(true);
const [ticketBalance, setTicketBalance] = useState<string>("--");
const [isBuyingTickets, setIsBuyingTickets] = useState(false);
const [showBuyModal, setShowBuyModal] = useState(false);
const [ticketAmount, setTicketAmount] = useState<number>(1);
const [isEnteringLeaderboard, setIsEnteringLeaderboard] = useState<number | null>(null);
const [userDataCache, setUserDataCache] = useState<Map<string, UserData>>(new Map());
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const [selectedLeaderboardIndex, setSelectedLeaderboardIndex] = useState(0);
const [activeGameData, setActiveGameData] = useState<{gameId: string, leaderboardId: number, highscore: number} | null>(null);
const defaultPFP = '/duelfiassets/PFP (1).png';
const iframeRef = useRef<HTMLIFrameElement>(null);
const { isPracticeGameOpen, setIsPracticeGameOpen, isActiveGameOpen, setIsActiveGameOpen } = usePracticeGame();
const { user } = usePrivy();
const { wallets, ready } = useSolanaWallets();
const game_close_signal = (status: number) => {
setIsPracticeGameOpen(false);
setIsActiveGameOpen(false);
console.log(status);
fetchAllLeaderboards();
};
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin === window.location.origin && typeof event.data === "string") {
try {
const message = JSON.parse(event.data);
if (message?.type === "gameClose" && typeof message.status === "number") {
game_close_signal(message.status);
}
} catch (error) {
console.error("JSON parse error from Unity message:", error);
}
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
// Check receipt account on load and every 10 seconds
useEffect(() => {
const checkReceiptAccount = async () => {
if (!user || !wallets || wallets.length === 0 || isPracticeGameOpen || isActiveGameOpen) {
return;
}
try {
const solanaWallet = wallets.find(wallet => wallet.type === "solana");
if (!solanaWallet) return;
const receiptAccount = await getReceiptAccount(solanaWallet);
if (receiptAccount && receiptAccount.active) {
// Find the leaderboard by ID from receipt
const leaderboardId = receiptAccount.id;
console.log("Load game for leaderboard id: ", leaderboardId);
const currentLeaderboard = leaderboards.find(lb => lb.id.toString() === leaderboardId.toString());
console.log("Current leaderboard: ", currentLeaderboard, " searched ", leaderboards.length);
if (currentLeaderboard) {
const game = highscoreGames.find(g => g.id === currentLeaderboard.gameId);
if (game) {
// Find current user's highscore in this leaderboard
const currentPlayer = currentLeaderboard.players.find(p => p.uid === user?.id);
const highscore = currentPlayer?.score || 0;
setActiveGameData({
gameId: game.id,
leaderboardId: leaderboardId.toNumber(),
highscore: highscore
});
console.log("Active game data: ", activeGameData);
setIsActiveGameOpen(true);
}
}
}
} catch (error) {
console.error("Error checking receipt account:", error);
}
};
// Check immediately on load
checkReceiptAccount();
// Check every 10 seconds
const interval = setInterval(checkReceiptAccount, 10000);
return () => clearInterval(interval);
}, [user, wallets, leaderboards, isPracticeGameOpen, isActiveGameOpen]);
// Fetch user data by ID
const fetchUserData = async (uid: string): Promise<UserData | null> => {
if (userDataCache.has(uid)) {
return userDataCache.get(uid) || null;
}
try {
const userData = await fetchUserById(uid);
if (userData) {
setUserDataCache(prev => new Map(prev).set(uid, userData));
return userData;
}
} catch (error) {
console.error("Error fetching user data:", error);
}
return null;
};
// Fetch ticket balance
const fetchTicketBalance = async () => {
if (!user || !wallets || wallets.length === 0) {
setTicketBalance("--");
return;
}
try {
const solanaWallet = wallets.find(wallet => wallet.type === "solana");
if (!solanaWallet) {
setTicketBalance("--");
return;
}
const balance = await getTicketsBalance(solanaWallet);
if (balance !== undefined) {
setTicketBalance(balance);
} else {
setTicketBalance("0");
}
} catch (error) {
console.error("Error fetching ticket balance:", error);
setTicketBalance("0");
}
};
// Handle buying tickets
const handleBuyTickets = async () => {
if (!user || !wallets || wallets.length === 0) {
toast.error("Please connect your wallet first");
return;
}
const solanaWallet = wallets.find(wallet => wallet.type === "solana");
if (!solanaWallet) {
toast.error("Please connect a Solana wallet");
return;
}
setIsBuyingTickets(true);
toast.loading(`Buying ${ticketAmount} ticket${ticketAmount > 1 ? 's' : ''}...`);
try {
const tx = await buyTickets(solanaWallet, ticketAmount);
if(tx){
await connection.confirmTransaction(tx, 'finalized');
}
toast.dismiss();
toast.success(`${ticketAmount} ticket${ticketAmount > 1 ? 's' : ''} purchased successfully!`);
setShowBuyModal(false);
setTicketAmount(1); // Reset to default
fetchTicketBalance(); // Refresh balance
} catch (error) {
console.error("Error buying tickets:", error);
toast.dismiss();
toast.error("Failed to buy tickets. Please try again.");
} finally {
setIsBuyingTickets(false);
await fetchTicketBalance();
}
};
// Open buy modal
const openBuyModal = () => {
setShowBuyModal(true);
setTicketAmount(1); // Reset to default
};
useEffect(() => {
if (ready && user) {
fetchTicketBalance();
}
}, [ready, user, wallets]);
// Handle entering leaderboard
const handleEnterLeaderboard = async (leaderboardId: number) => {
if (!user || !wallets || wallets.length === 0) {
toast.error("Please connect your wallet first");
return;
}
const solanaWallet = wallets.find(wallet => wallet.type === "solana");
if (!solanaWallet) {
toast.error("Please connect a Solana wallet");
return;
}
setIsEnteringLeaderboard(leaderboardId);
toast.loading("Entering leaderboard...");
try {
const tx = await enterLeaderboard(solanaWallet, leaderboardId, user?.id);
if(tx){
await connection.confirmTransaction(tx, 'finalized');
}
toast.dismiss();
toast.success("Successfully entered leaderboard!");
// Refresh ticket balance since entering consumes one ticket
await fetchTicketBalance();
// Check receipt account to see if active
const receiptAccount = await getReceiptAccount(solanaWallet);
if (receiptAccount && receiptAccount.active) {
// Find the current leaderboard to get game info
const currentLeaderboard = leaderboards.find(lb => lb.id.toString() === leaderboardId.toString());
if (currentLeaderboard) {
const game = highscoreGames.find(g => g.id === currentLeaderboard.gameId);
if (game) {
// Find current user's highscore in this leaderboard
const currentPlayer = currentLeaderboard.players.find(p => p.uid === user?.id);
const highscore = currentPlayer?.score || 0;
setActiveGameData({
gameId: game.id,
leaderboardId: leaderboardId,
highscore: highscore
});
console.log("Active game data: ", activeGameData);
setIsActiveGameOpen(true);
}
}
}
} catch (error) {
console.error("Error entering leaderboard:", error);
toast.dismiss();
toast.error("Failed to enter leaderboard. Please try again.");
} finally {
setIsEnteringLeaderboard(null);
}
};
const fetchAllLeaderboards = async () => {
try {
const data = await fetchLeaderboard();
// Filter out leaderboards that are over
const activeLeaderboards = data.filter(leaderboard => !leaderboard.isOver);
setLeaderboards(activeLeaderboards);
// Fetch user data for all players
const uniqueUserIds = new Set<string>();
activeLeaderboards.forEach(leaderboard => {
leaderboard.players.forEach(player => {
if (player.uid) {
uniqueUserIds.add(player.uid);
}
});
});
// Fetch user data for all unique users
for (const uid of uniqueUserIds) {
await fetchUserData(uid);
}
} catch (error) {
console.error("Error fetching leaderboards:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAllLeaderboards();
}, []);
// Navigation functions for carousel
const nextLeaderboard = () => {
setSelectedLeaderboardIndex((prev) =>
prev === leaderboards.length - 1 ? 0 : prev + 1
);
};
const prevLeaderboard = () => {
setSelectedLeaderboardIndex((prev) =>
prev === 0 ? leaderboards.length - 1 : prev - 1
);
};
// Handle practice game
const handlePracticeGame = () => {
setIsPracticeGameOpen(true);
};
if (loading) {
return (
<div className="container mx-auto max-w-screen-xl px-3">
<div className="bg-[rgb(30,30,30)] rounded-xl p-6 shadow-lg">
<h2 className="text-2xl font-bold text-white mb-4">Beat the Highscore</h2>
<div className="flex justify-center items-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-[rgb(248,144,22)]"></div>
</div>
</div>
</div>
);
}
const currentLeaderboard = leaderboards[selectedLeaderboardIndex];
const currentGame = highscoreGames.find(g => g.id === currentLeaderboard.gameId);
return (
<>
{isPracticeGameOpen && currentGame ? (
<div className="w-full h-screen flex justify-center items-center bg-black">
<iframe
ref={iframeRef}
src={`/UnityBuild/${currentGame.id}/index.html?isPractice=1`}
className="w-full h-full"
allowFullScreen
/>
</div>
) : isActiveGameOpen && activeGameData ? (
<div className="w-full h-screen flex justify-center items-center bg-black">
<iframe
ref={iframeRef}
src={`/UnityBuild/${activeGameData.gameId}/index.html?playerKey=${wallets?.[0]?.address}&leaderboardId=${activeGameData.leaderboardId}&highscore=${activeGameData.highscore}&isDev=${CLUSTER_URL === clusterApiUrl("devnet") ? 1 : 0}`}
className="w-full h-full"
allowFullScreen
/>
</div>
) : !isPracticeGameOpen && !isActiveGameOpen ? (
<div className="container mx-auto max-w-screen-xl px-3 md:px-40">
<div className="bg-[rgb(30,30,30)] rounded-xl p-6 shadow-lg">
<h2 className="text-2xl font-bold text-white mb-6 text-center">Beat the Highscore</h2>
{/* Ticket Balance Section */}
{user && (
<div className="mb-6 p-4 bg-[rgb(10,10,10)] rounded-lg border border-[rgb(30,30,30)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 flex items-center justify-center bg-[rgb(248,144,22)] text-black font-bold rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
</svg>
</div>
<div>
<p className="text-white font-semibold">Ticket Balance</p>
<p className="text-[rgb(248,144,22)] font-bold text-lg">{ticketBalance}</p>
</div>
<button
onClick={fetchTicketBalance}
className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-3 rounded-lg transition-all duration-300 flex items-center gap-2"
title="Refresh balance"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={openBuyModal}
className="bg-[rgb(248,144,22)] hover:bg-[rgb(248,144,22)]/80 text-black font-bold py-2 px-4 rounded-lg transition-all duration-300 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Buy Tickets
</button>
</div>
</div>
</div>
)}
{leaderboards.length > 0 && (
<div className="relative">
{/* Carousel Navigation */}
<div className="flex items-center justify-between mb-6">
<button
onClick={prevLeaderboard}
className="bg-gray-700 hover:bg-gray-600 text-white p-2 rounded-lg transition-colors"
disabled={leaderboards.length <= 1}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div className="flex items-center gap-4">
<div className="text-center">
<h3 className="text-white font-bold text-lg">
{(() => {
const game = highscoreGames.find(g => g.id === currentLeaderboard.gameId);
return game ? game.name : `Game ${currentLeaderboard.gameId}`;
})()}
</h3>
<p className="text-gray-400 text-sm">
Leaderboard {selectedLeaderboardIndex + 1} of {leaderboards.length}
</p>
</div>
</div>
<button
onClick={nextLeaderboard}
className="bg-gray-700 hover:bg-gray-600 text-white p-2 rounded-lg transition-colors"
disabled={leaderboards.length <= 1}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Carousel Indicators */}
{leaderboards.length > 1 && (
<div className="flex justify-center gap-2 mb-6">
{leaderboards.map((_, index) => (
<button
key={index}
onClick={() => setSelectedLeaderboardIndex(index)}
className={`w-2 h-2 rounded-full transition-colors ${
index === selectedLeaderboardIndex
? 'bg-[rgb(248,144,22)]'
: 'bg-gray-600 hover:bg-gray-500'
}`}
/>
))}
</div>
)}
{/* Current Leaderboard */}
<div className="bg-[rgb(10,10,10)] rounded-lg border border-[rgb(30,30,30)] p-6">
<div className="text-center mb-6">
<div className="mb-4 flex items-center justify-center gap-4">
{(() => {
const game = highscoreGames.find(g => g.id === currentLeaderboard.gameId);
const totalAttempts = currentLeaderboard.players.reduce((total, player) => total + (player.attempts || 0), 0);
const remainingAttempts = currentLeaderboard.maxAttemptCount - totalAttempts;
return game ? (
<>
<Image
src={game.thumbnail}
alt={game.name}
width={120}
height={80}
className="rounded-lg object-cover"
/>
<div className="flex flex-col items-center gap-2">
<div className="bg-[rgb(248,144,22)] text-black px-3 py-1 rounded-full text-sm font-bold">
{totalAttempts}/{currentLeaderboard.maxAttemptCount} attempts
</div>
<div className="text-gray-400 text-xs">
{remainingAttempts > 0 ? `${remainingAttempts} remaining` : 'Limit reached'}
</div>
</div>
</>
) : (
<>
<div className="w-30 h-20 bg-gray-700 rounded-lg flex items-center justify-center">
<span className="text-gray-400 text-sm">{currentLeaderboard.gameId}</span>
</div>
<div className="flex flex-col items-center gap-2">
<div className="bg-[rgb(248,144,22)] text-black px-3 py-1 rounded-full text-sm font-bold">
{totalAttempts}/{currentLeaderboard.maxAttemptCount} attempts
</div>
<div className="text-gray-400 text-xs">
{remainingAttempts > 0 ? `${remainingAttempts} remaining` : 'Limit reached'}
</div>
<div className="text-gray-400 text-xs">
Entry: {currentLeaderboard.entryTicketCount} ticket{currentLeaderboard.entryTicketCount > 1 ? 's' : ''}
</div>
</div>
</>
);
})()}
</div>
<p className="text-gray-400 text-sm mb-4">{currentLeaderboard.players.length} players</p>
</div>
{/* Leaderboard Entries - Full Width */}
<div className="space-y-3 mb-6">
{currentLeaderboard.players.length > 0 ? (
[...currentLeaderboard.players]
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map((player, index) => {
const userData = userDataCache.get(player.uid);
let profileUrl = userData?.x_profile_url || `${API_BASE_URL}profile_pics/${userData?.id}.jpg`;
if (failedImages.has(profileUrl)) {
profileUrl = defaultPFP;
}
return (
<div
key={`${currentLeaderboard.id}-${player.uid}-${index}`}
className="flex items-center gap-4 p-3 rounded-lg bg-[rgb(20,20,20)] border border-[rgb(30,30,30)] w-full"
>
<div className="w-8 h-8 flex items-center justify-center bg-[rgb(248,144,22)] text-black font-bold rounded-full flex-shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold truncate">
{userData?.username || "Anonymous"}
</h3>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<div className="flex flex-col items-center">
<span className="text-[rgb(248,144,22)] font-bold">{player.score}</span>
<span className="text-gray-400 text-xs">score</span>
</div>
<div className="flex flex-col items-center">
<span className="text-gray-300 font-semibold">{player.attempts || 0}</span>
<span className="text-gray-400 text-xs">attempts</span>
</div>
</div>
<Image
src={profileUrl}
alt="Profile"
width={40}
height={40}
className="rounded-full border border-gray-700 object-cover flex-shrink-0"
onError={(e) => {
// @ts-expect-error - Type mismatch expected
e.target.src = defaultPFP;
setFailedImages(prev => new Set(prev).add(profileUrl));
}}
/>
</div>
);
})
) : (
<div className="text-center py-8">
<p className="text-gray-400">No players yet</p>
<p className="text-gray-500 text-sm">Be the first to join!</p>
</div>
)}
</div>
<div className="text-center">
<div className="flex gap-3">
<button
onClick={handlePracticeGame}
className="w-1/3 bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-4 rounded-lg transition-all duration-300 flex items-center justify-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Practice
</button>
<button
onClick={() => handleEnterLeaderboard(currentLeaderboard.id)}
disabled={isEnteringLeaderboard === currentLeaderboard.id}
className="flex-1 bg-[rgb(248,144,22)] hover:bg-[rgb(248,144,22)]/80 disabled:opacity-50 disabled:cursor-not-allowed text-black font-bold py-3 px-6 rounded-lg transition-all duration-300 flex items-center justify-center gap-2"
>
{isEnteringLeaderboard === currentLeaderboard.id ? (
<div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-black"></div>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
</svg>
)}
{isEnteringLeaderboard === currentLeaderboard.id ? "Entering..." : `Try - ${currentLeaderboard.entryTicketCount} Ticket${currentLeaderboard.entryTicketCount > 1 ? 's' : ''}`}
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Buy Tickets Modal */}
{showBuyModal && (
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[rgb(30,30,30)] rounded-xl p-6 shadow-lg max-w-md w-full mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-white">Buy Tickets</h3>
<button
onClick={() => setShowBuyModal(false)}
className="text-gray-400 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mb-4">
<label className="block text-sm text-gray-300 mb-2">Number of tickets:</label>
<div className="flex items-center gap-3">
<button
onClick={() => setTicketAmount(Math.max(1, ticketAmount - 1))}
className="w-8 h-8 flex items-center justify-center bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<input
type="number"
min="1"
max="100"
value={ticketAmount}
onChange={(e) => setTicketAmount(Math.max(1, parseInt(e.target.value) || 1))}
className="flex-1 bg-gray-800 text-white text-center p-2 rounded-lg border border-gray-600 focus:border-[rgb(248,144,22)] focus:outline-none"
/>
<button
onClick={() => setTicketAmount(Math.min(100, ticketAmount + 1))}
className="w-8 h-8 flex items-center justify-center bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
</div>
<div className="mb-4 p-3 bg-gray-800 rounded-lg">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-300">Price per ticket:</span>
<span className="text-white">0.1 SOL</span>
</div>
<div className="flex items-center justify-between text-sm mt-1">
<span className="text-gray-300">Total cost:</span>
<span className="text-[rgb(248,144,22)] font-bold">{(ticketAmount * 0.1).toFixed(1)} SOL</span>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowBuyModal(false)}
className="flex-1 bg-gray-700 hover:bg-gray-600 text-white font-bold py-2 px-4 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleBuyTickets}
disabled={isBuyingTickets}
className="flex-1 bg-[rgb(248,144,22)] hover:bg-[rgb(248,144,22)]/80 disabled:opacity-50 disabled:cursor-not-allowed text-black font-bold py-2 px-4 rounded-lg transition-all duration-300 flex items-center justify-center gap-2"
>
{isBuyingTickets ? (
<div className="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-black"></div>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
</svg>
)}
{isBuyingTickets ? "Buying..." : `Buy ${ticketAmount} Ticket${ticketAmount > 1 ? 's' : ''}`}
</button>
</div>
</div>
</div>
)}
</div>
) : null}
</>
);
}

View File

@ -15,6 +15,7 @@ import { RematchModal } from "./RematchModal";
import { API_URL, CLUSTER_URL } from '../data/shared';
import { clusterApiUrl } from "@solana/web3.js";
import { getCurrencyByMint } from "@/data/currencies";
import { usePracticeGame } from "@/contexts/PracticeGameContext";
export default function HeroSection() {
const [isModalOpen, setIsModalOpen] = useState(false);
@ -31,6 +32,7 @@ export default function HeroSection() {
const { wallets, ready } = useSolanaWallets();
const { user } = usePrivy();
const iframeRef = useRef<HTMLIFrameElement>(null);
const { isPracticeGameOpen, isActiveGameOpen } = usePracticeGame();
// Check if this is the user's first visit
useEffect(() => {
@ -256,6 +258,8 @@ export default function HeroSection() {
}, []);
return (
<>
{!isPracticeGameOpen && !isActiveGameOpen && (
<>
{myActiveBet ? (
(
@ -276,16 +280,10 @@ export default function HeroSection() {
)
) : (
<section className="flex flex-col items-center text-center py-16">
<video
autoPlay
loop
muted
playsInline
className="w-full max-w-4xl mb-8 rounded-lg"
>
<source src="/duelfiassets/headervideo.MP4" type="video/mp4" />
Your browser does not support the video tag.
</video>
<h1 className="text-4xl font-bold text-white mb-8">
Head-to-Head
</h1>
{/* <Image
src="/duelfiassets/Playing on Arcade Machine no BG.png"
@ -333,6 +331,8 @@ export default function HeroSection() {
<OpenGames bets={bets} />
</section>
)}
</>
)}
{rematch && (
<RematchModal

View File

@ -0,0 +1,32 @@
import { usePracticeGame } from "@/contexts/PracticeGameContext";
export default function TopBanner() {
const { isPracticeGameOpen, isActiveGameOpen } = usePracticeGame();
return (
<>
{!isPracticeGameOpen && !isActiveGameOpen && (
<div className="w-full flex justify-center items-center">
{/* <video
autoPlay
loop
muted
playsInline
className="w-full max-w-4xl mb-8 rounded-lg"
>
<source src="/duelfiassets/headervideo.MP4" type="video/mp4" />
Your browser does not support the video tag.
</video> */}
<div className="flex flex-col items-center p-20">
<img src="/duelfiassets/Playing on Arcade Machine no BG.png" alt="Duelfi" className="w-full max-w-xs mb-8 rounded-lg" />
<h1 className="text-xl font-bold text-white">Beat Highscores or Beat Players. Get Paid in SOL.</h1>
<p className="text-xl text-gray-300">Choose your path; solo grind or direct duels. Skill always wins.</p>
</div>
</div>
)}
</>
)
}

View File

@ -0,0 +1,35 @@
import { createContext, useState, useContext } from "react";
// Create context for practice game state
interface PracticeGameContextType {
isPracticeGameOpen: boolean;
setIsPracticeGameOpen: (open: boolean) => void;
isActiveGameOpen: boolean;
setIsActiveGameOpen: (open: boolean) => void;
}
const PracticeGameContext = createContext<PracticeGameContextType | undefined>(undefined);
export const usePracticeGame = () => {
const context = useContext(PracticeGameContext);
if (!context) {
throw new Error('usePracticeGame must be used within a PracticeGameProvider');
}
return context;
};
export function PracticeGameProvider({ children }: { children: React.ReactNode }) {
const [isPracticeGameOpen, setIsPracticeGameOpen] = useState(false);
const [isActiveGameOpen, setIsActiveGameOpen] = useState(false);
return (
<PracticeGameContext.Provider value={{
isPracticeGameOpen,
setIsPracticeGameOpen,
isActiveGameOpen,
setIsActiveGameOpen
}}>
{children}
</PracticeGameContext.Provider>
);
}

View File

@ -38,6 +38,17 @@ export const games = [
}
];
export const highscoreGames = [
{
id:"dino",
name:"Dino Run",
entryFee: "1 Ticket",
thumbnail: "/duelfiassets/Dino Run Game Cover banner.jpg",
isAvailable: true
}
]
export function GetGameByID(id:string):Game | undefined{
return games.find((game)=> game.id == id);
}

View File

@ -20,3 +20,5 @@ export const VALIDATOR_URL_DEV = "https://validatordev.duelfi.io/";
export const TOKEN_PROGRAM_ID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
export const TOKEN_PROGRAM_ID_OLD = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
export const TICKETS_TOKEN_MINT = "Tktza8fjqeG89UerPf5z78xBPpiCGV5WMCwUMDpnu9m";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,98 @@
import { CLUSTER_URL, TICKETS_TOKEN_MINT, TOKEN_PROGRAM_ID_OLD, VALIDATOR_URL, VALIDATOR_URL_DEV, connection } from "@/data/shared";
import { Leaderboard, TicketReceipt } from "@/types/Leaderboard";
import { AnchorProvider, Program } from "@coral-xyz/anchor";
import { clusterApiUrl, PublicKey } from "@solana/web3.js";
import { CONFIRMATION_THRESHOLD } from "./constants";
import { Bets } from "@/idl/bets";
import { ConnectedSolanaWallet } from "@privy-io/react-auth";
import idl from "../idl/bets_idl.json";
import { getAssociatedTokenAddress, getAssociatedTokenAddressSync } from "@solana/spl-token";
import { BN } from "@coral-xyz/anchor";
const ValidatorURL = CLUSTER_URL == clusterApiUrl("devnet") ? VALIDATOR_URL_DEV : VALIDATOR_URL;
export async function fetchLeaderboard(): Promise<Leaderboard[]> {
const response = await fetch(`${ValidatorURL}getLeaderboards`);
const data = await response.json();
console.log(`Fetched ${data.length} open bets from validator`);
return data;
}
export async function enterLeaderboard(wallets:ConnectedSolanaWallet, id:number, uid:string){
if (!wallets) return undefined;
const wallet = {
publicKey: new PublicKey(wallets.address),
signTransaction: wallets.signTransaction,
signAllTransactions: wallets.signAllTransactions,
};
const provider = new AnchorProvider(connection, wallet, {
preflightCommitment: CONFIRMATION_THRESHOLD,
});
const program = new Program<Bets>(idl, provider);
console.log("Entering leaderboard: ", id, uid);
const tx = await program.methods.enterLeaderboard(new BN(id), uid).accounts({
payer: wallet.publicKey,
tokenProgram: TOKEN_PROGRAM_ID_OLD,
tokenMint: new PublicKey(TICKETS_TOKEN_MINT),
}).rpc();
return tx;
}
export async function buyTickets(wallets:ConnectedSolanaWallet, amount:number){
if (!wallets) return undefined;
const wallet = {
publicKey: new PublicKey(wallets.address),
signTransaction: wallets.signTransaction,
signAllTransactions: wallets.signAllTransactions,
};
const provider = new AnchorProvider(connection, wallet, {
preflightCommitment: CONFIRMATION_THRESHOLD,
});
const program = new Program<Bets>(idl, provider);
const [leaderboard_list_pda] = await PublicKey.findProgramAddressSync([Buffer.from("ticket_leaderboards_list")], program.programId);
const leaderboard_list_token_vault = await getAssociatedTokenAddressSync(new PublicKey(TICKETS_TOKEN_MINT), leaderboard_list_pda, true, new PublicKey(TOKEN_PROGRAM_ID_OLD));
console.log("leaderboard list pda: ", leaderboard_list_pda.toBase58());
console.log("Buying tickets to leaderboard list token vault: ", leaderboard_list_token_vault.toBase58());
const tx = await program.methods.buyTickets(new BN(amount)).accounts({
payer: wallet.publicKey,
tokenProgram: TOKEN_PROGRAM_ID_OLD,
tokenMint: new PublicKey(TICKETS_TOKEN_MINT),
}).rpc();
await connection.confirmTransaction(tx, "confirmed");
return tx;
}
export async function getTicketsBalance(wallets:ConnectedSolanaWallet){
if (!wallets) return undefined;
const tokenAccount = await getAssociatedTokenAddress(new PublicKey(TICKETS_TOKEN_MINT), new PublicKey(wallets.address));
const balance = await connection.getTokenAccountBalance(tokenAccount);
return balance.value.amount;
}
export async function getReceiptAccount(wallets:ConnectedSolanaWallet): Promise<TicketReceipt | undefined> {
if (!wallets) return undefined;
const wallet = {
publicKey: new PublicKey(wallets.address),
signTransaction: wallets.signTransaction,
signAllTransactions: wallets.signAllTransactions,
};
const provider = new AnchorProvider(connection, wallet, {
preflightCommitment: CONFIRMATION_THRESHOLD,
});
const program = new Program<Bets>(idl, provider);
const [receipt_pda] = await PublicKey.findProgramAddressSync([Buffer.from("receipt_account"), new PublicKey(wallets.address).toBuffer()], program.programId);
const receipt_acc = await program.account.ticketReceiptVault.fetch(receipt_pda);
return receipt_acc as TicketReceipt;
}

23
src/types/Leaderboard.ts Normal file
View File

@ -0,0 +1,23 @@
import { BN } from "@coral-xyz/anchor";
export interface Player {
username: string;
score: number;
uid:string;
attempts:number;
}
export interface Leaderboard {
id: BN;
gameId: string;
players: Player[];
isOver: boolean;
entryTicketCount: number;
maxAttemptCount: number;
}
export interface TicketReceipt {
id: BN;
uid: string;
active: boolean;
}