Add a new coins system for users to earn and spend currency

Implement new API endpoints for retrieving and configuring guild coins, and add a dedicated section in the dashboard for coin-related information.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: 1a60159e-fbc2-40e0-8dc7-acade23267a8
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/bakeZwZ
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sirpiglr 2025-12-12 23:38:43 +00:00
parent dc7fb514b8
commit ede721af0f
2 changed files with 358 additions and 0 deletions

View file

@ -1158,6 +1158,9 @@
<div class="nav-item" data-page="titles">
<span class="nav-icon">🏷️</span> Titles
</div>
<div class="nav-item" data-page="coins">
<span class="nav-icon">🪙</span> Coins
</div>
</div>
<div class="nav-section" id="adminSection" style="display:none">
@ -1236,6 +1239,11 @@
<div class="stat-value" id="statAchievements">0</div>
<div class="stat-sub">earned</div>
</div>
<div class="stat-card">
<div class="stat-label" id="statCoinLabel">Coins</div>
<div class="stat-value" id="statCoins">0</div>
<div class="stat-sub">server currency</div>
</div>
</div>
<div class="card">
@ -1370,6 +1378,66 @@
</div>
</div>
<div id="page-coins" class="page hidden">
<div class="page-header">
<h1 class="page-title"><span class="text-gradient" id="coinPageTitle">Coins</span></h1>
<p class="page-subtitle">Server currency you can earn and spend</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label" id="coinBalanceLabel">Your Balance</div>
<div class="stat-value" id="coinBalance">0</div>
<div class="stat-sub" id="coinCurrencyName">Coins</div>
</div>
</div>
<div class="card" style="margin-top:1.5rem">
<div class="card-header">
<h3 class="card-title" id="coinLeaderboardTitle">Coin Leaderboard</h3>
</div>
<div class="card-body" id="coinLeaderboardBody">
<div class="empty-state">
<div class="empty-state-icon">🪙</div>
<p>No coin leaderboard data yet</p>
</div>
</div>
</div>
<div class="card" style="margin-top:1.5rem" id="coinAdminCard" style="display:none">
<div class="card-header">
<h3 class="card-title">Coin Settings (Admin)</h3>
</div>
<div class="card-body">
<form id="coinSettingsForm" onsubmit="saveCoinConfig(event)">
<div class="form-grid">
<div class="form-group">
<label for="coinName">Currency Name</label>
<input type="text" id="coinName" name="coin_name" value="Coins" class="form-input" placeholder="e.g., Coins, Gold, Credits">
</div>
<div class="form-group">
<label for="messageCoins">Coins per Message</label>
<input type="number" id="messageCoins" name="message_coins" min="0" max="100" value="1" class="form-input">
</div>
<div class="form-group">
<label for="dailyCoins">Daily Claim Coins</label>
<input type="number" id="dailyCoins" name="daily_coins" min="0" max="1000" value="50" class="form-input">
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="coinsEnabled" name="coins_enabled" checked>
<span>Coins Enabled</span>
</label>
</div>
</div>
<div style="margin-top:1.5rem">
<button type="submit" class="btn btn-primary">Save Coin Settings</button>
</div>
</form>
</div>
</div>
</div>
<div id="page-admin-xp" class="page hidden">
<div class="page-header">
<h1 class="page-title"><span class="text-gradient">XP Settings</span></h1>
@ -2204,6 +2272,7 @@
case 'shop': await loadShop(); break;
case 'inventory': await loadInventory(); break;
case 'titles': await loadTitles(); break;
case 'coins': await loadCoins(); break;
case 'admin-xp': await loadXpConfig(); break;
case 'admin-quests': await loadAdminQuests(); break;
case 'admin-achievements': await loadAdminAchievements(); break;
@ -2255,6 +2324,22 @@
document.getElementById('statVoice').textContent = Math.floor(voiceMinutes / 60) + 'h';
}
// Load coins for profile display
if (currentGuild) {
try {
const coinsRes = await fetch('/api/guild/' + currentGuild + '/coins');
if (coinsRes.ok) {
const coinsData = await coinsRes.json();
const statCoinsEl = document.getElementById('statCoins');
const statCoinLabelEl = document.getElementById('statCoinLabel');
if (statCoinsEl) statCoinsEl.textContent = (coinsData.coins || 0).toLocaleString();
if (statCoinLabelEl) statCoinLabelEl.textContent = coinsData.coinName || 'Coins';
}
} catch (ce) {
// Silently skip on error
}
}
} catch (e) {
console.error('Profile load error:', e);
}
@ -3476,6 +3561,120 @@
}
}
// Coins functions
async function loadCoins() {
if (!currentGuild) return;
try {
const res = await fetch('/api/guild/' + currentGuild + '/coins');
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
const coinName = data.coinName || 'Coins';
document.getElementById('coinPageTitle').textContent = coinName;
document.getElementById('coinBalance').textContent = (data.coins || 0).toLocaleString();
document.getElementById('coinCurrencyName').textContent = coinName;
document.getElementById('statCoins').textContent = (data.coins || 0).toLocaleString();
document.getElementById('statCoinLabel').textContent = coinName;
// Load leaderboard
loadCoinLeaderboard(coinName);
// Show admin card if user is admin
const adminCard = document.getElementById('coinAdminCard');
if (isCurrentGuildAdmin()) {
adminCard.style.display = 'block';
loadCoinConfig();
} else {
adminCard.style.display = 'none';
}
} catch (e) {
console.error('Failed to load coins:', e);
}
}
async function loadCoinLeaderboard(coinName) {
if (!currentGuild) return;
const container = document.getElementById('coinLeaderboardBody');
try {
const res = await fetch('/api/guild/' + currentGuild + '/coins/leaderboard?limit=10');
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
if (!data.leaderboard || data.leaderboard.length === 0) {
container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">🪙</div><p>No coin leaderboard data yet</p></div>';
return;
}
container.innerHTML = data.leaderboard.map((entry, idx) => `
<div class="leaderboard-item ${idx < 3 ? 'top-3' : ''}">
<div class="leaderboard-rank">${idx + 1}</div>
<div class="leaderboard-avatar" style="background:linear-gradient(135deg, var(--gradient-1), var(--gradient-2));display:flex;align-items:center;justify-content:center;color:#fff;font-weight:600">${(entry.user_id || '?').substring(0, 2).toUpperCase()}</div>
<span class="leaderboard-name">User ${entry.user_id?.substring(0, 8) || 'Unknown'}</span>
<span class="leaderboard-xp">${(entry.coins || 0).toLocaleString()} ${coinName || 'Coins'}</span>
</div>
`).join('');
} catch (e) {
container.innerHTML = '<div class="empty-state">Failed to load leaderboard</div>';
}
}
async function loadCoinConfig() {
if (!currentGuild) return;
try {
const res = await fetch('/api/guild/' + currentGuild + '/coins/config');
if (!res.ok) return;
const data = await res.json();
const config = data.config || {};
document.getElementById('coinName').value = config.coin_name || 'Coins';
document.getElementById('messageCoins').value = config.message_coins ?? 1;
document.getElementById('dailyCoins').value = config.daily_coins ?? 50;
document.getElementById('coinsEnabled').checked = config.coins_enabled !== false;
} catch (e) {
console.error('Failed to load coin config:', e);
}
}
async function saveCoinConfig(e) {
e.preventDefault();
if (!currentGuild) return;
const formData = {
coin_name: document.getElementById('coinName').value || 'Coins',
message_coins: parseInt(document.getElementById('messageCoins').value) || 1,
daily_coins: parseInt(document.getElementById('dailyCoins').value) || 50,
coins_enabled: document.getElementById('coinsEnabled').checked
};
try {
const res = await fetch('/api/guild/' + currentGuild + '/coins/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (res.ok) {
alert('Coin settings saved!');
loadCoins();
} else {
alert('Failed to save coin settings');
}
} catch (e) {
alert('Failed to save coin settings');
}
}
function isCurrentGuildAdmin() {
if (!currentUser || !currentGuild) return false;
const guild = currentUser.guilds?.find(g => g.id === currentGuild);
return guild?.isAdmin === true;
}
init();
</script>
</body>

