Add deployment scaffolding (Dockerfile, docker-compose, nginx)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-07 14:14:29 +01:00
parent f3b259ab90
commit d7a54c7b92
6 changed files with 852 additions and 119 deletions

View File

@ -3,6 +3,17 @@ FROM node:18-alpine AS builder
WORKDIR /app WORKDIR /app
# Accept build args for Next.js public env vars (baked in at build time)
ARG NEXT_PUBLIC_PUSHER_APP_KEY
ARG NEXT_PUBLIC_PUSHER_CLUSTER
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_PUSHER_APP_KEY=$NEXT_PUBLIC_PUSHER_APP_KEY
ENV NEXT_PUBLIC_PUSHER_CLUSTER=$NEXT_PUBLIC_PUSHER_CLUSTER
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci

View File

@ -0,0 +1,24 @@
---
id: task-1
title: Migrate bored.jeffemmett.com and betting.jeffemmett.com to Netcup hosting
status: Done
assignee: []
created_date: '2025-12-06 17:22'
labels: []
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Migrate both domains from Firebase/Vercel to self-hosted Docker containers on Netcup RS 8000
Completed:
- Cloned repos from Gitea
- Created Dockerfiles and docker-compose.yml
- Deployed containers with Traefik labels
- Updated Cloudflare tunnel config
- Updated DNS records to point to tunnel
- Pushed changes to Gitea
<!-- SECTION:DESCRIPTION:END -->

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ChevronDown, ChevronUp, Plus, Settings, Crown } from 'lucide-react'; import { ChevronDown, ChevronUp, Plus, Settings, Crown, TrendingUp, Users, DollarSign, Target, BarChart3, X } from 'lucide-react';
import { pusherClient, triggerPusherEvent } from '../lib/pusher'; import { pusherClient, triggerPusherEvent } from '../lib/pusher';
const ChessApp = () => { const ChessApp = () => {
@ -14,10 +14,10 @@ const ChessApp = () => {
const [showGameForm, setShowGameForm] = useState(false); const [showGameForm, setShowGameForm] = useState(false);
const [newGameData, setNewGameData] = useState({ player1: '', player2: '' }); const [newGameData, setNewGameData] = useState({ player1: '', player2: '' });
const [showBetForm, setShowBetForm] = useState({}); const [showBetForm, setShowBetForm] = useState({});
const [showBetConfirmation, setShowBetConfirmation] = useState({}); const [showBetConfirmation, setShowBetConfirmation] = useState(false);
const [pendingBet, setPendingBet] = useState(null); const [pendingBet, setPendingBet] = useState(null);
const [betType, setBetType] = useState('hedged'); const [betType, setBetType] = useState('hedged');
const [newBetData, setNewBetData] = useState({ gameId: '', betAmount: 0, condition: '', certainty: 50 }); const [newBetData, setNewBetData] = useState({ gameId: '', betAmount: 10, condition: '', certainty: 50 });
const [showDashboard, setShowDashboard] = useState(false); const [showDashboard, setShowDashboard] = useState(false);
const [showAdminLogin, setShowAdminLogin] = useState(false); const [showAdminLogin, setShowAdminLogin] = useState(false);
const [adminPassword, setAdminPassword] = useState(''); const [adminPassword, setAdminPassword] = useState('');
@ -25,7 +25,8 @@ const ChessApp = () => {
const [marketHistory, setMarketHistory] = useState({}); const [marketHistory, setMarketHistory] = useState({});
const [selectedWager, setSelectedWager] = useState(null); const [selectedWager, setSelectedWager] = useState(null);
const [showExistingBetForm, setShowExistingBetForm] = useState({}); const [showExistingBetForm, setShowExistingBetForm] = useState({});
const [showAdvancedAnalysis, setShowAdvancedAnalysis] = useState(false); const [existingBetAmount, setExistingBetAmount] = useState(10);
const [existingBetPosition, setExistingBetPosition] = useState('yes');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@ -125,23 +126,29 @@ const ChessApp = () => {
setBets(betsData); setBets(betsData);
setPlatformAccount(platformData); setPlatformAccount(platformData);
// Create user if none exists // Check for existing user in localStorage
if (Object.keys(usersData).length === 0) { const savedUserId = typeof window !== 'undefined' ? localStorage.getItem('chessUserId') : null;
if (savedUserId && usersData[savedUserId]) {
setCurrentUser(savedUserId);
setIsAdmin(usersData[savedUserId].is_admin || false);
} else {
// Create new user
const newUserId = 'user_' + Math.random().toString(36).substr(2, 9); const newUserId = 'user_' + Math.random().toString(36).substr(2, 9);
const myceliumName = generateMyceliumName(); const myceliumName = generateMyceliumName();
const newUser = { const newUser = {
id: newUserId, id: newUserId,
name: myceliumName, name: myceliumName,
balance: 1000, balance: 1000,
isAdmin: false is_admin: false
}; };
await api.saveUser(newUser); await api.saveUser(newUser);
setUsers({ [newUserId]: newUser }); setUsers(prev => ({ ...prev, [newUserId]: newUser }));
setCurrentUser(newUserId); setCurrentUser(newUserId);
} else { if (typeof window !== 'undefined') {
// Set current user to first user (in production, this would be from auth) localStorage.setItem('chessUserId', newUserId);
setCurrentUser(Object.keys(usersData)[0]); }
} }
setLoading(false); setLoading(false);
@ -161,7 +168,6 @@ const ChessApp = () => {
const channel = pusherClient.subscribe('chess-tournament'); const channel = pusherClient.subscribe('chess-tournament');
// Listen for new games
channel.bind('new-game', (data) => { channel.bind('new-game', (data) => {
setGames(prev => { setGames(prev => {
const exists = prev.find(g => g.id === data.game.id); const exists = prev.find(g => g.id === data.game.id);
@ -170,22 +176,21 @@ const ChessApp = () => {
setExpandedGames(prev => ({ ...prev, [data.game.id]: true })); setExpandedGames(prev => ({ ...prev, [data.game.id]: true }));
}); });
// Listen for new bets
channel.bind('new-bet', (data) => { channel.bind('new-bet', (data) => {
setBets(prev => ({ setBets(prev => ({
...prev, ...prev,
[data.gameId]: [...(prev[data.gameId] || []), data.bet] [data.gameId]: [...(prev[data.gameId] || []), data.bet]
})); }));
// Update market history
updateMarketHistory(data.gameId, data.bet.condition, data.marketProbability); updateMarketHistory(data.gameId, data.bet.condition, data.marketProbability);
}); });
// Listen for user updates
channel.bind('user-update', (data) => { channel.bind('user-update', (data) => {
setUsers(prev => ({ ...prev, [data.userId]: data.user })); setUsers(prev => ({ ...prev, [data.userId]: data.user }));
if (data.userId === currentUser) {
// Update local user data
}
}); });
// Listen for platform account updates
channel.bind('platform-update', (data) => { channel.bind('platform-update', (data) => {
setPlatformAccount(data.platformAccount); setPlatformAccount(data.platformAccount);
}); });
@ -214,7 +219,7 @@ const ChessApp = () => {
// Update admin status when user changes // Update admin status when user changes
useEffect(() => { useEffect(() => {
if (currentUser && users[currentUser]) { if (currentUser && users[currentUser]) {
setIsAdmin(users[currentUser].isAdmin || false); setIsAdmin(users[currentUser].is_admin || false);
} }
}, [currentUser, users]); }, [currentUser, users]);
@ -238,17 +243,13 @@ const ChessApp = () => {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
probability: parseFloat(probability) probability: parseFloat(probability)
}; };
const updatedHistory = [...currentHistory, newEntry].slice(-10);
return { return {
...prev, ...prev,
[key]: updatedHistory [key]: [...currentHistory, newEntry].slice(-10)
}; };
}); });
}; };
// Rest of your calculation functions (unchanged)
const calculateTokenPrices = (gameId, betCondition) => { const calculateTokenPrices = (gameId, betCondition) => {
const gameBets = bets[gameId] || []; const gameBets = bets[gameId] || [];
const relevantBets = gameBets.filter(bet => bet.condition.toLowerCase().trim() === betCondition.toLowerCase().trim()); const relevantBets = gameBets.filter(bet => bet.condition.toLowerCase().trim() === betCondition.toLowerCase().trim());
@ -310,23 +311,11 @@ const ChessApp = () => {
return conditions; return conditions;
}; };
const updateUserName = async (newName) => { const toggleGameExpansion = (gameId) => {
try { setExpandedGames(prev => ({
const updatedUser = { ...users[currentUser], name: newName }; ...prev,
setUsers(prev => ({ ...prev, [currentUser]: updatedUser })); [gameId]: !prev[gameId]
await api.saveUser(updatedUser); }));
} catch (error) {
console.error('Failed to update user name:', error);
}
};
const updateUserBalance = async (userId, newBalance) => {
try {
await api.updateUserBalance(userId, newBalance);
// The real-time update will come through Pusher
} catch (error) {
console.error('Failed to update user balance:', error);
}
}; };
const addGame = async () => { const addGame = async () => {
@ -338,29 +327,181 @@ const ChessApp = () => {
player1: newGameData.player1, player1: newGameData.player1,
player2: newGameData.player2, player2: newGameData.player2,
status: 'upcoming', status: 'upcoming',
createdAt: new Date().toISOString() created_at: new Date().toISOString()
}; };
// Optimistically update UI
setGames(prev => [...prev, newGame]); setGames(prev => [...prev, newGame]);
setBets(prev => ({ ...prev, [gameId]: [] })); setBets(prev => ({ ...prev, [gameId]: [] }));
setExpandedGames(prev => ({ ...prev, [gameId]: true })); setExpandedGames(prev => ({ ...prev, [gameId]: true }));
setNewGameData({ player1: '', player2: '' }); setNewGameData({ player1: '', player2: '' });
setShowGameForm(false); setShowGameForm(false);
// Save to backend
await api.saveGame(newGame); await api.saveGame(newGame);
} catch (error) { } catch (error) {
console.error('Failed to add game:', error); console.error('Failed to add game:', error);
// Revert optimistic update on error
setGames(prev => prev.filter(g => g.id !== newGame.id));
} }
} }
}; };
// Continue with rest of component logic... const openBetForm = (gameId, condition = '') => {
// (I'll create the rest in the next update due to length) setNewBetData({ gameId, betAmount: 10, condition, certainty: 50 });
setShowBetForm(prev => ({ ...prev, [gameId]: true }));
};
const closeBetForm = (gameId) => {
setShowBetForm(prev => ({ ...prev, [gameId]: false }));
};
const calculateBetDetails = (amount, certainty, type) => {
const platformFee = amount * 0.01; // 1% fee
const netCost = amount - platformFee;
if (type === 'hedged') {
const yesTokens = Math.floor((netCost * (certainty / 100)) / 0.50 * 100);
const noTokens = Math.floor((netCost * ((100 - certainty) / 100)) / 0.50 * 100);
return { yesTokens, noTokens, platformFee, netCost, actualCost: amount };
} else {
const tokens = Math.floor(netCost / 0.50 * 100);
return { yesTokens: tokens, noTokens: 0, platformFee, netCost, actualCost: amount };
}
};
const prepareBet = () => {
const { gameId, betAmount, condition, certainty } = newBetData;
if (!condition || betAmount <= 0) return;
const userBalance = users[currentUser]?.balance || 0;
if (betAmount > userBalance) {
alert('Insufficient balance!');
return;
}
const betDetails = calculateBetDetails(betAmount, certainty, betType);
const { marketProbability } = calculateTokenPrices(gameId, condition);
setPendingBet({
gameId,
condition,
certainty,
amount: betAmount,
betType,
...betDetails,
marketProbability
});
setShowBetConfirmation(true);
};
const confirmBet = async () => {
if (!pendingBet) return;
try {
const betId = 'bet_' + Math.random().toString(36).substr(2, 9);
const newBet = {
id: betId,
game_id: pendingBet.gameId,
user_id: currentUser,
user_name: users[currentUser].name,
amount: pendingBet.amount,
condition: pendingBet.condition,
certainty: pendingBet.certainty,
yes_tokens: pendingBet.yesTokens,
no_tokens: pendingBet.noTokens,
bet_type: pendingBet.betType,
actual_cost: pendingBet.actualCost,
platform_fee: pendingBet.platformFee,
net_cost: pendingBet.netCost,
created_at: new Date().toISOString()
};
// Optimistically update UI
setBets(prev => ({
...prev,
[pendingBet.gameId]: [...(prev[pendingBet.gameId] || []), newBet]
}));
// Update user balance
const newBalance = users[currentUser].balance - pendingBet.amount;
setUsers(prev => ({
...prev,
[currentUser]: { ...prev[currentUser], balance: newBalance }
}));
// Update platform account
setPlatformAccount(prev => ({
balance: prev.balance + pendingBet.platformFee,
totalFees: prev.totalFees + pendingBet.platformFee,
transactionCount: prev.transactionCount + 1
}));
// Save to backend
await api.saveBet(pendingBet.gameId, newBet, pendingBet.marketProbability);
await api.updateUserBalance(currentUser, newBalance);
// Close forms
setShowBetConfirmation(false);
setPendingBet(null);
setShowBetForm({});
setShowExistingBetForm({});
} catch (error) {
console.error('Failed to place bet:', error);
alert('Failed to place bet. Please try again.');
}
};
const placeBetOnExisting = (gameId, condition, position) => {
const { yesPrice, noPrice, marketProbability } = calculateTokenPrices(gameId, condition);
const certainty = position === 'yes' ? parseFloat(marketProbability) : (100 - parseFloat(marketProbability));
setNewBetData({
gameId,
betAmount: existingBetAmount,
condition,
certainty
});
setBetType('non-hedged');
const betDetails = calculateBetDetails(existingBetAmount, certainty, 'non-hedged');
setPendingBet({
gameId,
condition,
certainty,
amount: existingBetAmount,
betType: 'non-hedged',
position,
...betDetails,
marketProbability
});
setShowBetConfirmation(true);
};
const handleAdminLogin = () => {
if (adminPassword === 'mycelium2024') {
setIsAdmin(true);
setShowAdminLogin(false);
setAdminPassword('');
// Update user in database
const updatedUser = { ...users[currentUser], is_admin: true };
setUsers(prev => ({ ...prev, [currentUser]: updatedUser }));
api.saveUser(updatedUser);
} else {
alert('Invalid password');
}
};
const getUserBets = () => {
const userBets = [];
Object.entries(bets).forEach(([gameId, gameBets]) => {
gameBets.forEach(bet => {
if (bet.user_id === currentUser) {
const game = games.find(g => g.id === gameId);
userBets.push({ ...bet, game });
}
});
});
return userBets;
};
// Loading state
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gradient-to-b from-green-900 to-gray-900 text-green-100 flex items-center justify-center"> <div className="min-h-screen bg-gradient-to-b from-green-900 to-gray-900 text-green-100 flex items-center justify-center">
@ -373,6 +514,7 @@ const ChessApp = () => {
); );
} }
// Error state
if (error) { if (error) {
return ( return (
<div className="min-h-screen bg-gradient-to-b from-green-900 to-gray-900 text-green-100 flex items-center justify-center"> <div className="min-h-screen bg-gradient-to-b from-green-900 to-gray-900 text-green-100 flex items-center justify-center">
@ -391,6 +533,7 @@ const ChessApp = () => {
); );
} }
// Not logged in state
if (!currentUser || !users[currentUser]) { if (!currentUser || !users[currentUser]) {
return ( return (
<div className="min-h-screen bg-gradient-to-b from-green-900 to-gray-900 text-green-100 flex items-center justify-center"> <div className="min-h-screen bg-gradient-to-b from-green-900 to-gray-900 text-green-100 flex items-center justify-center">
@ -403,61 +546,617 @@ const ChessApp = () => {
} }
const currentUserData = users[currentUser]; const currentUserData = users[currentUser];
const userBets = getUserBets();
return ( return (
<div className="min-h-screen bg-gradient-to-b from-green-900 to-gray-900 text-green-100"> <div className="min-h-screen bg-gradient-to-b from-green-900 to-gray-900 text-green-100">
{/* Backend Status Indicator */} {/* Header */}
<div className="bg-green-900/20 border-b border-green-800/30 p-2 text-center">
<div className="text-xs text-green-300">
🌐 <strong>Live Multiplayer Mode</strong> - Connected to Pusher + Supabase
</div>
</div>
{/* Basic header and navigation for now */}
<div className="bg-black/50 border-b border-green-800/30 p-4"> <div className="bg-black/50 border-b border-green-800/30 p-4">
<div className="max-w-6xl mx-auto flex justify-between items-center"> <div className="max-w-6xl mx-auto flex justify-between items-center">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="text-3xl">🍄</div> <div className="text-3xl">🍄</div>
<div> <div>
<h1 className="text-2xl font-bold text-green-400">Commons Hub Chess Tournament</h1> <h1 className="text-xl md:text-2xl font-bold text-green-400">Commons Hub Chess</h1>
<p className="text-sm text-green-300/70">Official Mycelial-Betting Network</p> <p className="text-xs md:text-sm text-green-300/70">Mycelial Prediction Network</p>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4">
<button
onClick={() => setShowDashboard(!showDashboard)}
className="p-2 bg-green-800/50 hover:bg-green-700/50 rounded-lg"
title="Dashboard"
>
<BarChart3 size={20} />
</button>
<button
onClick={() => setShowOnlineUsers(!showOnlineUsers)}
className="p-2 bg-green-800/50 hover:bg-green-700/50 rounded-lg"
title="Online Users"
>
<Users size={20} />
</button>
{!isAdmin && (
<button
onClick={() => setShowAdminLogin(true)}
className="p-2 bg-green-800/50 hover:bg-green-700/50 rounded-lg"
title="Admin Login"
>
<Settings size={20} />
</button>
)}
{isAdmin && (
<button
onClick={() => setShowAdminPanel(!showAdminPanel)}
className="p-2 bg-amber-800/50 hover:bg-amber-700/50 rounded-lg"
title="Admin Panel"
>
<Crown size={20} className="text-amber-400" />
</button>
)}
<div className="text-right"> <div className="text-right">
<div className="text-lg font-bold text-amber-400"> <div className="text-lg font-bold text-amber-400">
🟫 {currentUserData.balance} Spore Tokens 🟫 {currentUserData.balance?.toFixed(0) || 0}
</div>
<div className="text-xs text-green-300">{currentUserData.name}</div>
</div> </div>
<div className="text-sm text-green-300">{currentUserData.name}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="max-w-6xl mx-auto p-6"> <div className="max-w-6xl mx-auto p-4 md:p-6">
<div className="bg-blue-900/20 border border-blue-600/30 rounded-lg p-6 text-center"> {/* Admin Login Modal */}
<div className="text-4xl mb-4">🚀</div> {showAdminLogin && (
<h3 className="text-xl font-bold text-blue-300 mb-2">Backend Successfully Connected!</h3> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<p className="text-blue-200 mb-4">Your app is now running with real backend services:</p> <div className="bg-gray-900 border border-green-600/30 rounded-lg p-6 max-w-sm w-full">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> <h3 className="text-xl font-bold text-green-400 mb-4">Admin Login</h3>
<div className="bg-purple-900/20 rounded p-3"> <input
<div className="font-semibold text-purple-300">🔌 Pusher Real-time</div> type="password"
<div className="text-purple-400">Live updates between users</div> placeholder="Enter admin password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
className="w-full p-3 bg-gray-800 border border-green-600/30 rounded text-white mb-4"
onKeyPress={(e) => e.key === 'Enter' && handleAdminLogin()}
/>
<div className="flex space-x-3">
<button
onClick={handleAdminLogin}
className="flex-1 bg-green-700 hover:bg-green-600 px-4 py-2 rounded font-semibold"
>
Login
</button>
<button
onClick={() => { setShowAdminLogin(false); setAdminPassword(''); }}
className="flex-1 bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded"
>
Cancel
</button>
</div> </div>
<div className="bg-green-900/20 rounded p-3">
<div className="font-semibold text-green-300">🗄 Supabase Database</div>
<div className="text-green-400">Persistent data storage</div>
</div> </div>
<div className="bg-amber-900/20 rounded p-3"> </div>
<div className="font-semibold text-amber-300"> Vercel Hosting</div> )}
<div className="text-amber-400">Production deployment</div>
{/* Bet Confirmation Modal */}
{showBetConfirmation && pendingBet && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 border border-green-600/30 rounded-lg p-6 max-w-md w-full">
<h3 className="text-xl font-bold text-green-400 mb-4">Confirm Your Bet</h3>
<div className="space-y-3 mb-6">
<div className="flex justify-between">
<span className="text-gray-400">Condition:</span>
<span className="text-white font-semibold">{pendingBet.condition}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Your Certainty:</span>
<span className="text-white">{pendingBet.certainty}%</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Market Says:</span>
<span className="text-white">{pendingBet.marketProbability}%</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Bet Type:</span>
<span className="text-white capitalize">{pendingBet.betType}</span>
</div>
<hr className="border-green-800/30" />
<div className="flex justify-between">
<span className="text-gray-400">Amount:</span>
<span className="text-white">{pendingBet.amount} tokens</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Platform Fee (1%):</span>
<span className="text-amber-400">{pendingBet.platformFee.toFixed(2)} tokens</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Net Investment:</span>
<span className="text-green-400">{pendingBet.netCost.toFixed(2)} tokens</span>
</div>
{pendingBet.betType === 'hedged' && (
<>
<div className="flex justify-between">
<span className="text-gray-400">YES Tokens:</span>
<span className="text-green-400">{pendingBet.yesTokens}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">NO Tokens:</span>
<span className="text-red-400">{pendingBet.noTokens}</span>
</div>
</>
)}
</div>
<div className="flex space-x-3">
<button
onClick={confirmBet}
className="flex-1 bg-green-700 hover:bg-green-600 px-4 py-3 rounded font-semibold"
>
Confirm Bet
</button>
<button
onClick={() => { setShowBetConfirmation(false); setPendingBet(null); }}
className="flex-1 bg-gray-700 hover:bg-gray-600 px-4 py-3 rounded"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Dashboard Panel */}
{showDashboard && (
<div className="bg-gray-900/50 border border-green-600/30 rounded-lg p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-green-400">Your Dashboard</h2>
<button onClick={() => setShowDashboard(false)} className="text-gray-400 hover:text-white">
<X size={20} />
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-green-900/30 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-400">{currentUserData.balance?.toFixed(0) || 0}</div>
<div className="text-xs text-gray-400">Balance</div>
</div>
<div className="bg-purple-900/30 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-purple-400">{userBets.length}</div>
<div className="text-xs text-gray-400">Active Bets</div>
</div>
<div className="bg-amber-900/30 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-amber-400">
{userBets.reduce((sum, bet) => sum + (bet.amount || 0), 0).toFixed(0)}
</div>
<div className="text-xs text-gray-400">Total Wagered</div>
</div>
<div className="bg-blue-900/30 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-blue-400">{games.length}</div>
<div className="text-xs text-gray-400">Games</div>
</div> </div>
</div> </div>
<div className="mt-6 text-sm text-gray-400"> {userBets.length > 0 && (
<p>👥 <strong>{Object.keys(users).length}</strong> users connected</p> <div>
<p>🎮 <strong>{games.length}</strong> games created</p> <h3 className="text-lg font-semibold mb-3">Your Bets</h3>
<p>💰 <strong>{Object.values(bets).flat().length}</strong> bets placed</p> <div className="space-y-2 max-h-60 overflow-y-auto">
{userBets.map((bet, idx) => (
<div key={idx} className="bg-gray-800/50 rounded p-3 text-sm">
<div className="flex justify-between">
<span className="text-green-300">{bet.condition}</span>
<span className="text-amber-400">{bet.amount} tokens</span>
</div> </div>
<div className="text-xs text-gray-400 mt-1">
{bet.game?.player1} vs {bet.game?.player2} {bet.certainty}% certainty
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Online Users Panel */}
{showOnlineUsers && (
<div className="bg-gray-900/50 border border-green-600/30 rounded-lg p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-green-400">Network Participants</h2>
<button onClick={() => setShowOnlineUsers(false)} className="text-gray-400 hover:text-white">
<X size={20} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{Object.values(users).map((user) => (
<div key={user.id} className="bg-gray-800/50 rounded-lg p-3 flex justify-between items-center">
<div>
<div className="text-green-300 font-medium">{user.name}</div>
<div className="text-xs text-gray-400">
{user.is_admin && <span className="text-amber-400">👑 Admin </span>}
Balance: {user.balance?.toFixed(0) || 0}
</div>
</div>
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
</div>
))}
</div>
</div>
)}
{/* Admin Panel */}
{showAdminPanel && isAdmin && (
<div className="bg-amber-900/20 border border-amber-600/30 rounded-lg p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-amber-400">Admin Panel</h2>
<button onClick={() => setShowAdminPanel(false)} className="text-gray-400 hover:text-white">
<X size={20} />
</button>
</div>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-amber-900/30 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-amber-400">{platformAccount.balance?.toFixed(2) || 0}</div>
<div className="text-xs text-gray-400">Platform Balance</div>
</div>
<div className="bg-amber-900/30 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-amber-400">{platformAccount.totalFees?.toFixed(2) || 0}</div>
<div className="text-xs text-gray-400">Total Fees Collected</div>
</div>
<div className="bg-amber-900/30 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-amber-400">{platformAccount.transactionCount || 0}</div>
<div className="text-xs text-gray-400">Transactions</div>
</div>
</div>
<button
onClick={() => setShowGameForm(!showGameForm)}
className="w-full bg-amber-700 hover:bg-amber-600 px-4 py-3 rounded font-semibold flex items-center justify-center space-x-2"
>
<Plus size={20} />
<span>Create New Game</span>
</button>
{showGameForm && (
<div className="mt-4 bg-gray-800/50 rounded-lg p-4">
<h3 className="font-semibold mb-3">New Game</h3>
<div className="grid grid-cols-2 gap-3 mb-3">
<input
type="text"
placeholder="Player 1 Name"
value={newGameData.player1}
onChange={(e) => setNewGameData(prev => ({ ...prev, player1: e.target.value }))}
className="p-3 bg-gray-700 border border-gray-600 rounded text-white"
/>
<input
type="text"
placeholder="Player 2 Name"
value={newGameData.player2}
onChange={(e) => setNewGameData(prev => ({ ...prev, player2: e.target.value }))}
className="p-3 bg-gray-700 border border-gray-600 rounded text-white"
/>
</div>
<button
onClick={addGame}
disabled={!newGameData.player1 || !newGameData.player2}
className="w-full bg-green-700 hover:bg-green-600 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-2 rounded font-semibold"
>
Create Game
</button>
</div>
)}
</div>
)}
{/* Games List */}
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold text-green-400">Active Games</h2>
<div className="text-sm text-gray-400">
{games.length} game{games.length !== 1 ? 's' : ''} {Object.values(bets).flat().length} bet{Object.values(bets).flat().length !== 1 ? 's' : ''}
</div>
</div>
{games.length === 0 ? (
<div className="bg-gray-900/50 border border-green-600/30 rounded-lg p-8 text-center">
<div className="text-4xl mb-4">🎯</div>
<div className="text-xl text-gray-300 mb-2">No games yet</div>
<div className="text-sm text-gray-500">
{isAdmin ? 'Create a game to get started!' : 'Waiting for admin to create games...'}
</div>
</div>
) : (
games.map((game) => {
const gameBets = bets[game.id] || [];
const conditions = getUniqueBetConditions(game.id);
const isExpanded = expandedGames[game.id];
return (
<div key={game.id} className="bg-gray-900/50 border border-green-600/30 rounded-lg overflow-hidden">
{/* Game Header */}
<div
onClick={() => toggleGameExpansion(game.id)}
className="p-4 cursor-pointer hover:bg-gray-800/30 flex justify-between items-center"
>
<div className="flex items-center space-x-4">
<div className="text-2xl"></div>
<div>
<div className="text-lg font-bold text-white">
{game.player1} <span className="text-gray-500">vs</span> {game.player2}
</div>
<div className="text-xs text-gray-400">
{gameBets.length} bet{gameBets.length !== 1 ? 's' : ''} {conditions.length} market{conditions.length !== 1 ? 's' : ''}
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<span className={`px-2 py-1 rounded text-xs ${
game.status === 'upcoming' ? 'bg-blue-900/50 text-blue-300' :
game.status === 'in_progress' ? 'bg-green-900/50 text-green-300' :
'bg-gray-700 text-gray-300'
}`}>
{game.status?.replace('_', ' ')}
</span>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div>
</div>
{/* Expanded Game Content */}
{isExpanded && (
<div className="border-t border-green-800/30 p-4">
{/* Default Markets */}
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-400 mb-3">Quick Markets</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[`${game.player1} wins`, `${game.player2} wins`].map((condition) => {
const { yesPrice, noPrice, marketProbability } = calculateTokenPrices(game.id, condition);
const odds = calculateOdds(game.id, condition);
const hasMarket = odds.betCount > 0;
return (
<div key={condition} className="bg-gray-800/50 rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<span className="font-medium text-white">{condition}</span>
{hasMarket && (
<span className="text-xs text-gray-400">{odds.betCount} bets</span>
)}
</div>
<div className="flex space-x-2 mb-3">
<button
onClick={() => {
setExistingBetAmount(10);
setShowExistingBetForm(prev => ({ ...prev, [`${game.id}-${condition}`]: 'yes' }));
}}
className="flex-1 bg-green-700/50 hover:bg-green-600/50 rounded p-2 text-center"
>
<div className="text-xs text-gray-400">YES</div>
<div className="text-lg font-bold text-green-400">{yesPrice.toFixed(2)}</div>
</button>
<button
onClick={() => {
setExistingBetAmount(10);
setShowExistingBetForm(prev => ({ ...prev, [`${game.id}-${condition}`]: 'no' }));
}}
className="flex-1 bg-red-700/50 hover:bg-red-600/50 rounded p-2 text-center"
>
<div className="text-xs text-gray-400">NO</div>
<div className="text-lg font-bold text-red-400">{noPrice.toFixed(2)}</div>
</button>
</div>
{/* Quick bet form */}
{showExistingBetForm[`${game.id}-${condition}`] && (
<div className="bg-gray-700/50 rounded p-3 mt-2">
<div className="flex items-center space-x-2 mb-2">
<input
type="number"
value={existingBetAmount}
onChange={(e) => setExistingBetAmount(Number(e.target.value))}
min="1"
max={currentUserData.balance}
className="flex-1 p-2 bg-gray-600 rounded text-white text-sm"
/>
<span className="text-xs text-gray-400">tokens</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => placeBetOnExisting(game.id, condition, showExistingBetForm[`${game.id}-${condition}`])}
className={`flex-1 py-2 rounded text-sm font-semibold ${
showExistingBetForm[`${game.id}-${condition}`] === 'yes'
? 'bg-green-600 hover:bg-green-500'
: 'bg-red-600 hover:bg-red-500'
}`}
>
Buy {showExistingBetForm[`${game.id}-${condition}`].toUpperCase()}
</button>
<button
onClick={() => setShowExistingBetForm(prev => ({ ...prev, [`${game.id}-${condition}`]: null }))}
className="px-3 py-2 bg-gray-600 hover:bg-gray-500 rounded text-sm"
>
</button>
</div>
</div>
)}
{hasMarket && (
<div className="text-xs text-center text-gray-400">
Market: {marketProbability}% Pool: {odds.totalAmount}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Custom Conditions */}
{conditions.filter(c => !c.includes('wins')).length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-semibold text-gray-400 mb-3">Custom Markets</h4>
<div className="space-y-2">
{conditions.filter(c => !c.includes('wins')).map((condition) => {
const { yesPrice, noPrice, marketProbability } = calculateTokenPrices(game.id, condition);
const odds = calculateOdds(game.id, condition);
return (
<div key={condition} className="bg-gray-800/50 rounded-lg p-3 flex justify-between items-center">
<div>
<span className="text-white">{condition}</span>
<div className="text-xs text-gray-400">
{odds.betCount} bets {marketProbability}% likely
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => placeBetOnExisting(game.id, condition, 'yes')}
className="px-3 py-1 bg-green-700/50 hover:bg-green-600/50 rounded text-sm"
>
YES {yesPrice.toFixed(2)}
</button>
<button
onClick={() => placeBetOnExisting(game.id, condition, 'no')}
className="px-3 py-1 bg-red-700/50 hover:bg-red-600/50 rounded text-sm"
>
NO {noPrice.toFixed(2)}
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Create Custom Bet */}
<div className="border-t border-green-800/30 pt-4">
{!showBetForm[game.id] ? (
<button
onClick={() => openBetForm(game.id)}
className="w-full bg-green-800/50 hover:bg-green-700/50 px-4 py-3 rounded flex items-center justify-center space-x-2"
>
<Plus size={18} />
<span>Create Custom Prediction</span>
</button>
) : (
<div className="bg-gray-800/50 rounded-lg p-4">
<div className="flex justify-between items-center mb-4">
<h4 className="font-semibold">New Prediction</h4>
<button onClick={() => closeBetForm(game.id)} className="text-gray-400 hover:text-white">
<X size={18} />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-1">What are you predicting?</label>
<input
type="text"
placeholder="e.g., Game ends in checkmate"
value={newBetData.condition}
onChange={(e) => setNewBetData(prev => ({ ...prev, condition: e.target.value }))}
className="w-full p-3 bg-gray-700 border border-gray-600 rounded text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">
How certain are you? {newBetData.certainty}%
</label>
<input
type="range"
min="1"
max="99"
value={newBetData.certainty}
onChange={(e) => setNewBetData(prev => ({ ...prev, certainty: Number(e.target.value) }))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500">
<span>Unlikely</span>
<span>50/50</span>
<span>Very Likely</span>
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Bet Amount</label>
<input
type="number"
min="1"
max={currentUserData.balance}
value={newBetData.betAmount}
onChange={(e) => setNewBetData(prev => ({ ...prev, betAmount: Number(e.target.value) }))}
className="w-full p-3 bg-gray-700 border border-gray-600 rounded text-white"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Bet Type</label>
<div className="flex space-x-3">
<button
onClick={() => setBetType('hedged')}
className={`flex-1 p-3 rounded border ${
betType === 'hedged'
? 'border-green-500 bg-green-900/30'
: 'border-gray-600 bg-gray-700'
}`}
>
<div className="font-semibold">Hedged</div>
<div className="text-xs text-gray-400">Split YES/NO tokens</div>
</button>
<button
onClick={() => setBetType('non-hedged')}
className={`flex-1 p-3 rounded border ${
betType === 'non-hedged'
? 'border-green-500 bg-green-900/30'
: 'border-gray-600 bg-gray-700'
}`}
>
<div className="font-semibold">All-In</div>
<div className="text-xs text-gray-400">100% one direction</div>
</button>
</div>
</div>
<button
onClick={prepareBet}
disabled={!newBetData.condition || newBetData.betAmount <= 0}
className="w-full bg-green-700 hover:bg-green-600 disabled:bg-gray-600 disabled:cursor-not-allowed px-4 py-3 rounded font-semibold"
>
Review Bet
</button>
</div>
</div>
)}
</div>
{/* Recent Bets on this game */}
{gameBets.length > 0 && (
<div className="mt-4 border-t border-green-800/30 pt-4">
<h4 className="text-sm font-semibold text-gray-400 mb-2">Recent Activity</h4>
<div className="space-y-2 max-h-40 overflow-y-auto">
{gameBets.slice(-5).reverse().map((bet, idx) => (
<div key={idx} className="text-xs bg-gray-800/30 rounded p-2 flex justify-between">
<span className="text-gray-300">
<span className="text-green-400">{bet.user_name}</span> bet on "{bet.condition}"
</span>
<span className="text-amber-400">{bet.amount} tokens</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
{/* Footer */}
<div className="mt-8 text-center text-xs text-gray-500">
<div>🍄 Mycelial Prediction Network 1% Platform Fee</div>
<div className="mt-1">Real-time via Pusher Data stored in Supabase</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,8 +1,16 @@
services: services:
betting-app: betting-app:
build: . build:
context: .
args:
- NEXT_PUBLIC_PUSHER_APP_KEY=f3c4d32f8247f83439e8
- NEXT_PUBLIC_PUSHER_CLUSTER=us2
- NEXT_PUBLIC_SUPABASE_URL=https://vzpxtpqpmjmrobsmdxoe.supabase.co
- NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ6cHh0cHFwbWptcm9ic21keG9lIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTAzNDc2MjMsImV4cCI6MjA2NTkyMzYyM30.B2Z_0hHbJad9gi0qY2RBGRAGc52NjIBehHxG_7_KnDU
container_name: betting-app-prod container_name: betting-app-prod
restart: unless-stopped restart: unless-stopped
env_file:
- .env.local
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.betting-app.rule=Host(`betting.jeffemmett.com`)" - "traefik.http.routers.betting-app.rule=Host(`betting.jeffemmett.com`)"

View File

@ -1,9 +0,0 @@
# Pusher Configuration
NEXT_PUBLIC_PUSHER_APP_KEY=f3c4d32f8247f83439e8
NEXT_PUBLIC_PUSHER_CLUSTER=us2
PUSHER_APP_ID=2010598
PUSHER_SECRET=3395fb093b110cec7bf2
# Supabase Configuration
NEXT_PUBLIC_SUPABASE_URL=https://vzpxtpqpmjmrobsmdxoe.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ6cHh0cHFwbWptcm9ic21keG9lIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTAzNDc2MjMsImV4cCI6MjA2NTkyMzYyM30.B2Z_0hHbJad9gi0qY2RBGRAGc52NjIBehHxG_7_KnDU

View File

@ -22,7 +22,7 @@ export default function Home() {
<title>Commons Hub Chess Tournament</title> <title>Commons Hub Chess Tournament</title>
<meta name="description" content="Official Mycelial-Betting Network for Chess Tournaments" /> <meta name="description" content="Official Mycelial-Betting Network for Chess Tournaments" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎲</text></svg>" />
</Head> </Head>
<ChessApp /> <ChessApp />
</> </>