forked from syntaxbullet/aurorabot
feat: Add web admin page for quest management and refactor Discord bot's quest UI to use new components.
This commit is contained in:
@@ -1,8 +1,12 @@
|
|||||||
import { createCommand } from "@shared/lib/utils";
|
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 { questService } from "@shared/modules/quest/quest.service";
|
||||||
import { createSuccessEmbed, createWarningEmbed } from "@lib/embeds";
|
import { createSuccessEmbed } from "@lib/embeds";
|
||||||
import { getQuestListEmbed, getAvailableQuestsEmbed, getQuestActionRows } from "@/modules/quest/quest.view";
|
import {
|
||||||
|
getQuestListComponents,
|
||||||
|
getAvailableQuestsComponents,
|
||||||
|
getQuestActionRows
|
||||||
|
} from "@/modules/quest/quest.view";
|
||||||
|
|
||||||
export const quests = createCommand({
|
export const quests = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -17,15 +21,18 @@ export const quests = createCommand({
|
|||||||
const userQuests = await questService.getUserQuests(userId);
|
const userQuests = await questService.getUserQuests(userId);
|
||||||
const availableQuests = await questService.getAvailableQuests(userId);
|
const availableQuests = await questService.getAvailableQuests(userId);
|
||||||
|
|
||||||
const embed = viewType === 'active'
|
const containers = viewType === 'active'
|
||||||
? getQuestListEmbed(userQuests)
|
? getQuestListComponents(userQuests)
|
||||||
: getAvailableQuestsEmbed(availableQuests);
|
: getAvailableQuestsComponents(availableQuests);
|
||||||
|
|
||||||
const components = getQuestActionRows(viewType, availableQuests);
|
const actionRows = getQuestActionRows(viewType);
|
||||||
|
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [embed],
|
content: null,
|
||||||
components: components
|
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');
|
await updateView('active');
|
||||||
|
|
||||||
const collector = response.createMessageComponentCollector({
|
const collector = response.createMessageComponentCollector({
|
||||||
time: 60000,
|
time: 120000, // 2 minutes
|
||||||
componentType: undefined // Allow both buttons and select menu
|
componentType: undefined // Allow buttons
|
||||||
});
|
});
|
||||||
|
|
||||||
collector.on('collect', async (i) => {
|
collector.on('collect', async (i) => {
|
||||||
@@ -47,22 +54,24 @@ export const quests = createCommand({
|
|||||||
} else if (i.customId === "quest_view_available") {
|
} else if (i.customId === "quest_view_available") {
|
||||||
await i.deferUpdate();
|
await i.deferUpdate();
|
||||||
await updateView('available');
|
await updateView('available');
|
||||||
} else if (i.customId === "quest_accept_select") {
|
} else if (i.customId.startsWith("quest_accept:")) {
|
||||||
const questId = parseInt((i as any).values[0]);
|
const questIdStr = i.customId.split(":")[1];
|
||||||
|
if (!questIdStr) return;
|
||||||
|
const questId = parseInt(questIdStr);
|
||||||
await questService.assignQuest(userId, questId);
|
await questService.assignQuest(userId, questId);
|
||||||
|
|
||||||
await i.reply({
|
await i.reply({
|
||||||
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
|
embeds: [createSuccessEmbed(`You have accepted a new quest!`, "Quest Accepted")],
|
||||||
flags: MessageFlags.Ephemeral
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateView('active');
|
await updateView('active');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Quest interaction error:", error);
|
console.error("Quest interaction error:", error);
|
||||||
await i.followUp({
|
await i.followUp({
|
||||||
content: "Something went wrong while processing your quest interaction.",
|
content: "Something went wrong while processing your quest interaction.",
|
||||||
flags: MessageFlags.Ephemeral
|
flags: MessageFlags.Ephemeral
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { join } from "node:path";
|
||||||
import type { Command } from "@shared/lib/types";
|
import type { Command } from "@shared/lib/types";
|
||||||
import { env } from "@shared/lib/env";
|
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})` : ""}`);
|
console.log(`🛠️ System Action: Maintenance mode ${enabled ? "ON" : "OFF"}${reason ? ` (${reason})` : ""}`);
|
||||||
this.maintenanceMode = enabled;
|
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) {
|
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] });
|
export const AuroraClient = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMembers, GatewayIntentBits.DirectMessages] });
|
||||||
@@ -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
|
* Quest entry with quest details and progress
|
||||||
@@ -27,6 +36,13 @@ interface AvailableQuest {
|
|||||||
requirements: any;
|
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
|
* Formats quest rewards object into a human-readable string
|
||||||
*/
|
*/
|
||||||
@@ -34,14 +50,7 @@ function formatQuestRewards(rewards: { xp?: number, balance?: number }): string
|
|||||||
const rewardStr: string[] = [];
|
const rewardStr: string[] = [];
|
||||||
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
if (rewards?.xp) rewardStr.push(`${rewards.xp} XP`);
|
||||||
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
if (rewards?.balance) rewardStr.push(`${rewards.balance} 🪙`);
|
||||||
return rewardStr.join(", ");
|
return rewardStr.join(" • ") || "None";
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the quest status display string
|
|
||||||
*/
|
|
||||||
function getQuestStatus(completedAt: Date | null): string {
|
|
||||||
return completedAt ? "✅ Completed" : "📝 In Progress";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,108 +64,155 @@ function renderProgressBar(current: number, total: number, size: number = 10): s
|
|||||||
const progressText = "▰".repeat(progress);
|
const progressText = "▰".repeat(progress);
|
||||||
const emptyText = "▱".repeat(empty);
|
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 {
|
export function getQuestListComponents(userQuests: QuestEntry[]): ContainerBuilder[] {
|
||||||
const embed = new EmbedBuilder()
|
// Filter to only show in-progress quests (not completed)
|
||||||
.setTitle("📜 Quest Log")
|
const activeQuests = userQuests.filter(entry => entry.completedAt === null);
|
||||||
.setDescription("Your active and completed quests.")
|
|
||||||
.setColor(0x3498db); // Blue
|
|
||||||
|
|
||||||
if (userQuests.length === 0) {
|
const container = new ContainerBuilder()
|
||||||
embed.setDescription("You have no active quests. Check available quests!");
|
.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 => {
|
activeQuests.forEach((entry) => {
|
||||||
const status = getQuestStatus(entry.completedAt);
|
container.addSeparatorComponents(new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small));
|
||||||
|
|
||||||
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
const rewards = entry.quest.rewards as { xp?: number, balance?: number };
|
||||||
const rewardsText = formatQuestRewards(rewards);
|
const rewardsText = formatQuestRewards(rewards);
|
||||||
|
|
||||||
const requirements = entry.quest.requirements as { target?: number };
|
const requirements = entry.quest.requirements as { target?: number };
|
||||||
const target = requirements?.target || 1;
|
const target = requirements?.target || 1;
|
||||||
const progress = entry.progress || 0;
|
const progress = entry.progress || 0;
|
||||||
|
const progressBar = renderProgressBar(progress, target);
|
||||||
|
|
||||||
const progressBar = entry.completedAt ? "✅ Fully completed" : renderProgressBar(progress, target);
|
container.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent(`**${entry.quest.name}**`),
|
||||||
embed.addFields({
|
new TextDisplayBuilder().setContent(entry.quest.description || "*No description*"),
|
||||||
name: `${entry.quest.name} (${status})`,
|
new TextDisplayBuilder().setContent(`📊 ${progressBar} \`${progress}/${target}\` • 🎁 ${rewardsText}`)
|
||||||
value: `${entry.quest.description}\n**Rewards:** ${rewardsText}\n**Progress:** ${progressBar}`,
|
);
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {
|
export function getAvailableQuestsComponents(availableQuests: AvailableQuest[]): ContainerBuilder[] {
|
||||||
const embed = new EmbedBuilder()
|
const container = new ContainerBuilder()
|
||||||
.setTitle("🗺️ Available Quests")
|
.setAccentColor(COLORS.AVAILABLE)
|
||||||
.setDescription("Quests you can accept right now.")
|
.addTextDisplayComponents(
|
||||||
.setColor(0x2ecc71); // Green
|
new TextDisplayBuilder().setContent("# 🗺️ Available Quests"),
|
||||||
|
new TextDisplayBuilder().setContent("-# Quests you can accept")
|
||||||
|
);
|
||||||
|
|
||||||
if (availableQuests.length === 0) {
|
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 rewards = quest.rewards as { xp?: number, balance?: number };
|
||||||
const rewardsText = formatQuestRewards(rewards);
|
const rewardsText = formatQuestRewards(rewards);
|
||||||
|
|
||||||
const requirements = quest.requirements as { target?: number };
|
const requirements = quest.requirements as { target?: number };
|
||||||
const target = requirements?.target || 1;
|
const target = requirements?.target || 1;
|
||||||
|
|
||||||
embed.addFields({
|
container.addTextDisplayComponents(
|
||||||
name: quest.name,
|
new TextDisplayBuilder().setContent(`**${quest.name}**`),
|
||||||
value: `${quest.description}\n**Goal:** Reach ${target} for this activity.\n**Rewards:** ${rewardsText}`,
|
new TextDisplayBuilder().setContent(quest.description || "*No description*"),
|
||||||
inline: false
|
new TextDisplayBuilder().setContent(`🎯 Goal: \`${target}\` • 🎁 ${rewardsText}`)
|
||||||
});
|
);
|
||||||
|
|
||||||
|
// Add accept button inline within the container
|
||||||
|
container.addActionRowComponents(
|
||||||
|
new ActionRowBuilder<ButtonBuilder>().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<any>[] {
|
export function getQuestActionRows(viewType: 'active' | 'available'): ActionRowBuilder<ButtonBuilder>[] {
|
||||||
const rows: ActionRowBuilder<any>[] = [];
|
// Navigation row
|
||||||
|
|
||||||
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
const navRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("quest_view_active")
|
.setCustomId("quest_view_active")
|
||||||
.setLabel("Active Quests")
|
.setLabel("📜 Active")
|
||||||
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
.setStyle(viewType === 'active' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
.setDisabled(viewType === 'active'),
|
.setDisabled(viewType === 'active'),
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId("quest_view_available")
|
.setCustomId("quest_view_available")
|
||||||
.setLabel("Available Quests")
|
.setLabel("🗺️ Available")
|
||||||
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
.setStyle(viewType === 'available' ? ButtonStyle.Primary : ButtonStyle.Secondary)
|
||||||
.setDisabled(viewType === 'available')
|
.setDisabled(viewType === 'available')
|
||||||
);
|
);
|
||||||
rows.push(navRow);
|
|
||||||
|
|
||||||
if (viewType === 'available' && availableQuests.length > 0) {
|
return [navRow];
|
||||||
const selectMenu = new StringSelectMenuBuilder()
|
}
|
||||||
.setCustomId("quest_accept_select")
|
|
||||||
.setPlaceholder("Select a quest to accept")
|
/**
|
||||||
.addOptions(
|
* Creates Components v2 celebratory message for quest completion
|
||||||
availableQuests.slice(0, 25).map(q =>
|
*/
|
||||||
new StringSelectMenuOptionBuilder()
|
export function getQuestCompletionComponents(quest: any, rewards: { xp: bigint, balance: bigint }): ContainerBuilder[] {
|
||||||
.setLabel(q.name)
|
const rewardsText = formatQuestRewards({
|
||||||
.setDescription(q.description?.substring(0, 100) || "")
|
xp: Number(rewards.xp),
|
||||||
.setValue(q.id.toString())
|
balance: Number(rewards.balance)
|
||||||
)
|
});
|
||||||
);
|
|
||||||
|
const container = new ContainerBuilder()
|
||||||
rows.push(new ActionRowBuilder().addComponents(selectMenu));
|
.setAccentColor(COLORS.COMPLETED)
|
||||||
}
|
.addTextDisplayComponents(
|
||||||
|
new TextDisplayBuilder().setContent("# 🎉 Quest Completed!"),
|
||||||
return rows;
|
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 }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,8 @@ export const EVENTS = {
|
|||||||
RELOAD_COMMANDS: "actions:reload_commands",
|
RELOAD_COMMANDS: "actions:reload_commands",
|
||||||
CLEAR_CACHE: "actions:clear_cache",
|
CLEAR_CACHE: "actions:clear_cache",
|
||||||
MAINTENANCE_MODE: "actions:maintenance_mode",
|
MAINTENANCE_MODE: "actions:maintenance_mode",
|
||||||
|
},
|
||||||
|
QUEST: {
|
||||||
|
COMPLETED: "quest:completed",
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { userQuests } from "@db/schema";
|
import { userQuests, quests } from "@db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { UserError } from "@shared/lib/errors";
|
import { UserError } from "@shared/lib/errors";
|
||||||
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
import { DrizzleClient } from "@shared/db/DrizzleClient";
|
||||||
@@ -7,6 +7,7 @@ import { levelingService } from "@shared/modules/leveling/leveling.service";
|
|||||||
import { withTransaction } from "@/lib/db";
|
import { withTransaction } from "@/lib/db";
|
||||||
import type { Transaction } from "@shared/lib/types";
|
import type { Transaction } from "@shared/lib/types";
|
||||||
import { TransactionType } from "@shared/lib/constants";
|
import { TransactionType } from "@shared/lib/constants";
|
||||||
|
import { systemEvents, EVENTS } from "@shared/lib/events";
|
||||||
|
|
||||||
export const questService = {
|
export const questService = {
|
||||||
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
|
||||||
@@ -107,6 +108,14 @@ export const questService = {
|
|||||||
results.xp = xp;
|
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 };
|
return { success: true, rewards: results };
|
||||||
}, tx);
|
}, tx);
|
||||||
},
|
},
|
||||||
@@ -120,7 +129,7 @@ export const questService = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getAvailableQuests: async (userId: string) => {
|
async getAvailableQuests(userId: string) {
|
||||||
const userQuestIds = (await DrizzleClient.query.userQuests.findMany({
|
const userQuestIds = (await DrizzleClient.query.userQuests.findMany({
|
||||||
where: eq(userQuests.userId, BigInt(userId)),
|
where: eq(userQuests.userId, BigInt(userId)),
|
||||||
columns: {
|
columns: {
|
||||||
@@ -133,5 +142,25 @@ export const questService = {
|
|||||||
? notInArray(quests.id, userQuestIds)
|
? notInArray(quests.id, userQuestIds)
|
||||||
: undefined
|
: 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { DesignSystem } from "./pages/DesignSystem";
|
import { DesignSystem } from "./pages/DesignSystem";
|
||||||
|
import { AdminQuests } from "./pages/AdminQuests";
|
||||||
import { Home } from "./pages/Home";
|
import { Home } from "./pages/Home";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/design-system" element={<DesignSystem />} />
|
<Route path="/design-system" element={<DesignSystem />} />
|
||||||
|
<Route path="/admin/quests" element={<AdminQuests />} />
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
230
web/src/components/quest-form.tsx
Normal file
230
web/src/components/quest-form.tsx
Normal file
@@ -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<typeof questSchema>;
|
||||||
|
|
||||||
|
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<QuestFormValues>({
|
||||||
|
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 (
|
||||||
|
<Card className="glass-card max-w-2xl mx-auto overflow-hidden">
|
||||||
|
<div className="h-1.5 bg-primary w-full" />
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold text-primary">Create New Quest</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure a new quest for the Aurora RPG academy.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Quest Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Collector's Journey" {...field} className="bg-background/50" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="triggerEvent"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Trigger Event</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger className="bg-background/50">
|
||||||
|
<SelectValue placeholder="Select an event" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent className="glass-card border-border/50">
|
||||||
|
<ScrollArea className="h-48">
|
||||||
|
{TRIGGER_EVENTS.map((event) => (
|
||||||
|
<SelectItem key={event.value} value={event.value}>
|
||||||
|
{event.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Assigns a task to the student..."
|
||||||
|
{...field}
|
||||||
|
className="min-h-[100px] bg-background/50"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="target"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Target Value</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
onChange={e => field.onChange(parseInt(e.target.value))}
|
||||||
|
className="bg-background/50"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="xpReward"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>XP Reward</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
onChange={e => field.onChange(parseInt(e.target.value))}
|
||||||
|
className="bg-background/50"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="balanceReward"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>AU Reward</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...field}
|
||||||
|
onChange={e => field.onChange(parseInt(e.target.value))}
|
||||||
|
className="bg-background/50"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-primary text-primary-foreground hover:glow-primary active-press py-6 text-lg font-bold"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Creating..." : "Create Quest"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
web/src/pages/AdminQuests.tsx
Normal file
59
web/src/pages/AdminQuests.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { QuestForm } from "../components/quest-form";
|
||||||
|
import { Badge } from "../components/ui/badge";
|
||||||
|
import { SectionHeader } from "../components/section-header";
|
||||||
|
import { SettingsDrawer } from "../components/settings-drawer";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export function AdminQuests() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-aurora-page text-foreground font-outfit overflow-x-hidden">
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="sticky top-0 z-50 glass-card border-b border-border/50 py-4 px-8 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-aurora sun-flare shadow-sm" />
|
||||||
|
<span className="text-xl font-bold tracking-tight text-primary">Aurora Admin</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Link to="/" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link to="/dashboard" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link to="/design-system" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
Design System
|
||||||
|
</Link>
|
||||||
|
<div className="h-4 w-px bg-border/50" />
|
||||||
|
<SettingsDrawer />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="pt-12 px-8 pb-12 max-w-7xl mx-auto space-y-12">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className="flex items-center gap-2 text-muted-foreground hover:text-primary transition-colors w-fit"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">Back to Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<SectionHeader
|
||||||
|
badge="Quest Management"
|
||||||
|
title="Administrative Tools"
|
||||||
|
description="Create and manage quests for the Aurora RPG students."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="animate-in fade-in slide-up duration-700">
|
||||||
|
<QuestForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminQuests;
|
||||||
@@ -58,6 +58,9 @@ export function Dashboard() {
|
|||||||
<Link to="/design-system" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
<Link to="/design-system" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
||||||
Design System
|
Design System
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/admin/quests" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
<div className="h-4 w-px bg-border/50" />
|
<div className="h-4 w-px bg-border/50" />
|
||||||
<SettingsDrawer />
|
<SettingsDrawer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { StatCard } from "../components/stat-card";
|
|||||||
import { LootdropCard } from "../components/lootdrop-card";
|
import { LootdropCard } from "../components/lootdrop-card";
|
||||||
import { Activity, Coins, Flame, Trophy } from "lucide-react";
|
import { Activity, Coins, Flame, Trophy } from "lucide-react";
|
||||||
import { SettingsDrawer } from "../components/settings-drawer";
|
import { SettingsDrawer } from "../components/settings-drawer";
|
||||||
|
import { QuestForm } from "../components/quest-form";
|
||||||
|
|
||||||
import { RecentActivity } from "../components/recent-activity";
|
import { RecentActivity } from "../components/recent-activity";
|
||||||
import { type RecentEvent } from "@shared/modules/dashboard/dashboard.types";
|
import { type RecentEvent } from "@shared/modules/dashboard/dashboard.types";
|
||||||
@@ -98,6 +99,9 @@ export function DesignSystem() {
|
|||||||
<Link to="/dashboard" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
<Link to="/dashboard" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/admin/quests" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -464,6 +468,17 @@ export function DesignSystem() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Administrative Tools Showcase */}
|
||||||
|
<section className="space-y-6 animate-in slide-up delay-700">
|
||||||
|
<h2 className="text-3xl font-bold flex items-center justify-center md:justify-start gap-2">
|
||||||
|
<span className="w-8 h-8 rounded-full bg-primary inline-block" />
|
||||||
|
Administrative Tools
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-6 text-left">
|
||||||
|
<QuestForm />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Typography */}
|
{/* Typography */}
|
||||||
<section className="space-y-8 pb-12">
|
<section className="space-y-8 pb-12">
|
||||||
<h2 className="text-step-3 font-bold text-center">Fluid Typography</h2>
|
<h2 className="text-step-3 font-bold text-center">Fluid Typography</h2>
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export function Home() {
|
|||||||
<Link to="/design-system" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
<Link to="/design-system" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
||||||
Design System
|
Design System
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/admin/quests" className="text-step--1 font-medium text-muted-foreground hover:text-primary transition-colors">
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -169,6 +169,34 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quest Management
|
||||||
|
if (url.pathname === "/api/quests" && req.method === "POST") {
|
||||||
|
try {
|
||||||
|
const { questService } = await import("@shared/modules/quest/quest.service");
|
||||||
|
const data = await req.json();
|
||||||
|
|
||||||
|
// Basic validation could be added here or rely on service/DB
|
||||||
|
const result = await questService.createQuest({
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || "",
|
||||||
|
triggerEvent: data.triggerEvent,
|
||||||
|
requirements: { target: Number(data.target) || 1 },
|
||||||
|
rewards: {
|
||||||
|
xp: Number(data.xpReward) || 0,
|
||||||
|
balance: Number(data.balanceReward) || 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ success: true, quest: result[0] });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("web", "Error creating quest", error);
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Failed to create quest", details: error instanceof Error ? error.message : String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Settings Management
|
// Settings Management
|
||||||
if (url.pathname === "/api/settings") {
|
if (url.pathname === "/api/settings") {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user