diff --git a/src/modules/admin/item_wizard.ts b/src/modules/admin/item_wizard.ts index 922cf28..c55bfcf 100644 --- a/src/modules/admin/item_wizard.ts +++ b/src/modules/admin/item_wizard.ts @@ -1,51 +1,17 @@ -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - ModalBuilder, - StringSelectMenuBuilder, - TextInputBuilder, - TextInputStyle, - type Interaction, - type MessageActionRowComponentBuilder -} from "discord.js"; +import { type Interaction } from "discord.js"; import { items } from "@/db/schema"; import { DrizzleClient } from "@/lib/DrizzleClient"; import type { ItemUsageData, ItemEffect } from "@/lib/types"; -import { createBaseEmbed } from "@lib/embeds"; +import { getItemWizardEmbed, getItemTypeSelection, getEffectTypeSelection, getDetailsModal, getEconomyModal, getVisualsModal, getEffectConfigModal } from "./item_wizard.view"; +import type { DraftItem } from "./item_wizard.types"; // --- Types --- -export interface DraftItem { - name: string; - description: string; - rarity: string; - type: string; - price: number | null; - iconUrl: string; - imageUrl: string; - usageData: ItemUsageData; - // Temporary state for effect adding flow - pendingEffectType?: string; -} + // --- State --- const draftSession = new Map(); -const getItemTypeOptions = () => [ - { label: "Material", value: "MATERIAL", description: "Used for crafting or trading" }, - { label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" }, - { label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" }, - { label: "Quest Item", value: "QUEST", description: "Required for quests" }, -]; -const getEffectTypeOptions = () => [ - { label: "Add XP", value: "ADD_XP", description: "Gives XP to the user" }, - { label: "Add Balance", value: "ADD_BALANCE", description: "Gives currency to the user" }, - { label: "Reply Message", value: "REPLY_MESSAGE", description: "Bot replies with a message" }, - { label: "XP Boost", value: "XP_BOOST", description: "Temporarily boosts XP gain" }, - { label: "Temp Role", value: "TEMP_ROLE", description: "Gives a temporary role" }, - { label: "Color Role", value: "COLOR_ROLE", description: "Equips a permanent color role (swaps)" }, -]; // --- Render --- export const renderWizard = (userId: string, isDraft = true) => { @@ -66,43 +32,8 @@ export const renderWizard = (userId: string, isDraft = true) => { draftSession.set(userId, draft); } - const embed = createBaseEmbed(`🛠️ Item Creator: ${draft.name}`, undefined, "Blue") - .addFields( - { name: "General", value: `**Type:** ${draft.type}\n**Rarity:** ${draft.rarity}\n**Desc:** ${draft.description}`, inline: true }, - { name: "Economy", value: `**Price:** ${draft.price ? `${draft.price} 🪙` : "Not for sale"}`, inline: true }, - { name: "Visuals", value: `**Icon:** ${draft.iconUrl ? "✅ Set" : "❌"}\n**Image:** ${draft.imageUrl ? "✅ Set" : "❌"}`, inline: true }, - { name: "Usage", value: `**Consume:** ${draft.usageData.consume ? "✅ Yes" : "❌ No"}`, inline: true }, - ); - - // Effects Display - if (draft.usageData.effects.length > 0) { - const effecto = draft.usageData.effects.map((e, i) => `${i + 1}. **${e.type}**: ${JSON.stringify(e)}`).join("\n"); - embed.addFields({ name: "Usage Effects", value: effecto.substring(0, 1024) }); - } else { - embed.addFields({ name: "Usage Effects", value: "None" }); - } - - if (draft.imageUrl) embed.setImage(draft.imageUrl); - if (draft.iconUrl) embed.setThumbnail(draft.iconUrl); - - // Components - const row1 = new ActionRowBuilder() - .addComponents( - new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"), - new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"), - new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"), - new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"), - ); - - const row2 = new ActionRowBuilder() - .addComponents( - new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"), - new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"), - new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"), - new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️") - ); - - return { embeds: [embed], components: [row1, row2] }; + const { embeds, components } = getItemWizardEmbed(draft); + return { embeds, components }; }; // --- Handler --- @@ -151,12 +82,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => { // 1. Details Modal if (interaction.customId === "createitem_details") { if (!interaction.isButton()) return; - const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details"); - modal.addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(draft.name).setStyle(TextInputStyle.Short).setRequired(true)), - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(draft.description).setStyle(TextInputStyle.Paragraph).setRequired(false)), - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(draft.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true)) - ); + const modal = getDetailsModal(draft); await interaction.showModal(modal); return; } @@ -164,10 +90,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => { // 2. Economy Modal if (interaction.customId === "createitem_economy") { if (!interaction.isButton()) return; - const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy"); - modal.addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(draft.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true)) - ); + const modal = getEconomyModal(draft); await interaction.showModal(modal); return; } @@ -175,11 +98,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => { // 3. Visuals Modal if (interaction.customId === "createitem_visuals") { if (!interaction.isButton()) return; - const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals"); - modal.addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(draft.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)), - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(draft.imageUrl).setStyle(TextInputStyle.Short).setRequired(false)) - ); + const modal = getVisualsModal(draft); await interaction.showModal(modal); return; } @@ -187,10 +106,8 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => { // 4. Type Toggle (Start Select Menu) if (interaction.customId === "createitem_type_toggle") { if (!interaction.isButton()) return; - const row = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions()) - ); - await interaction.update({ components: [row as any] }); // Temporary view + const { components } = getItemTypeSelection(); + await interaction.update({ components }); // Temporary view return; } @@ -209,16 +126,15 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => { // 5. Add Effect Flow if (interaction.customId === "createitem_addeffect_start") { if (!interaction.isButton()) return; - const row = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions()) - ); - await interaction.update({ components: [row as any] }); + const { components } = getEffectTypeSelection(); + await interaction.update({ components }); return; } if (interaction.customId === "createitem_select_effect_type") { if (!interaction.isStringSelectMenu()) return; const effectType = interaction.values[0]; + if (!effectType) return; draft.pendingEffectType = effectType; // Immediately show modal for data collection @@ -226,28 +142,7 @@ export const handleItemWizardInteraction = async (interaction: Interaction) => { // But we shouldn't update the message AND show modal. We must pick one. // We will show modal. The message remains in "Select Effect" state until modal submit re-renders it. - let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`); - - if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") { - modal.addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100"))); - } else if (effectType === "REPLY_MESSAGE") { - modal.addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true))); - } else if (effectType === "XP_BOOST") { - modal.addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)), - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600")) - ); - } else if (effectType === "TEMP_ROLE") { - modal.addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)), - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600")) - ); - } else if (effectType === "COLOR_ROLE") { - modal.addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)) - ); - } - + const modal = getEffectConfigModal(effectType); await interaction.showModal(modal); return; } diff --git a/src/modules/admin/item_wizard.types.ts b/src/modules/admin/item_wizard.types.ts new file mode 100644 index 0000000..2317204 --- /dev/null +++ b/src/modules/admin/item_wizard.types.ts @@ -0,0 +1,14 @@ +import type { ItemUsageData } from "@/lib/types"; + +export interface DraftItem { + name: string; + description: string; + rarity: string; + type: string; + price: number | null; + iconUrl: string; + imageUrl: string; + usageData: ItemUsageData; + // Temporary state for effect adding flow + pendingEffectType?: string; +} diff --git a/src/modules/admin/item_wizard.view.ts b/src/modules/admin/item_wizard.view.ts new file mode 100644 index 0000000..8de5d19 --- /dev/null +++ b/src/modules/admin/item_wizard.view.ts @@ -0,0 +1,134 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ModalBuilder, + StringSelectMenuBuilder, + TextInputBuilder, + TextInputStyle, + type MessageActionRowComponentBuilder +} from "discord.js"; +import { createBaseEmbed } from "@lib/embeds"; +import type { DraftItem } from "./item_wizard.types"; + +const getItemTypeOptions = () => [ + { label: "Material", value: "MATERIAL", description: "Used for crafting or trading" }, + { label: "Consumable", value: "CONSUMABLE", description: "Can be used to gain effects" }, + { label: "Equipment", value: "EQUIPMENT", description: "Can be equipped (Not yet implemented)" }, + { label: "Quest Item", value: "QUEST", description: "Required for quests" }, +]; + +const getEffectTypeOptions = () => [ + { label: "Add XP", value: "ADD_XP", description: "Gives XP to the user" }, + { label: "Add Balance", value: "ADD_BALANCE", description: "Gives currency to the user" }, + { label: "Reply Message", value: "REPLY_MESSAGE", description: "Bot replies with a message" }, + { label: "XP Boost", value: "XP_BOOST", description: "Temporarily boosts XP gain" }, + { label: "Temp Role", value: "TEMP_ROLE", description: "Gives a temporary role" }, + { label: "Color Role", value: "COLOR_ROLE", description: "Equips a permanent color role (swaps)" }, +]; + +export const getItemWizardEmbed = (draft: DraftItem) => { + const embed = createBaseEmbed(`🛠️ Item Creator: ${draft.name}`, undefined, "Blue") + .addFields( + { name: "General", value: `**Type:** ${draft.type}\n**Rarity:** ${draft.rarity}\n**Desc:** ${draft.description}`, inline: true }, + { name: "Economy", value: `**Price:** ${draft.price ? `${draft.price} 🪙` : "Not for sale"}`, inline: true }, + { name: "Visuals", value: `**Icon:** ${draft.iconUrl ? "✅ Set" : "❌"}\n**Image:** ${draft.imageUrl ? "✅ Set" : "❌"}`, inline: true }, + { name: "Usage", value: `**Consume:** ${draft.usageData.consume ? "✅ Yes" : "❌ No"}`, inline: true }, + ); + + // Effects Display + if (draft.usageData.effects.length > 0) { + const effecto = draft.usageData.effects.map((e, i) => `${i + 1}. **${e.type}**: ${JSON.stringify(e)}`).join("\n"); + embed.addFields({ name: "Usage Effects", value: effecto.substring(0, 1024) }); + } else { + embed.addFields({ name: "Usage Effects", value: "None" }); + } + + if (draft.imageUrl) embed.setImage(draft.imageUrl); + if (draft.iconUrl) embed.setThumbnail(draft.iconUrl); + + // Components + const row1 = new ActionRowBuilder() + .addComponents( + new ButtonBuilder().setCustomId("createitem_details").setLabel("Edit Details").setStyle(ButtonStyle.Secondary).setEmoji("📝"), + new ButtonBuilder().setCustomId("createitem_economy").setLabel("Edit Economy").setStyle(ButtonStyle.Secondary).setEmoji("💰"), + new ButtonBuilder().setCustomId("createitem_visuals").setLabel("Edit Visuals").setStyle(ButtonStyle.Secondary).setEmoji("🖼️"), + new ButtonBuilder().setCustomId("createitem_type_toggle").setLabel("Change Type").setStyle(ButtonStyle.Secondary).setEmoji("🔄"), + ); + + const row2 = new ActionRowBuilder() + .addComponents( + new ButtonBuilder().setCustomId("createitem_addeffect_start").setLabel("Add Effect").setStyle(ButtonStyle.Primary).setEmoji("✨"), + new ButtonBuilder().setCustomId("createitem_toggle_consume").setLabel(`Consume: ${draft.usageData.consume ? "ON" : "OFF"}`).setStyle(ButtonStyle.Secondary).setEmoji("🔄"), + new ButtonBuilder().setCustomId("createitem_save").setLabel("Save Item").setStyle(ButtonStyle.Success).setEmoji("💾"), + new ButtonBuilder().setCustomId("createitem_cancel").setLabel("Cancel").setStyle(ButtonStyle.Danger).setEmoji("✖️") + ); + + return { embeds: [embed], components: [row1, row2] }; +}; + +export const getItemTypeSelection = () => { + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder().setCustomId("createitem_select_type").setPlaceholder("Select Item Type").addOptions(getItemTypeOptions()) + ); + return { components: [row] }; +}; + +export const getEffectTypeSelection = () => { + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder().setCustomId("createitem_select_effect_type").setPlaceholder("Select Effect Type").addOptions(getEffectTypeOptions()) + ); + return { components: [row] }; +}; + +export const getDetailsModal = (current: DraftItem) => { + const modal = new ModalBuilder().setCustomId("createitem_modal_details").setTitle("Edit Details"); + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("name").setLabel("Name").setValue(current.name).setStyle(TextInputStyle.Short).setRequired(true)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("desc").setLabel("Description").setValue(current.description).setStyle(TextInputStyle.Paragraph).setRequired(false)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("rarity").setLabel("Rarity").setValue(current.rarity).setStyle(TextInputStyle.Short).setPlaceholder("Common, Rare, Legendary...").setRequired(true)) + ); + return modal; +}; + +export const getEconomyModal = (current: DraftItem) => { + const modal = new ModalBuilder().setCustomId("createitem_modal_economy").setTitle("Edit Economy"); + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("price").setLabel("Price (0 for not for sale)").setValue(current.price?.toString() || "0").setStyle(TextInputStyle.Short).setRequired(true)) + ); + return modal; +}; + +export const getVisualsModal = (current: DraftItem) => { + const modal = new ModalBuilder().setCustomId("createitem_modal_visuals").setTitle("Edit Visuals"); + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("icon").setLabel("Icon URL (Emoji or Link)").setValue(current.iconUrl).setStyle(TextInputStyle.Short).setRequired(false)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("image").setLabel("Image URL").setValue(current.imageUrl).setStyle(TextInputStyle.Short).setRequired(false)) + ); + return modal; +}; + +export const getEffectConfigModal = (effectType: string) => { + let modal = new ModalBuilder().setCustomId("createitem_modal_effect").setTitle(`Config ${effectType}`); + + if (effectType === "ADD_XP" || effectType === "ADD_BALANCE") { + modal.addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("amount").setLabel("Amount").setStyle(TextInputStyle.Short).setRequired(true).setPlaceholder("100"))); + } else if (effectType === "REPLY_MESSAGE") { + modal.addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("message").setLabel("Message").setStyle(TextInputStyle.Paragraph).setRequired(true))); + } else if (effectType === "XP_BOOST") { + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("multiplier").setLabel("Multiplier (e.g. 1.5)").setStyle(TextInputStyle.Short).setRequired(true)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600")) + ); + } else if (effectType === "TEMP_ROLE") { + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)), + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("duration").setLabel("Duration (Seconds)").setStyle(TextInputStyle.Short).setRequired(true).setValue("3600")) + ); + } else if (effectType === "COLOR_ROLE") { + modal.addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder().setCustomId("role_id").setLabel("Role ID").setStyle(TextInputStyle.Short).setRequired(true)) + ); + } + return modal; +}; diff --git a/src/modules/economy/lootdrop.interaction.ts b/src/modules/economy/lootdrop.interaction.ts index c13fd4c..6ed6f43 100644 --- a/src/modules/economy/lootdrop.interaction.ts +++ b/src/modules/economy/lootdrop.interaction.ts @@ -1,6 +1,7 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle } from "discord.js"; +import { ButtonInteraction } from "discord.js"; import { lootdropService } from "./lootdrop.service"; -import { createErrorEmbed, createSuccessEmbed, createBaseEmbed } from "@/lib/embeds"; +import { createErrorEmbed } from "@/lib/embeds"; +import { getLootdropClaimedMessage } from "./lootdrop.view"; export async function handleLootdropInteraction(interaction: ButtonInteraction) { if (interaction.customId === "lootdrop_claim") { @@ -17,21 +18,14 @@ export async function handleLootdropInteraction(interaction: ButtonInteraction) const originalEmbed = interaction.message.embeds[0]; if (!originalEmbed) return; - const newEmbed = createBaseEmbed(originalEmbed.title || "💰 LOOTDROP!", `✅ Claimed by <@${interaction.user.id}> for **${result.amount} ${result.currency}**!`, "#00FF00"); + const { embeds, components } = getLootdropClaimedMessage( + originalEmbed.title || "💰 LOOTDROP!", + interaction.user.id, + result.amount || 0, + result.currency || "Coins" + ); - // Disable button - // We reconstruct the button using builders for safety - const newRow = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId("lootdrop_claim_disabled") - .setLabel("CLAIMED") - .setStyle(ButtonStyle.Secondary) - .setEmoji("✅") - .setDisabled(true) - ); - - await interaction.message.edit({ embeds: [newEmbed], components: [newRow] }); + await interaction.message.edit({ embeds, components }); } else { await interaction.editReply({ diff --git a/src/modules/economy/lootdrop.service.ts b/src/modules/economy/lootdrop.service.ts index 54416d4..93fe5c0 100644 --- a/src/modules/economy/lootdrop.service.ts +++ b/src/modules/economy/lootdrop.service.ts @@ -1,8 +1,9 @@ -import { Message, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType } from "discord.js"; +import { Message, TextChannel } from "discord.js"; +import { getLootdropMessage } from "./lootdrop.view"; import { config } from "@/lib/config"; import { economyService } from "./economy.service"; -import { createBaseEmbed } from "@lib/embeds"; + import { lootdrops } from "@/db/schema"; import { DrizzleClient } from "@/lib/DrizzleClient"; @@ -92,19 +93,10 @@ class LootdropService { const reward = Math.floor(Math.random() * (max - min + 1)) + min; const currency = config.lootdrop.reward.currency; - const embed = createBaseEmbed("💰 LOOTDROP!", `A lootdrop has appeared! Click the button below to claim **${reward} ${currency}**!`, "#FFD700"); - - const claimButton = new ButtonBuilder() - .setCustomId("lootdrop_claim") - .setLabel("CLAIM REWARD") - .setStyle(ButtonStyle.Success) - .setEmoji("💸"); - - const row = new ActionRowBuilder() - .addComponents(claimButton); + const { embeds, components } = getLootdropMessage(reward, currency); try { - const message = await channel.send({ embeds: [embed], components: [row] }); + const message = await channel.send({ embeds, components }); // Persist to DB await DrizzleClient.insert(lootdrops).values({ diff --git a/src/modules/economy/lootdrop.view.ts b/src/modules/economy/lootdrop.view.ts new file mode 100644 index 0000000..030418c --- /dev/null +++ b/src/modules/economy/lootdrop.view.ts @@ -0,0 +1,41 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; +import { createBaseEmbed } from "@lib/embeds"; + +export function getLootdropMessage(reward: number, currency: string) { + const embed = createBaseEmbed( + "💰 LOOTDROP!", + `A lootdrop has appeared! Click the button below to claim **${reward} ${currency}**!`, + "#FFD700" + ); + + const claimButton = new ButtonBuilder() + .setCustomId("lootdrop_claim") + .setLabel("CLAIM REWARD") + .setStyle(ButtonStyle.Success) + .setEmoji("💸"); + + const row = new ActionRowBuilder() + .addComponents(claimButton); + + return { embeds: [embed], components: [row] }; +} + +export function getLootdropClaimedMessage(originalTitle: string, userId: string, amount: number, currency: string) { + const newEmbed = createBaseEmbed( + originalTitle || "💰 LOOTDROP!", + `✅ Claimed by <@${userId}> for **${amount} ${currency}**!`, + "#00FF00" + ); + + const newRow = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId("lootdrop_claim_disabled") + .setLabel("CLAIMED") + .setStyle(ButtonStyle.Secondary) + .setEmoji("✅") + .setDisabled(true) + ); + + return { embeds: [newEmbed], components: [newRow] }; +} diff --git a/src/modules/trade/trade.interaction.ts b/src/modules/trade/trade.interaction.ts index b149b54..4ed3d7c 100644 --- a/src/modules/trade/trade.interaction.ts +++ b/src/modules/trade/trade.interaction.ts @@ -1,24 +1,18 @@ import { + type Interaction, ButtonInteraction, ModalSubmitInteraction, StringSelectMenuInteraction, - type Interaction, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - StringSelectMenuBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, ThreadChannel, TextChannel, EmbedBuilder } from "discord.js"; import { TradeService } from "./trade.service"; import { inventoryService } from "@/modules/inventory/inventory.service"; -import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed, createBaseEmbed } from "@lib/embeds"; +import { createErrorEmbed, createWarningEmbed, createSuccessEmbed, createInfoEmbed } from "@lib/embeds"; +import { getTradeDashboard, getTradeMoneyModal, getItemSelectMenu, getTradeCompletedEmbed } from "./trade.view"; + -const EMBED_COLOR = 0xFFD700; // Gold export async function handleTradeInteraction(interaction: Interaction) { if (!interaction.isButton() && !interaction.isStringSelectMenu() && !interaction.isModalSubmit()) return; @@ -91,20 +85,7 @@ async function handleLock(interaction: ButtonInteraction | StringSelectMenuInter async function handleAddMoneyClick(interaction: Interaction) { if (!interaction.isButton()) return; - const modal = new ModalBuilder() - .setCustomId('trade_money_modal') - .setTitle('Add Money'); - - const input = new TextInputBuilder() - .setCustomId('amount') - .setLabel("Amount to trade") - .setStyle(TextInputStyle.Short) - .setPlaceholder("100") - .setRequired(true); - - const row = new ActionRowBuilder().addComponents(input); - modal.addComponents(row); - + const modal = getTradeMoneyModal(); await interaction.showModal(modal); } @@ -131,17 +112,11 @@ async function handleAddItemClick(interaction: ButtonInteraction, threadId: stri const options = inventory.slice(0, 25).map(entry => ({ label: `${entry.item.name} (${entry.quantity})`, value: entry.item.id.toString(), - description: `Rarity: ${entry.item.rarity}` + description: `Rarity: ${entry.item.rarity} ` })); - const select = new StringSelectMenuBuilder() - .setCustomId('trade_select_item') - .setPlaceholder('Select an item to add') - .addOptions(options); - - const row = new ActionRowBuilder().addComponents(select); - - await interaction.reply({ content: "Select an item to add:", components: [row], ephemeral: true }); + const { components } = getItemSelectMenu(options, 'trade_select_item', 'Select an item to add'); + await interaction.reply({ content: "Select an item to add:", components, ephemeral: true }); } async function handleItemSelect(interaction: StringSelectMenuInteraction, threadId: string) { @@ -175,14 +150,8 @@ async function handleRemoveItemClick(interaction: ButtonInteraction, threadId: s value: i.id.toString(), })); - const select = new StringSelectMenuBuilder() - .setCustomId('trade_remove_item_select') - .setPlaceholder('Select an item to remove') - .addOptions(options); - - const row = new ActionRowBuilder().addComponents(select); - - await interaction.reply({ content: "Select an item to remove:", components: [row], ephemeral: true }); + const { components } = getItemSelectMenu(options, 'trade_remove_item_select', 'Select an item to remove'); + await interaction.reply({ content: "Select an item to remove:", components, ephemeral: true }); } async function handleRemoveItemSelect(interaction: StringSelectMenuInteraction, threadId: string) { @@ -207,13 +176,7 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s // Execute Trade try { await TradeService.executeTrade(threadId); - const embed = createBaseEmbed("✅ Trade Completed", undefined, "Green") - .addFields( - { name: session.userA.username, value: formatOffer(session.userA), inline: true }, - { name: session.userB.username, value: formatOffer(session.userB), inline: true } - ) - .setTimestamp(); - + const embed = getTradeCompletedEmbed(session); await updateDashboardMessage(interaction, { embeds: [embed], components: [] }); // Notify and Schedule Cleanup @@ -221,7 +184,7 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s const successEmbed = createSuccessEmbed("Trade executed successfully. Items and funds have been transferred.", "Trade Complete"); await scheduleThreadCleanup( interaction.channel, - `🎉 Trade successful! <@${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`, + `🎉 Trade successful! < @${session.userA.id}> <@${session.userB.id}>\nThis thread will be deleted in 10 seconds.`, 10000, successEmbed ); @@ -244,31 +207,8 @@ export async function updateTradeDashboard(interaction: Interaction, threadId: s } // Build Status Embed - const embed = createBaseEmbed("🤝 Trading Session", undefined, EMBED_COLOR) - .addFields( - { - name: `${session.userA.username} ${session.userA.locked ? '✅ (Ready)' : '✏️ (Editing)'}`, - value: formatOffer(session.userA), - inline: true - }, - { - name: `${session.userB.username} ${session.userB.locked ? '✅ (Ready)' : '✏️ (Editing)'}`, - value: formatOffer(session.userB), - inline: true - } - ) - .setFooter({ text: "Both parties must click Lock to confirm trade." }); - - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary), - new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success), - new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary), - new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary), - new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger), - ); - - await updateDashboardMessage(interaction, { embeds: [embed], components: [row] }); + const { embeds, components } = getTradeDashboard(session); + await updateDashboardMessage(interaction, { embeds, components }); } async function updateDashboardMessage(interaction: Interaction, payload: any) { @@ -296,17 +236,7 @@ async function updateDashboardMessage(interaction: Interaction, payload: any) { } } -function formatOffer(participant: any) { - let text = ""; - if (participant.offer.money > 0n) { - text += `💰 ${participant.offer.money} 🪙\n`; - } - if (participant.offer.items.length > 0) { - text += participant.offer.items.map((i: any) => `- ${i.name} (x${i.quantity})`).join("\n"); - } - if (text === "") text = "*Empty Offer*"; - return text; -} + async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, message: string, delayMs: number = 10000, embed?: EmbedBuilder) { try { @@ -318,7 +248,7 @@ async function scheduleThreadCleanup(channel: ThreadChannel | TextChannel, messa setTimeout(async () => { try { if (channel.isThread()) { - console.log(`Deleting thread: ${channel.id}`); + console.log(`Deleting thread: ${channel.id} `); await channel.delete("Trade Session Ended"); } } catch (e) { diff --git a/src/modules/trade/trade.view.ts b/src/modules/trade/trade.view.ts new file mode 100644 index 0000000..e4b2279 --- /dev/null +++ b/src/modules/trade/trade.view.ts @@ -0,0 +1,85 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, StringSelectMenuBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; +import { createBaseEmbed } from "@lib/embeds"; +import type { TradeSession, TradeParticipant } from "./trade.types"; + +const EMBED_COLOR = 0xFFD700; // Gold + +function formatOffer(participant: TradeParticipant) { + let text = ""; + if (participant.offer.money > 0n) { + text += `💰 ${participant.offer.money} 🪙\n`; + } + if (participant.offer.items.length > 0) { + text += participant.offer.items.map((i) => `- ${i.name} (x${i.quantity})`).join("\n"); + } + if (text === "") text = "*Empty Offer*"; + return text; +} + +export function getTradeDashboard(session: TradeSession) { + const embed = createBaseEmbed("🤝 Trading Session", undefined, EMBED_COLOR) + .addFields( + { + name: `${session.userA.username} ${session.userA.locked ? '✅ (Ready)' : '✏️ (Editing)'}`, + value: formatOffer(session.userA), + inline: true + }, + { + name: `${session.userB.username} ${session.userB.locked ? '✅ (Ready)' : '✏️ (Editing)'}`, + value: formatOffer(session.userB), + inline: true + } + ) + .setFooter({ text: "Both parties must click Lock to confirm trade." }); + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder().setCustomId('trade_add_item').setLabel('Add Item').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('trade_add_money').setLabel('Add Money').setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId('trade_remove_item').setLabel('Remove Item').setStyle(ButtonStyle.Secondary), + new ButtonBuilder().setCustomId('trade_lock').setLabel('Lock / Unlock').setStyle(ButtonStyle.Primary), + new ButtonBuilder().setCustomId('trade_cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger), + ); + + return { embeds: [embed], components: [row] }; +} + +export function getTradeCompletedEmbed(session: TradeSession) { + const embed = createBaseEmbed("✅ Trade Completed", undefined, "Green") + .addFields( + { name: session.userA.username, value: formatOffer(session.userA), inline: true }, + { name: session.userB.username, value: formatOffer(session.userB), inline: true } + ) + .setTimestamp(); + + return embed; +} + +export function getTradeMoneyModal() { + const modal = new ModalBuilder() + .setCustomId('trade_money_modal') + .setTitle('Add Money'); + + const input = new TextInputBuilder() + .setCustomId('amount') + .setLabel("Amount to trade") + .setStyle(TextInputStyle.Short) + .setPlaceholder("100") + .setRequired(true); + + const row = new ActionRowBuilder().addComponents(input); + modal.addComponents(row); + + return modal; +} + +export function getItemSelectMenu(items: { label: string, value: string, description?: string }[], customId: string, placeholder: string) { + const select = new StringSelectMenuBuilder() + .setCustomId(customId) + .setPlaceholder(placeholder) + .addOptions(items); + + const row = new ActionRowBuilder().addComponents(select); + + return { components: [row] }; +} diff --git a/src/modules/user/enrollment.interaction.ts b/src/modules/user/enrollment.interaction.ts index a444030..ef690da 100644 --- a/src/modules/user/enrollment.interaction.ts +++ b/src/modules/user/enrollment.interaction.ts @@ -1,6 +1,6 @@ import { ButtonInteraction, MessageFlags } from "discord.js"; import { config } from "@/lib/config"; -import { createErrorEmbed } from "@/lib/embeds"; +import { getEnrollmentErrorEmbed, getEnrollmentSuccessMessage } from "./enrollment.view"; import { classService } from "@modules/class/class.service"; import { userService } from "@modules/user/user.service"; import { sendWebhookMessage } from "@/lib/webhookUtils"; @@ -15,7 +15,7 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction if (!studentRole || !visitorRole) { await interaction.reply({ - embeds: [createErrorEmbed("No student or visitor role configured for enrollment.", "Configuration Error")], + ...getEnrollmentErrorEmbed("No student or visitor role configured for enrollment.", "Configuration Error"), flags: MessageFlags.Ephemeral }); return; @@ -28,7 +28,7 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction // Check DB enrollment if (user.class) { await interaction.reply({ - embeds: [createErrorEmbed("You are already enrolled in a class.", "Enrollment Failed")], + ...getEnrollmentErrorEmbed("You are already enrolled in a class.", "Enrollment Failed"), flags: MessageFlags.Ephemeral }); return; @@ -39,7 +39,7 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction // Check Discord role enrollment (Double safety) if (member.roles.cache.has(studentRole)) { await interaction.reply({ - embeds: [createErrorEmbed("You already have the student role.", "Enrollment Failed")], + ...getEnrollmentErrorEmbed("You already have the student role.", "Enrollment Failed"), flags: MessageFlags.Ephemeral }); return; @@ -51,7 +51,7 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction if (validClasses.length === 0) { await interaction.reply({ - embeds: [createErrorEmbed("No classes with specified roles found in database.", "Configuration Error")], + ...getEnrollmentErrorEmbed("No classes with specified roles found in database.", "Configuration Error"), flags: MessageFlags.Ephemeral }); return; @@ -65,7 +65,7 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction const classRole = interaction.guild.roles.cache.get(classRoleId); if (!classRole) { await interaction.reply({ - embeds: [createErrorEmbed(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`, "Configuration Error")], + ...getEnrollmentErrorEmbed(`The configured role ID \`${classRoleId}\` for class **${selectedClass.name}** does not exist in this server.`, "Configuration Error"), flags: MessageFlags.Ephemeral }); return; @@ -81,7 +81,7 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction await classService.assignClass(user.id.toString(), selectedClass.id); await interaction.reply({ - content: `🎉 You have been successfully enrolled! You received the **${classRole.name}** role.`, + ...getEnrollmentSuccessMessage(classRole.name), flags: MessageFlags.Ephemeral }); @@ -113,8 +113,8 @@ export async function handleEnrollmentInteraction(interaction: ButtonInteraction } catch (error) { console.error("Enrollment error:", error); await interaction.reply({ - embeds: [createErrorEmbed("An unexpected error occurred during enrollment. Please contact an administrator.", "System Error")], + ...getEnrollmentErrorEmbed("An unexpected error occurred during enrollment. Please contact an administrator.", "System Error"), flags: MessageFlags.Ephemeral }); } -} +} \ No newline at end of file diff --git a/src/modules/user/enrollment.view.ts b/src/modules/user/enrollment.view.ts new file mode 100644 index 0000000..639ddcd --- /dev/null +++ b/src/modules/user/enrollment.view.ts @@ -0,0 +1,12 @@ +import { createErrorEmbed } from "@/lib/embeds"; + +export function getEnrollmentErrorEmbed(message: string, title: string = "Enrollment Failed") { + const embed = createErrorEmbed(message, title); + return { embeds: [embed] }; +} + +export function getEnrollmentSuccessMessage(roleName: string) { + return { + content: `🎉 You have been successfully enrolled! You received the **${roleName}** role.` + }; +}