From 2f73f38877f95b995ff1c877ee17cfa59cac9f0d Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 15 Jan 2026 17:21:49 +0100 Subject: [PATCH] feat: Add web admin page for quest management and refactor Discord bot's quest UI to use new components. --- bot/commands/quest/quests.ts | 53 +++--- bot/lib/BotClient.ts | 25 ++- bot/modules/quest/quest.view.ts | 192 +++++++++++++-------- shared/lib/events.ts | 3 + shared/modules/quest/quest.service.ts | 33 +++- web/src/App.tsx | 2 + web/src/components/quest-form.tsx | 230 ++++++++++++++++++++++++++ web/src/pages/AdminQuests.tsx | 59 +++++++ web/src/pages/Dashboard.tsx | 3 + web/src/pages/DesignSystem.tsx | 15 ++ web/src/pages/Home.tsx | 3 + web/src/server.ts | 28 ++++ 12 files changed, 552 insertions(+), 94 deletions(-) create mode 100644 web/src/components/quest-form.tsx create mode 100644 web/src/pages/AdminQuests.tsx diff --git a/bot/commands/quest/quests.ts b/bot/commands/quest/quests.ts index 32262e5..a3f053a 100644 --- a/bot/commands/quest/quests.ts +++ b/bot/commands/quest/quests.ts @@ -1,8 +1,12 @@ import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js"; +import { SlashCommandBuilder, MessageFlags } from "discord.js"; import { questService } from "@shared/modules/quest/quest.service"; -import { createSuccessEmbed, createWarningEmbed } from "@lib/embeds"; -import { getQuestListEmbed, getAvailableQuestsEmbed, getQuestActionRows } from "@/modules/quest/quest.view"; +import { createSuccessEmbed } from "@lib/embeds"; +import { + getQuestListComponents, + getAvailableQuestsComponents, + getQuestActionRows +} from "@/modules/quest/quest.view"; export const quests = createCommand({ data: new SlashCommandBuilder() @@ -17,15 +21,18 @@ export const quests = createCommand({ const userQuests = await questService.getUserQuests(userId); const availableQuests = await questService.getAvailableQuests(userId); - const embed = viewType === 'active' - ? getQuestListEmbed(userQuests) - : getAvailableQuestsEmbed(availableQuests); - - const components = getQuestActionRows(viewType, availableQuests); + const containers = viewType === 'active' + ? getQuestListComponents(userQuests) + : getAvailableQuestsComponents(availableQuests); + + const actionRows = getQuestActionRows(viewType); await interaction.editReply({ - embeds: [embed], - components: components + content: null, + embeds: null as any, + components: [...containers, ...actionRows] as any, + flags: MessageFlags.IsComponentsV2, + allowedMentions: { parse: [] } }); }; @@ -33,8 +40,8 @@ export const quests = createCommand({ await updateView('active'); const collector = response.createMessageComponentCollector({ - time: 60000, - componentType: undefined // Allow both buttons and select menu + time: 120000, // 2 minutes + componentType: undefined // Allow buttons }); collector.on('collect', async (i) => { @@ -47,22 +54,24 @@ export const quests = createCommand({ } else if (i.customId === "quest_view_available") { await i.deferUpdate(); await updateView('available'); - } else if (i.customId === "quest_accept_select") { - const questId = parseInt((i as any).values[0]); + } else if (i.customId.startsWith("quest_accept:")) { + const questIdStr = i.customId.split(":")[1]; + if (!questIdStr) return; + const questId = parseInt(questIdStr); await questService.assignQuest(userId, questId); - - await i.reply({ - embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")], - flags: MessageFlags.Ephemeral + + await i.reply({ + embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")], + flags: MessageFlags.Ephemeral }); - + await updateView('active'); } } catch (error) { console.error("Quest interaction error:", error); - await i.followUp({ - content: "Something went wrong while processing your quest interaction.", - flags: MessageFlags.Ephemeral + await i.followUp({ + content: "Something went wrong while processing your quest interaction.", + flags: MessageFlags.Ephemeral }); } }); diff --git a/bot/lib/BotClient.ts b/bot/lib/BotClient.ts index d347f08..de0fdb6 100644 --- a/bot/lib/BotClient.ts +++ b/bot/lib/BotClient.ts @@ -1,4 +1,4 @@ -import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js"; +import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes, MessageFlags } from "discord.js"; import { join } from "node:path"; import type { Command } from "@shared/lib/types"; import { env } from "@shared/lib/env"; @@ -74,6 +74,27 @@ export class Client extends DiscordClient { console.log(`πŸ› οΈ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`); this.maintenanceMode = enabled; }); + + systemEvents.on(EVENTS.QUEST.COMPLETED, async (data: { userId: string, quest: any, rewards: any }) => { + const { userId, quest, rewards } = data; + try { + const user = await this.users.fetch(userId); + if (!user) return; + + const { getQuestCompletionComponents } = await import("@/modules/quest/quest.view"); + const components = getQuestCompletionComponents(quest, rewards); + + // Try to send to the user's DM + await user.send({ + components: components as any, + flags: [MessageFlags.IsComponentsV2] + }).catch(async () => { + console.warn(`Could not DM user ${userId} quest completion message. User might have DMs disabled.`); + }); + } catch (error) { + console.error("Failed to send quest completion notification:", error); + } + }); } async loadCommands(reload: boolean = false) { @@ -176,4 +197,4 @@ export class Client extends DiscordClient { } } -export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers] }); \ No newline at end of file +export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] }); \ No newline at end of file diff --git a/bot/modules/quest/quest.view.ts b/bot/modules/quest/quest.view.ts index 6343dc4..2e18283 100644 --- a/bot/modules/quest/quest.view.ts +++ b/bot/modules/quest/quest.view.ts @@ -1,4 +1,13 @@ -import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ContainerBuilder, + TextDisplayBuilder, + SeparatorBuilder, + SeparatorSpacingSize, + MessageFlags +} from "discord.js"; /** * Quest entry with quest details and progress @@ -27,6 +36,13 @@ interface AvailableQuest { requirements: any; } +// Color palette for containers +const COLORS = { + ACTIVE: 0x3498db, // Blue - in progress + AVAILABLE: 0x2ecc71, // Green - available + COMPLETED: 0xf1c40f // Gold - completed +}; + /** * Formats quest rewards object into a human-readable string */ @@ -34,14 +50,7 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string const rewardStr: string[] = []; if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`); if (rewards?.balance) rewardStr.push(`${rewards.balance} πŸͺ™`); - return rewardStr.join(", "); -} - -/** - * Returns the quest status display string - */ -function getQuestStatus(completedAt: Date | null): string { - return completedAt ? "βœ… Completed" : "πŸ“ In Progress"; + return rewardStr.join(" β€’ ") || "None"; } /** @@ -55,108 +64,155 @@ function renderProgressBar(current: number, total: number, size: number = 10): s const progressText = "β–°".repeat(progress); const emptyText = "β–±".repeat(empty); - return `${progressText}${emptyText} ${Math.round(percentage * 100)}% (${current}/${total})`; + return `${progressText}${emptyText} ${Math.round(percentage * 100)}%`; } /** - * Creates an embed displaying a user's quest log + * Creates Components v2 containers for the quest list (active quests only) */ -export function getQuestListEmbed(userQuests: QuestEntry[]): EmbedBuilder { - const embed = new EmbedBuilder() - .setTitle("πŸ“œ Quest Log") - .setDescription("Your active and completed quests.") - .setColor(0x3498db); // Blue +export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] { + // Filter to only show in-progress quests (not completed) + const activeQuests = userQuests.filter(entry => entry.completedAt === null); - if (userQuests.length === 0) { - embed.setDescription("You have no active quests. Check available quests!"); + const container = new ContainerBuilder() + .setAccentColor(COLORS.ACTIVE) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# πŸ“œ Quest Log"), + new TextDisplayBuilder().setContent("-# Your active quests") + ); + + if (activeQuests.length === 0) { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent("*You have no active quests. Check available quests!*") + ); + return [container]; } - userQuests.forEach(entry => { - const status = getQuestStatus(entry.completedAt); + activeQuests.forEach((entry) => { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + const rewards = entry.quest.rewards as { xp?: number, balance?: number }; const rewardsText = formatQuestRewards(rewards); - + const requirements = entry.quest.requirements as { target?: number }; const target = requirements?.target || 1; const progress = entry.progress || 0; + const progressBar = renderProgressBar(progress, target); - const progressBar = entry.completedAt ? "βœ… Fully completed" : renderProgressBar(progress, target); - - embed.addFields({ - name: `${entry.quest.name} (${status})`, - value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${progressBar}`, - inline: false - }); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`**${entry.quest.name}**`), + new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"), + new TextDisplayBuilder().setContent(`πŸ“Š ${progressBar} \`${progress}/${target}\` β€’ 🎁 ${rewardsText}`) + ); }); - return embed; + return [container]; } /** - * Creates an embed for available quests + * Creates Components v2 containers for available quests with inline accept buttons */ -export function getAvailableQuestsEmbed(availableQuests: AvailableQuest[]): EmbedBuilder { - const embed = new EmbedBuilder() - .setTitle("πŸ—ΊοΈ Available Quests") - .setDescription("Quests you can accept right now.") - .setColor(0x2ecc71); // Green +export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] { + const container = new ContainerBuilder() + .setAccentColor(COLORS.AVAILABLE) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# πŸ—ΊοΈ Available Quests"), + new TextDisplayBuilder().setContent("-# Quests you can accept") + ); if (availableQuests.length === 0) { - embed.setDescription("There are no new quests available for you at the moment."); + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent("*No new quests available at the moment.*") + ); + return [container]; } - availableQuests.forEach(quest => { + // Limit to 10 quests (5 action rows max with 2 added for navigation) + const questsToShow = availableQuests.slice(0, 10); + + questsToShow.forEach((quest) => { + container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)); + const rewards = quest.rewards as { xp?: number, balance?: number }; const rewardsText = formatQuestRewards(rewards); - + const requirements = quest.requirements as { target?: number }; const target = requirements?.target || 1; - embed.addFields({ - name: quest.name, - value: `${quest.description}\n**Goal:** Reach ${target} for this activity.\n**Rewards:** ${rewardsText}`, - inline: false - }); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`**${quest.name}**`), + new TextDisplayBuilder().setContent(quest.description || "*No description*"), + new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` β€’ 🎁 ${rewardsText}`) + ); + + // Add accept button inline within the container + container.addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`quest_accept:${quest.id}`) + .setLabel("Accept Quest") + .setStyle(ButtonStyle.Success) + .setEmoji("βœ…") + ) + ); }); - return embed; + return [container]; } /** - * Returns action rows for the quest view + * Returns action rows for navigation only */ -export function getQuestActionRows(viewType: 'active' | 'available', availableQuests: AvailableQuest[] = []): ActionRowBuilder[] { - const rows: ActionRowBuilder[] = []; - +export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder[] { + // Navigation row const navRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId("quest_view_active") - .setLabel("Active Quests") + .setLabel("πŸ“œ Active") .setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary) .setDisabled(viewType === 'active'), new ButtonBuilder() .setCustomId("quest_view_available") - .setLabel("Available Quests") + .setLabel("πŸ—ΊοΈ Available") .setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary) .setDisabled(viewType === 'available') ); - rows.push(navRow); - if (viewType === 'available' && availableQuests.length > 0) { - const selectMenu = new StringSelectMenuBuilder() - .setCustomId("quest_accept_select") - .setPlaceholder("Select a quest to accept") - .addOptions( - availableQuests.slice(0, 25).map(q => - new StringSelectMenuOptionBuilder() - .setLabel(q.name) - .setDescription(q.description?.substring(0, 100) || "") - .setValue(q.id.toString()) - ) - ); - - rows.push(new ActionRowBuilder().addComponents(selectMenu)); - } - - return rows; + return [navRow]; +} + +/** + * Creates Components v2 celebratory message for quest completion + */ +export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] { + const rewardsText = formatQuestRewards({ + xp: Number(rewards.xp), + balance: Number(rewards.balance) + }); + + const container = new ContainerBuilder() + .setAccentColor(COLORS.COMPLETED) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# πŸŽ‰ Quest Completed!"), + new TextDisplayBuilder().setContent(`Congratulations! You've completed **${quest.name}**`) + ) + .addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small)) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`πŸ“ ${quest.description || "No description provided."}`), + new TextDisplayBuilder().setContent(`🎁 **Rewards Earned:** ${rewardsText}`) + ); + + return [container]; +} + +/** + * Gets MessageFlags and allowedMentions for Components v2 messages + */ +export function getComponentsV2MessageFlags() { + return { + flags: MessageFlags.IsComponentsV2, + allowedMentions: { parse: [] as const } + }; } diff --git a/shared/lib/events.ts b/shared/lib/events.ts index aff8748..258c13a 100644 --- a/shared/lib/events.ts +++ b/shared/lib/events.ts @@ -17,5 +17,8 @@ export const EVENTS = { RELOAD_COMMANDS: "actions:reload_commands", CLEAR_CACHE: "actions:clear_cache", MAINTENANCE_MODE: "actions:maintenance_mode", + }, + QUEST: { + COMPLETED: "quest:completed", } } as const; diff --git a/shared/modules/quest/quest.service.ts b/shared/modules/quest/quest.service.ts index 4cc187f..72ec622 100644 --- a/shared/modules/quest/quest.service.ts +++ b/shared/modules/quest/quest.service.ts @@ -1,4 +1,4 @@ -import { userQuests } from "@db/schema"; +import { userQuests, quests } from "@db/schema"; import { eq, and } from "drizzle-orm"; import { UserError } from "@shared/lib/errors"; import { DrizzleClient } from "@shared/db/DrizzleClient"; @@ -7,6 +7,7 @@ import { levelingService } from "@shared/modules/leveling/leveling.service"; import { withTransaction } from "@/lib/db"; import type { Transaction } from "@shared/lib/types"; import { TransactionType } from "@shared/lib/constants"; +import { systemEvents, EVENTS } from "@shared/lib/events"; export const questService = { assignQuest: async (userId: string, questId: number, tx?: Transaction) => { @@ -107,6 +108,14 @@ export const questService = { results.xp = xp; } + // Emit completion event for the bot to handle notifications + systemEvents.emit(EVENTS.QUEST.COMPLETED, { + userId, + questId, + quest: userQuest.quest, + rewards: results + }); + return { success: true, rewards: results }; }, tx); }, @@ -120,7 +129,7 @@ export const questService = { }); }, - getAvailableQuests: async (userId: string) => { + async getAvailableQuests(userId: string) { const userQuestIds = (await DrizzleClient.query.userQuests.findMany({ where: eq(userQuests.userId, BigInt(userId)), columns: { @@ -133,5 +142,25 @@ export const questService = { ? notInArray(quests.id, userQuestIds) : undefined }); + }, + + async createQuest(data: { + name: string; + description: string; + triggerEvent: string; + requirements: { target: number }; + rewards: { xp: number; balance: number }; + }, tx?: Transaction) { + return await withTransaction(async (txFn) => { + return await txFn.insert(quests) + .values({ + name: data.name, + description: data.description, + triggerEvent: data.triggerEvent, + requirements: data.requirements, + rewards: data.rewards, + }) + .returning(); + }, tx); } }; diff --git a/web/src/App.tsx b/web/src/App.tsx index f6362d3..3ffa537 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import "./index.css"; import { Dashboard } from "./pages/Dashboard"; import { DesignSystem } from "./pages/DesignSystem"; +import { AdminQuests } from "./pages/AdminQuests"; import { Home } from "./pages/Home"; import { Toaster } from "sonner"; @@ -12,6 +13,7 @@ export function App() { } /> } /> + } /> } /> diff --git a/web/src/components/quest-form.tsx b/web/src/components/quest-form.tsx new file mode 100644 index 0000000..3cfbed1 --- /dev/null +++ b/web/src/components/quest-form.tsx @@ -0,0 +1,230 @@ +import React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "./ui/card"; +import { Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage } from "./ui/form"; +import { Input } from "./ui/input"; +import { Button } from "./ui/button"; +import { Textarea } from "./ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { toast } from "sonner"; +import { ScrollArea } from "./ui/scroll-area"; + +const questSchema = z.object({ + name: z.string().min(3, "Name must be at least 3 characters"), + description: z.string().optional(), + triggerEvent: z.string().min(1, "Trigger event is required"), + target: z.number().min(1, "Target must be at least 1"), + xpReward: z.number().min(0).optional(), + balanceReward: z.number().min(0).optional(), +}); + +type QuestFormValues = z.infer; + +const TRIGGER_EVENTS = [ + { label: "XP Gain", value: "XP_GAIN" }, + { label: "Item Collect", value: "ITEM_COLLECT" }, + { label: "Item Use", value: "ITEM_USE" }, + { label: "Daily Reward", value: "DAILY_REWARD" }, + { label: "Lootbox Currency Reward", value: "LOOTBOX" }, + { label: "Exam Reward", value: "EXAM_REWARD" }, + { label: "Purchase", value: "PURCHASE" }, + { label: "Transfer In", value: "TRANSFER_IN" }, + { label: "Transfer Out", value: "TRANSFER_OUT" }, + { label: "Trade In", value: "TRADE_IN" }, + { label: "Trade Out", value: "TRADE_OUT" }, + { label: "Quest Reward", value: "QUEST_REWARD" }, + { label: "Trivia Entry", value: "TRIVIA_ENTRY" }, + { label: "Trivia Win", value: "TRIVIA_WIN" }, +]; + +export function QuestForm() { + const [isSubmitting, setIsSubmitting] = React.useState(false); + const form = useForm({ + resolver: zodResolver(questSchema), + defaultValues: { + name: "", + description: "", + triggerEvent: "XP_GAIN", + target: 1, + xpReward: 100, + balanceReward: 500, + }, + }); + + const onSubmit = async (data: QuestFormValues) => { + setIsSubmitting(true); + try { + const response = await fetch("/api/quests", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to create quest"); + } + + toast.success("Quest created successfully!", { + description: `${data.name} has been added to the database.`, + }); + form.reset(); + } catch (error) { + console.error("Submission error:", error); + toast.error("Failed to create quest", { + description: error instanceof Error ? error.message : "An unknown error occurred", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ + Create New Quest + + Configure a new quest for the Aurora RPG academy. + + + +
+ +
+ ( + + Quest Name + + + + + + )} + /> + + ( + + Trigger Event + + + + )} + /> +
+ + ( + + Description + +