diff --git a/.replit b/.replit index 641ca02..5b6f647 100644 --- a/.replit +++ b/.replit @@ -22,10 +22,6 @@ externalPort = 80 localPort = 8080 externalPort = 8080 -[[ports]] -localPort = 35463 -externalPort = 3000 - [workflows] runButton = "Project" diff --git a/aethex-bot/commands/shop-manage.js b/aethex-bot/commands/shop-manage.js new file mode 100644 index 0000000..017a48e --- /dev/null +++ b/aethex-bot/commands/shop-manage.js @@ -0,0 +1,535 @@ +const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } = require('discord.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('shop-manage') + .setDescription('Manage the XP shop (Admin only)') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(sub => + sub.setName('add') + .setDescription('Add a new shop item') + .addStringOption(opt => + opt.setName('name') + .setDescription('Item name') + .setRequired(true) + ) + .addIntegerOption(opt => + opt.setName('price') + .setDescription('Price in XP') + .setRequired(true) + .setMinValue(1) + ) + .addStringOption(opt => + opt.setName('type') + .setDescription('Item type') + .setRequired(true) + .addChoices( + { name: '🏅 Badge', value: 'badge' }, + { name: '🏷️ Title', value: 'title' }, + { name: '🎨 Background', value: 'background' }, + { name: '⚡ XP Booster', value: 'booster' }, + { name: '👑 Role', value: 'role' }, + { name: '✨ Special', value: 'special' } + ) + ) + .addStringOption(opt => + opt.setName('description') + .setDescription('Item description') + .setRequired(false) + ) + .addIntegerOption(opt => + opt.setName('stock') + .setDescription('Limited stock (leave empty for unlimited)') + .setRequired(false) + .setMinValue(1) + ) + .addIntegerOption(opt => + opt.setName('level_required') + .setDescription('Minimum level to purchase') + .setRequired(false) + .setMinValue(1) + ) + .addIntegerOption(opt => + opt.setName('prestige_required') + .setDescription('Minimum prestige to purchase') + .setRequired(false) + .setMinValue(1) + ) + .addRoleOption(opt => + opt.setName('role_required') + .setDescription('Role required to purchase') + .setRequired(false) + ) + .addRoleOption(opt => + opt.setName('grant_role') + .setDescription('Role to grant on purchase (for role type items)') + .setRequired(false) + ) + .addNumberOption(opt => + opt.setName('booster_multiplier') + .setDescription('XP multiplier for boosters (e.g., 1.5 for 50% bonus)') + .setRequired(false) + .setMinValue(1.1) + .setMaxValue(5) + ) + .addIntegerOption(opt => + opt.setName('booster_hours') + .setDescription('Duration in hours for boosters') + .setRequired(false) + .setMinValue(1) + .setMaxValue(168) + ) + .addStringOption(opt => + opt.setName('badge_emoji') + .setDescription('Emoji for badge items') + .setRequired(false) + ) + ) + .addSubcommand(sub => + sub.setName('remove') + .setDescription('Remove a shop item') + .addIntegerOption(opt => + opt.setName('item_id') + .setDescription('The ID of the item to remove') + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub.setName('edit') + .setDescription('Edit an existing shop item') + .addIntegerOption(opt => + opt.setName('item_id') + .setDescription('The ID of the item to edit') + .setRequired(true) + ) + .addStringOption(opt => + opt.setName('name') + .setDescription('New item name') + .setRequired(false) + ) + .addIntegerOption(opt => + opt.setName('price') + .setDescription('New price in XP') + .setRequired(false) + .setMinValue(1) + ) + .addStringOption(opt => + opt.setName('description') + .setDescription('New item description') + .setRequired(false) + ) + .addIntegerOption(opt => + opt.setName('stock') + .setDescription('New stock amount (-1 for unlimited)') + .setRequired(false) + .setMinValue(-1) + ) + .addBooleanOption(opt => + opt.setName('enabled') + .setDescription('Enable or disable the item') + .setRequired(false) + ) + ) + .addSubcommand(sub => + sub.setName('list') + .setDescription('List all shop items with details') + ) + .addSubcommand(sub => + sub.setName('stats') + .setDescription('View shop statistics') + ), + + async execute(interaction, supabase) { + if (!supabase) { + return interaction.reply({ content: 'Database not configured.', ephemeral: true }); + } + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'add': + return handleAdd(interaction, supabase); + case 'remove': + return handleRemove(interaction, supabase); + case 'edit': + return handleEdit(interaction, supabase); + case 'list': + return handleList(interaction, supabase); + case 'stats': + return handleStats(interaction, supabase); + } + } +}; + +async function handleAdd(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + const name = interaction.options.getString('name'); + const price = interaction.options.getInteger('price'); + const itemType = interaction.options.getString('type'); + const description = interaction.options.getString('description') || ''; + const stock = interaction.options.getInteger('stock') || null; + const levelRequired = interaction.options.getInteger('level_required') || 0; + const prestigeRequired = interaction.options.getInteger('prestige_required') || 0; + const roleRequired = interaction.options.getRole('role_required'); + const grantRole = interaction.options.getRole('grant_role'); + const boosterMultiplier = interaction.options.getNumber('booster_multiplier'); + const boosterHours = interaction.options.getInteger('booster_hours'); + const badgeEmoji = interaction.options.getString('badge_emoji'); + + const itemData = {}; + if (itemType === 'role' && grantRole) { + itemData.role_id = grantRole.id; + itemData.role_name = grantRole.name; + } + if (itemType === 'booster') { + itemData.multiplier = boosterMultiplier || 1.5; + itemData.duration_hours = boosterHours || 24; + } + if (itemType === 'badge' && badgeEmoji) { + itemData.emoji = badgeEmoji; + } + + try { + const { data: newItem, error } = await supabase + .from('shop_items') + .insert({ + guild_id: guildId, + name: name, + description: description, + item_type: itemType, + price: price, + stock: stock, + level_required: levelRequired, + prestige_required: prestigeRequired, + role_required: roleRequired?.id || null, + item_data: itemData, + enabled: true + }) + .select() + .single(); + + if (error) throw error; + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✅ Item Added to Shop') + .addFields( + { name: 'Item ID', value: `#${newItem.id}`, inline: true }, + { name: 'Name', value: name, inline: true }, + { name: 'Price', value: `${price.toLocaleString()} XP`, inline: true }, + { name: 'Type', value: itemType, inline: true }, + { name: 'Stock', value: stock ? stock.toString() : 'Unlimited', inline: true } + ); + + if (description) { + embed.addFields({ name: 'Description', value: description, inline: false }); + } + + if (levelRequired > 0 || prestigeRequired > 0) { + const reqs = []; + if (levelRequired > 0) reqs.push(`Level ${levelRequired}`); + if (prestigeRequired > 0) reqs.push(`Prestige ${prestigeRequired}`); + embed.addFields({ name: 'Requirements', value: reqs.join(', '), inline: true }); + } + + embed.setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Shop add error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to add item to shop.') + ] + }); + } +} + +async function handleRemove(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + const itemId = interaction.options.getInteger('item_id'); + + try { + const { data: item } = await supabase + .from('shop_items') + .select('*') + .eq('id', itemId) + .eq('guild_id', guildId) + .maybeSingle(); + + if (!item) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('Item not found.') + ] + }); + } + + await supabase + .from('shop_items') + .delete() + .eq('id', itemId) + .eq('guild_id', guildId); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('🗑️ Item Removed') + .setDescription(`**${item.name}** has been removed from the shop.`) + .setTimestamp() + ] + }); + } catch (error) { + console.error('Shop remove error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to remove item.') + ] + }); + } +} + +async function handleEdit(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + const itemId = interaction.options.getInteger('item_id'); + const newName = interaction.options.getString('name'); + const newPrice = interaction.options.getInteger('price'); + const newDescription = interaction.options.getString('description'); + const newStock = interaction.options.getInteger('stock'); + const enabled = interaction.options.getBoolean('enabled'); + + try { + const { data: item } = await supabase + .from('shop_items') + .select('*') + .eq('id', itemId) + .eq('guild_id', guildId) + .maybeSingle(); + + if (!item) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('Item not found.') + ] + }); + } + + const updates = { updated_at: new Date().toISOString() }; + const changes = []; + + if (newName !== null) { + updates.name = newName; + changes.push(`Name: ${item.name} → ${newName}`); + } + if (newPrice !== null) { + updates.price = newPrice; + changes.push(`Price: ${item.price} → ${newPrice} XP`); + } + if (newDescription !== null) { + updates.description = newDescription; + changes.push(`Description updated`); + } + if (newStock !== null) { + updates.stock = newStock === -1 ? null : newStock; + changes.push(`Stock: ${item.stock || 'Unlimited'} → ${newStock === -1 ? 'Unlimited' : newStock}`); + } + if (enabled !== null) { + updates.enabled = enabled; + changes.push(`Enabled: ${item.enabled} → ${enabled}`); + } + + if (changes.length === 0) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setDescription('No changes specified.') + ] + }); + } + + await supabase + .from('shop_items') + .update(updates) + .eq('id', itemId); + + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✏️ Item Updated') + .setDescription(`**${item.name}** has been updated.`) + .addFields({ name: 'Changes', value: changes.join('\n') }) + .setTimestamp() + ] + }); + } catch (error) { + console.error('Shop edit error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to edit item.') + ] + }); + } +} + +async function handleList(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + + try { + const { data: items, error } = await supabase + .from('shop_items') + .select('*') + .eq('guild_id', guildId) + .order('item_type') + .order('price'); + + if (error || !items || items.length === 0) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setTitle('📋 Shop Items') + .setDescription('No items in the shop yet.\n\nUse `/shop-manage add` to create items.') + ] + }); + } + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('📋 Shop Items (Admin View)') + .setDescription(`Total items: **${items.length}**`) + .setTimestamp(); + + const itemLines = items.map(item => { + const status = item.enabled ? '✅' : '❌'; + const stock = item.stock !== null ? ` [${item.stock} left]` : ''; + const reqs = []; + if (item.level_required > 0) reqs.push(`L${item.level_required}`); + if (item.prestige_required > 0) reqs.push(`P${item.prestige_required}`); + const reqStr = reqs.length > 0 ? ` (${reqs.join('/')})` : ''; + return `${status} \`#${item.id}\` **${item.name}** - ${item.price.toLocaleString()} XP${stock}${reqStr}`; + }); + + const chunks = []; + let current = ''; + for (const line of itemLines) { + if ((current + '\n' + line).length > 1000) { + chunks.push(current); + current = line; + } else { + current = current ? current + '\n' + line : line; + } + } + if (current) chunks.push(current); + + for (let i = 0; i < Math.min(chunks.length, 5); i++) { + embed.addFields({ + name: i === 0 ? 'Items' : '\u200b', + value: chunks[i], + inline: false + }); + } + + if (chunks.length > 5) { + embed.setFooter({ text: `Showing first ${Math.min(items.length, 25)} items` }); + } + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Shop list error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to list items.') + ] + }); + } +} + +async function handleStats(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + + try { + const { data: items } = await supabase + .from('shop_items') + .select('*') + .eq('guild_id', guildId); + + const { data: purchases } = await supabase + .from('user_inventory') + .select('*, shop_items(*)') + .eq('guild_id', guildId); + + const { data: balances } = await supabase + .from('user_balance') + .select('*') + .eq('guild_id', guildId); + + const totalItems = items?.length || 0; + const enabledItems = items?.filter(i => i.enabled).length || 0; + const totalPurchases = purchases?.length || 0; + const totalSpent = balances?.reduce((sum, b) => sum + (b.total_spent || 0), 0) || 0; + const uniqueBuyers = new Set(purchases?.map(p => p.user_id)).size; + + const itemCounts = {}; + for (const p of purchases || []) { + const name = p.shop_items?.name || 'Unknown'; + itemCounts[name] = (itemCounts[name] || 0) + 1; + } + + const topItems = Object.entries(itemCounts) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([name, count], i) => `${i + 1}. **${name}** (${count} purchases)`) + .join('\n'); + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('📊 Shop Statistics') + .addFields( + { name: '🛍️ Total Items', value: `${totalItems} (${enabledItems} active)`, inline: true }, + { name: '🛒 Total Purchases', value: totalPurchases.toString(), inline: true }, + { name: '👥 Unique Buyers', value: uniqueBuyers.toString(), inline: true }, + { name: '💰 Total XP Spent', value: totalSpent.toLocaleString(), inline: true } + ) + .setTimestamp(); + + if (topItems) { + embed.addFields({ name: '🏆 Top Items', value: topItems, inline: false }); + } + + await interaction.editReply({ embeds: [embed] }); + } catch (error) { + console.error('Shop stats error:', error); + await interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff0000) + .setDescription('Failed to get statistics.') + ] + }); + } +} diff --git a/aethex-bot/commands/shop.js b/aethex-bot/commands/shop.js new file mode 100644 index 0000000..dd3c5e7 --- /dev/null +++ b/aethex-bot/commands/shop.js @@ -0,0 +1,630 @@ +const { SlashCommandBuilder, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder } = require('discord.js'); + +const ITEMS_PER_PAGE = 5; + +const CATEGORY_INFO = { + badge: { emoji: '🏅', name: 'Badges', description: 'Collectible profile badges' }, + title: { emoji: '🏷️', name: 'Titles', description: 'Custom titles for your profile' }, + background: { emoji: '🎨', name: 'Backgrounds', description: 'Profile card backgrounds' }, + booster: { emoji: '⚡', name: 'Boosters', description: 'Temporary XP multipliers' }, + role: { emoji: '👑', name: 'Roles', description: 'Purchasable server roles' }, + special: { emoji: '✨', name: 'Special', description: 'Limited edition items' } +}; + +module.exports = { + data: new SlashCommandBuilder() + .setName('shop') + .setDescription('Browse and purchase items with your XP') + .addSubcommand(sub => + sub.setName('browse') + .setDescription('Browse available items') + .addStringOption(opt => + opt.setName('category') + .setDescription('Filter by category') + .setRequired(false) + .addChoices( + { name: '🏅 Badges', value: 'badge' }, + { name: '🏷️ Titles', value: 'title' }, + { name: '🎨 Backgrounds', value: 'background' }, + { name: '⚡ Boosters', value: 'booster' }, + { name: '👑 Roles', value: 'role' }, + { name: '✨ Special', value: 'special' } + ) + ) + ) + .addSubcommand(sub => + sub.setName('buy') + .setDescription('Purchase an item') + .addIntegerOption(opt => + opt.setName('item_id') + .setDescription('The ID of the item to purchase') + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub.setName('inventory') + .setDescription('View your purchased items') + ) + .addSubcommand(sub => + sub.setName('equip') + .setDescription('Equip an item from your inventory') + .addIntegerOption(opt => + opt.setName('item_id') + .setDescription('The ID of the item to equip') + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub.setName('balance') + .setDescription('Check your XP balance') + ), + + async execute(interaction, supabase) { + if (!supabase) { + return interaction.reply({ content: 'Database not configured.', ephemeral: true }); + } + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'browse': + return handleBrowse(interaction, supabase); + case 'buy': + return handleBuy(interaction, supabase); + case 'inventory': + return handleInventory(interaction, supabase); + case 'equip': + return handleEquip(interaction, supabase); + case 'balance': + return handleBalance(interaction, supabase); + } + } +}; + +async function getUserLink(supabase, discordId) { + const { data } = await supabase + .from('discord_links') + .select('user_id') + .eq('discord_id', discordId) + .maybeSingle(); + return data; +} + +async function getUserBalance(supabase, userId, guildId) { + const { data } = await supabase + .from('user_balance') + .select('*') + .eq('user_id', userId) + .eq('guild_id', guildId) + .maybeSingle(); + + if (!data) { + const { data: profile } = await supabase + .from('user_profiles') + .select('xp, total_xp_earned') + .eq('id', userId) + .maybeSingle(); + + const totalEarned = profile?.total_xp_earned || profile?.xp || 0; + + await supabase + .from('user_balance') + .insert({ user_id: userId, guild_id: guildId, balance: totalEarned, total_earned: totalEarned }); + + return { balance: totalEarned, total_spent: 0, total_earned: totalEarned }; + } + + return data; +} + +async function syncBalanceWithXP(supabase, userId, guildId) { + const { data: profile } = await supabase + .from('user_profiles') + .select('xp, total_xp_earned') + .eq('id', userId) + .maybeSingle(); + + const totalXpEarned = profile?.total_xp_earned || profile?.xp || 0; + + const { data: balance } = await supabase + .from('user_balance') + .select('*') + .eq('user_id', userId) + .eq('guild_id', guildId) + .maybeSingle(); + + if (balance) { + const newBalance = totalXpEarned - balance.total_spent; + await supabase + .from('user_balance') + .update({ balance: Math.max(0, newBalance), total_earned: totalXpEarned, updated_at: new Date().toISOString() }) + .eq('user_id', userId) + .eq('guild_id', guildId); + return { balance: Math.max(0, newBalance), total_spent: balance.total_spent, total_earned: totalXpEarned }; + } else { + await supabase + .from('user_balance') + .insert({ user_id: userId, guild_id: guildId, balance: totalXpEarned, total_earned: totalXpEarned }); + return { balance: totalXpEarned, total_spent: 0, total_earned: totalXpEarned }; + } +} + +async function handleBrowse(interaction, supabase) { + await interaction.deferReply(); + + const guildId = interaction.guildId; + const category = interaction.options.getString('category'); + + let query = supabase + .from('shop_items') + .select('*') + .eq('guild_id', guildId) + .eq('enabled', true) + .order('item_type') + .order('price'); + + if (category) { + query = query.eq('item_type', category); + } + + const { data: items, error } = await query; + + if (error || !items || items.length === 0) { + const embed = new EmbedBuilder() + .setColor(0xfbbf24) + .setTitle('🛒 Shop') + .setDescription(category + ? `No items available in the **${CATEGORY_INFO[category]?.name || category}** category.` + : 'No items available in the shop yet!\n\nAsk a server admin to add items with `/shop-manage add`.') + .setTimestamp(); + + return interaction.editReply({ embeds: [embed] }); + } + + const link = await getUserLink(supabase, interaction.user.id); + let balanceInfo = null; + if (link) { + balanceInfo = await syncBalanceWithXP(supabase, link.user_id, guildId); + } + + const groupedItems = {}; + for (const item of items) { + const cat = item.item_type || 'other'; + if (!groupedItems[cat]) groupedItems[cat] = []; + groupedItems[cat].push(item); + } + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('🛒 AeThex Shop') + .setDescription(balanceInfo + ? `Your Balance: **${balanceInfo.balance.toLocaleString()} XP** 💰` + : 'Link your account with `/verify` to purchase items!') + .setThumbnail(interaction.guild.iconURL({ size: 128 })) + .setFooter({ text: `Use /shop buy to purchase • ${items.length} items available` }) + .setTimestamp(); + + for (const [cat, catItems] of Object.entries(groupedItems)) { + const catInfo = CATEGORY_INFO[cat] || { emoji: '📦', name: cat }; + const itemList = catItems.slice(0, 5).map(item => { + const stockInfo = item.stock !== null ? ` (${item.stock} left)` : ''; + const reqInfo = []; + if (item.level_required > 0) reqInfo.push(`Lvl ${item.level_required}`); + if (item.prestige_required > 0) reqInfo.push(`P${item.prestige_required}`); + const reqStr = reqInfo.length > 0 ? ` [${reqInfo.join(', ')}]` : ''; + return `\`#${item.id}\` **${item.name}** - ${item.price.toLocaleString()} XP${stockInfo}${reqStr}`; + }).join('\n'); + + const moreItems = catItems.length > 5 ? `\n*...and ${catItems.length - 5} more*` : ''; + + embed.addFields({ + name: `${catInfo.emoji} ${catInfo.name}`, + value: itemList + moreItems, + inline: false + }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleBuy(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + const itemId = interaction.options.getInteger('item_id'); + + const link = await getUserLink(supabase, interaction.user.id); + if (!link) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('You need to link your account first! Use `/verify` to get started.') + ] + }); + } + + const { data: item, error: itemError } = await supabase + .from('shop_items') + .select('*') + .eq('id', itemId) + .eq('guild_id', guildId) + .eq('enabled', true) + .maybeSingle(); + + if (itemError || !item) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('Item not found or no longer available.') + ] + }); + } + + const { data: profile } = await supabase + .from('user_profiles') + .select('xp, prestige_level') + .eq('id', link.user_id) + .maybeSingle(); + + const currentLevel = Math.floor(Math.sqrt((profile?.xp || 0) / 100)); + const prestige = profile?.prestige_level || 0; + + if (item.level_required > 0 && currentLevel < item.level_required) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle('❌ Level Requirement Not Met') + .setDescription(`You need to be **Level ${item.level_required}** to purchase this item.\nYour current level: **${currentLevel}**`) + ] + }); + } + + if (item.prestige_required > 0 && prestige < item.prestige_required) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle('❌ Prestige Requirement Not Met') + .setDescription(`You need to be **Prestige ${item.prestige_required}** to purchase this item.\nYour current prestige: **${prestige}**`) + ] + }); + } + + if (item.role_required) { + if (!interaction.member.roles.cache.has(item.role_required)) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle('❌ Role Requirement Not Met') + .setDescription(`You need a specific role to purchase this item.`) + ] + }); + } + } + + const { data: existingItem } = await supabase + .from('user_inventory') + .select('*') + .eq('user_id', link.user_id) + .eq('guild_id', guildId) + .eq('item_id', itemId) + .maybeSingle(); + + if (existingItem && item.item_type !== 'booster') { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setTitle('Already Owned') + .setDescription(`You already own **${item.name}**!`) + ] + }); + } + + if (item.stock !== null && item.stock <= 0) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle('Out of Stock') + .setDescription(`**${item.name}** is currently out of stock.`) + ] + }); + } + + const balance = await syncBalanceWithXP(supabase, link.user_id, guildId); + + if (balance.balance < item.price) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setTitle('💸 Insufficient XP') + .setDescription(`You need **${item.price.toLocaleString()} XP** to purchase this item.\nYour balance: **${balance.balance.toLocaleString()} XP**\nYou need **${(item.price - balance.balance).toLocaleString()}** more XP!`) + ] + }); + } + + const newBalance = balance.balance - item.price; + const newTotalSpent = (balance.total_spent || 0) + item.price; + + await supabase + .from('user_balance') + .update({ balance: newBalance, total_spent: newTotalSpent, updated_at: new Date().toISOString() }) + .eq('user_id', link.user_id) + .eq('guild_id', guildId); + + let expiresAt = null; + if (item.item_type === 'booster' && item.item_data?.duration_hours) { + expiresAt = new Date(Date.now() + item.item_data.duration_hours * 60 * 60 * 1000).toISOString(); + } + + if (existingItem && item.item_type === 'booster') { + await supabase + .from('user_inventory') + .update({ quantity: existingItem.quantity + 1, purchased_at: new Date().toISOString(), expires_at: expiresAt }) + .eq('id', existingItem.id); + } else { + await supabase + .from('user_inventory') + .insert({ + user_id: link.user_id, + guild_id: guildId, + item_id: itemId, + quantity: 1, + expires_at: expiresAt + }); + } + + if (item.stock !== null) { + await supabase + .from('shop_items') + .update({ stock: item.stock - 1 }) + .eq('id', itemId); + } + + if (item.item_type === 'role' && item.item_data?.role_id) { + try { + await interaction.member.roles.add(item.item_data.role_id); + } catch (e) { + console.error('Failed to add role:', e.message); + } + } + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('✅ Purchase Successful!') + .setDescription(`You purchased **${item.name}**!`) + .addFields( + { name: 'Price Paid', value: `${item.price.toLocaleString()} XP`, inline: true }, + { name: 'New Balance', value: `${newBalance.toLocaleString()} XP`, inline: true } + ) + .setTimestamp(); + + if (expiresAt) { + embed.addFields({ name: '⏰ Expires', value: ``, inline: true }); + } + + if (item.item_type === 'badge' || item.item_type === 'title' || item.item_type === 'background') { + embed.setFooter({ text: 'Use /shop equip to equip this item!' }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleInventory(interaction, supabase) { + await interaction.deferReply(); + + const guildId = interaction.guildId; + + const link = await getUserLink(supabase, interaction.user.id); + if (!link) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('You need to link your account first! Use `/verify` to get started.') + ] + }); + } + + const { data: inventory, error } = await supabase + .from('user_inventory') + .select('*, shop_items(*)') + .eq('user_id', link.user_id) + .eq('guild_id', guildId); + + if (error || !inventory || inventory.length === 0) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setTitle('🎒 Your Inventory') + .setDescription('Your inventory is empty!\n\nBrowse the shop with `/shop browse` to find items.') + ] + }); + } + + const now = new Date(); + const validItems = inventory.filter(inv => { + if (inv.expires_at && new Date(inv.expires_at) < now) return false; + return inv.shop_items; + }); + + if (validItems.length === 0) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setTitle('🎒 Your Inventory') + .setDescription('Your inventory is empty or all items have expired!') + ] + }); + } + + const groupedItems = {}; + for (const inv of validItems) { + const cat = inv.shop_items.item_type || 'other'; + if (!groupedItems[cat]) groupedItems[cat] = []; + groupedItems[cat].push(inv); + } + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('🎒 Your Inventory') + .setDescription(`You own **${validItems.length}** items`) + .setThumbnail(interaction.user.displayAvatarURL({ size: 128 })) + .setFooter({ text: 'Use /shop equip to equip items' }) + .setTimestamp(); + + for (const [cat, catItems] of Object.entries(groupedItems)) { + const catInfo = CATEGORY_INFO[cat] || { emoji: '📦', name: cat }; + const itemList = catItems.map(inv => { + const item = inv.shop_items; + const equipped = inv.equipped ? ' ✅' : ''; + const qty = inv.quantity > 1 ? ` x${inv.quantity}` : ''; + const expires = inv.expires_at ? ` (expires )` : ''; + return `\`#${item.id}\` **${item.name}**${qty}${equipped}${expires}`; + }).join('\n'); + + embed.addFields({ + name: `${catInfo.emoji} ${catInfo.name}`, + value: itemList, + inline: false + }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleEquip(interaction, supabase) { + await interaction.deferReply({ ephemeral: true }); + + const guildId = interaction.guildId; + const itemId = interaction.options.getInteger('item_id'); + + const link = await getUserLink(supabase, interaction.user.id); + if (!link) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('You need to link your account first! Use `/verify` to get started.') + ] + }); + } + + const { data: invItem, error } = await supabase + .from('user_inventory') + .select('*, shop_items(*)') + .eq('user_id', link.user_id) + .eq('guild_id', guildId) + .eq('item_id', itemId) + .maybeSingle(); + + if (error || !invItem) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('You don\'t own this item!') + ] + }); + } + + if (invItem.expires_at && new Date(invItem.expires_at) < new Date()) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('This item has expired!') + ] + }); + } + + const itemType = invItem.shop_items.item_type; + if (!['badge', 'title', 'background'].includes(itemType)) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xfbbf24) + .setDescription('This item type cannot be equipped.') + ] + }); + } + + await supabase + .from('user_inventory') + .update({ equipped: false }) + .eq('user_id', link.user_id) + .eq('guild_id', guildId) + .in('item_id', + (await supabase + .from('shop_items') + .select('id') + .eq('guild_id', guildId) + .eq('item_type', itemType) + ).data?.map(i => i.id) || [] + ); + + const newEquipped = !invItem.equipped; + await supabase + .from('user_inventory') + .update({ equipped: newEquipped }) + .eq('id', invItem.id); + + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle(newEquipped ? '✅ Item Equipped!' : '❌ Item Unequipped') + .setDescription(`**${invItem.shop_items.name}** has been ${newEquipped ? 'equipped' : 'unequipped'}.`) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleBalance(interaction, supabase) { + await interaction.deferReply(); + + const guildId = interaction.guildId; + + const link = await getUserLink(supabase, interaction.user.id); + if (!link) { + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setColor(0xff6b6b) + .setDescription('You need to link your account first! Use `/verify` to get started.') + ] + }); + } + + const balance = await syncBalanceWithXP(supabase, link.user_id, guildId); + + const { data: profile } = await supabase + .from('user_profiles') + .select('xp, prestige_level') + .eq('id', link.user_id) + .maybeSingle(); + + const level = Math.floor(Math.sqrt((profile?.xp || 0) / 100)); + const prestige = profile?.prestige_level || 0; + + const embed = new EmbedBuilder() + .setColor(0x7c3aed) + .setTitle('💰 Your Balance') + .setThumbnail(interaction.user.displayAvatarURL({ size: 128 })) + .addFields( + { name: '💵 Available XP', value: `**${balance.balance.toLocaleString()}** XP`, inline: true }, + { name: '📈 Total Earned', value: `${balance.total_earned.toLocaleString()} XP`, inline: true }, + { name: '🛒 Total Spent', value: `${balance.total_spent.toLocaleString()} XP`, inline: true }, + { name: '📊 Level', value: `${level}${prestige > 0 ? ` (P${prestige})` : ''}`, inline: true } + ) + .setFooter({ text: 'Earn XP by chatting, reacting, and joining voice!' }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); +} diff --git a/aethex-bot/docs/MANUAL.md b/aethex-bot/docs/MANUAL.md index 4e364b9..70f300b 100644 --- a/aethex-bot/docs/MANUAL.md +++ b/aethex-bot/docs/MANUAL.md @@ -324,6 +324,53 @@ Create custom server achievements with various triggers: /achievements revoke @user [name] # Revoke achievement ``` +### XP Shop System + +Spend your hard-earned XP on cosmetics, perks, and exclusive items! + +**User Commands:** +``` +/shop browse [category] # Browse available items +/shop buy # Purchase an item +/shop inventory # View your purchased items +/shop equip # Equip a badge, title, or background +/shop balance # Check your XP balance +``` + +**Item Categories:** +| Category | Description | +|----------|-------------| +| 🏅 Badges | Collectible profile badges | +| 🏷️ Titles | Custom titles for your profile | +| 🎨 Backgrounds | Profile card backgrounds | +| ⚡ Boosters | Temporary XP multipliers | +| 👑 Roles | Purchasable server roles | +| ✨ Special | Limited edition items | + +**How It Works:** +- Your XP balance is based on your total earned XP minus what you've spent +- Purchasing an item deducts from your balance, not your level +- Some items may have level, prestige, or role requirements +- Limited stock items are first-come, first-served +- Boosters can be stacked and have expiration times + +**Admin Commands (shop-manage):** +``` +/shop-manage add [name] [price] [type] # Add new item +/shop-manage edit [options] # Edit existing item +/shop-manage remove # Remove item from shop +/shop-manage list # List all items (admin view) +/shop-manage stats # View shop statistics +``` + +**Adding Items:** +- `name`: Item display name +- `price`: Cost in XP +- `type`: badge, title, background, booster, role, or special +- Optional: description, stock limit, level/prestige requirements +- For roles: specify `grant_role` to give users a role on purchase +- For boosters: set `booster_multiplier` and `booster_hours` + --- ## Moderation Tools @@ -622,12 +669,20 @@ Opens a form to set: | `/post` | Create a community feed post | | `/help` | View categorized commands | -### Leveling Commands (3) +### Leveling Commands (5) | Command | Description | |---------|-------------| | `/rank` | View your level and XP | | `/daily` | Claim daily XP bonus | | `/badges` | View earned badges | +| `/prestige` | Prestige system | +| `/achievements` | Achievement management | + +### XP Shop Commands (2) +| Command | Description | +|---------|-------------| +| `/shop browse/buy/inventory/equip/balance` | XP shop for cosmetics and perks | +| `/shop-manage add/edit/remove/list/stats` | Admin shop management | ### Moderation Commands (5) | Command | Description |