Introduces a new XP shop system with user-facing commands (/shop) and admin management commands (/shop-manage). Also updates the manual documentation with details on the shop system and commands. Includes database table creation for shop items, user inventory, and user balances. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aed2e46d-25bb-4b73-81a1-bb9e8437c261 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 9c137adc-a98f-45ee-bd82-83bfe97bdcab Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3bdfff67-975a-46ad-9845-fbb6b4a4c4b5/aed2e46d-25bb-4b73-81a1-bb9e8437c261/zRLxuQq Replit-Helium-Checkpoint-Created: true
535 lines
16 KiB
JavaScript
535 lines
16 KiB
JavaScript
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.')
|
|
]
|
|
});
|
|
}
|
|
}
|