View file

@ -1931,6 +1931,165 @@ function createWebServer(discordClient, supabase, options = {}) {
}
});
// Coins API endpoints
app.get('/api/guild/:guildId/coins', async (req, res) => {
if (!supabase) return res.status(503).json({ error: 'Database not available' });
const userId = req.session.user?.id;
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
const { guildId } = req.params;
try {
const { data: link } = await supabase
.from('discord_links')
.select('user_id')
.eq('discord_id', userId)
.maybeSingle();
if (!link) {
return res.json({ coins: 0, coinName: 'Coins' });
}
const { data: stats } = await supabase
.from('user_stats')
.select('coins')
.eq('user_id', link.user_id)
.eq('guild_id', guildId)
.maybeSingle();
const { data: config } = await supabase
.from('xp_config')
.select('coin_name')
.eq('guild_id', guildId)
.maybeSingle();
res.json({
coins: stats?.coins || 0,
coinName: config?.coin_name || 'Coins'
});
} catch (error) {
console.error('Failed to fetch coins:', error);
res.status(500).json({ error: 'Failed to load coins' });
}
});
app.get('/api/guild/:guildId/coins/config', async (req, res) => {
if (!supabase) return res.status(503).json({ error: 'Database not available' });
const userId = req.session.user?.id;
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
const { guildId } = req.params;
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
if (!userGuild || !userGuild.isAdmin) {
return res.status(403).json({ error: 'No admin access' });
}
try {
const { data: config } = await supabase
.from('xp_config')
.select('coins_enabled, message_coins, daily_coins, coin_name')
.eq('guild_id', guildId)
.maybeSingle();
res.json({
config: config || { coins_enabled: true, message_coins: 1, daily_coins: 50, coin_name: 'Coins' }
});
} catch (error) {
console.error('Failed to fetch coin config:', error);
res.status(500).json({ error: 'Failed to load coin config' });
}
});
app.post('/api/guild/:guildId/coins/config', async (req, res) => {
if (!supabase) return res.status(503).json({ error: 'Database not available' });
const userId = req.session.user?.id;
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
const { guildId } = req.params;
const userGuild = req.session.user.guilds.find(g => g.id === guildId);
if (!userGuild || !userGuild.isAdmin) {
return res.status(403).json({ error: 'No admin access' });
}
const { coins_enabled, message_coins, daily_coins, coin_name } = req.body;
// Validate inputs
const validMessageCoins = Math.max(0, Math.min(100, parseInt(message_coins) || 1));
const validDailyCoins = Math.max(0, Math.min(1000, parseInt(daily_coins) || 50));
const validCoinName = (coin_name || 'Coins').toString().substring(0, 32);
const validCoinsEnabled = coins_enabled === true;
try {
const { data: existing } = await supabase
.from('xp_config')
.select('guild_id')
.eq('guild_id', guildId)
.maybeSingle();
const coinData = {
coins_enabled: validCoinsEnabled,
message_coins: validMessageCoins,
daily_coins: validDailyCoins,
coin_name: validCoinName,
updated_at: new Date().toISOString()
};
if (existing) {
const { error } = await supabase
.from('xp_config')
.update(coinData)
.eq('guild_id', guildId);
if (error) throw error;
} else {
const { error } = await supabase
.from('xp_config')
.insert({ guild_id: guildId, ...coinData });
if (error) throw error;
}
res.json({ success: true });
} catch (error) {
console.error('Failed to save coin config:', error);
res.status(500).json({ error: 'Failed to save coin settings' });
}
});
app.get('/api/guild/:guildId/coins/leaderboard', async (req, res) => {
if (!supabase) return res.status(503).json({ error: 'Database not available' });
const { guildId } = req.params;
const { limit = 50 } = req.query;
try {
const { data: leaderboard } = await supabase
.from('user_stats')
.select('user_id, coins')
.eq('guild_id', guildId)
.gt('coins', 0)
.order('coins', { ascending: false })
.limit(parseInt(limit));
const { data: config } = await supabase
.from('xp_config')
.select('coin_name')
.eq('guild_id', guildId)
.maybeSingle();
res.json({
leaderboard: leaderboard || [],
coinName: config?.coin_name || 'Coins'
});
} catch (error) {
console.error('Failed to fetch coin leaderboard:', error);
res.status(500).json({ error: 'Failed to load leaderboard' });
}
});
app.get('/health', (req, res) => {
res.json({
status: 'ok',