From ba8afd144e14a39ede0362da9b88f3419e6231e3 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sat, 28 Mar 2026 17:01:09 +0100 Subject: [PATCH] docs: add inventory display redesign implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-28-inventory-display-redesign.md | 874 ++++++++++++++++++ 1 file changed, 874 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-28-inventory-display-redesign.md diff --git a/docs/superpowers/plans/2026-03-28-inventory-display-redesign.md b/docs/superpowers/plans/2026-03-28-inventory-display-redesign.md new file mode 100644 index 0000000..5a8d304 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-inventory-display-redesign.md @@ -0,0 +1,874 @@ +# Inventory Display Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the `/inventory` command into a polished Components V2 experience with rarity emojis, paginated list, item detail view with artwork, and inline item actions (use/discard). + +**Architecture:** Rewrite `inventory.view.ts` to produce CV2 containers for list and detail views. Rewrite `inventory.ts` command to manage pagination and view state with a collector. Add `inventory.interaction.ts` for interaction routing. Extend `RARITY_CONFIG` with square emojis. + +**Tech Stack:** discord.js Components V2 (ContainerBuilder, TextDisplayBuilder, SectionBuilder, MediaGalleryBuilder, ActionRowBuilder, ButtonBuilder, StringSelectMenuBuilder), Drizzle ORM, Bun test runner. + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `shared/lib/rarity.ts` | Modify | Add `squareEmoji` field to `RARITY_CONFIG` | +| `bot/modules/inventory/inventory.view.ts` | Rewrite | CV2 list message builder, CV2 detail message builder (keep `getLootboxResultMessage` untouched) | +| `bot/modules/inventory/inventory.interaction.ts` | Create | Handle all inventory interactions (select, pagination, back, use, discard, confirm) | +| `bot/commands/inventory/inventory.ts` | Rewrite | Command definition with `view` subcommand, pagination collector, autocomplete | + +--- + +### Task 1: Add squareEmoji to RARITY_CONFIG + +**Files:** +- Modify: `shared/lib/rarity.ts` + +- [ ] **Step 1: Update the RARITY_CONFIG type and entries** + +In `shared/lib/rarity.ts`, update the type signature and add `squareEmoji` to each entry: + +```typescript +/** + * Shared Rarity Configuration + * Provides the canonical rarity display config (colors, emoji, labels) + * used by lootbox pull results and shop loot table views. + */ +export const RARITY_CONFIG: Record = { + C: { color: 0x95A5A6, emoji: "๐Ÿ“ฆ", squareEmoji: "๐ŸŸค", label: "Common" }, + R: { color: 0x3498DB, emoji: "๐Ÿ“ฆ", squareEmoji: "๐Ÿ”ต", label: "Rare" }, + SR: { color: 0x9B59B6, emoji: "โœจ", squareEmoji: "๐ŸŸฃ", label: "Super Rare" }, + SSR: { color: 0xF1C40F, emoji: "๐ŸŒŸ", squareEmoji: "๐ŸŸก", label: "SSR" }, + CURRENCY: { color: 0x2ECC71, emoji: "๐Ÿ’ฐ", squareEmoji: "๐Ÿ’ฐ", label: "Currency" }, + XP: { color: 0x1ABC9C, emoji: "๐Ÿ”ฎ", squareEmoji: "๐Ÿ”ฎ", label: "Experience" }, + NOTHING: { color: 0x636363, emoji: "๐Ÿ’จ", squareEmoji: "๐Ÿ’จ", label: "Empty" }, +}; + +export function getRarityConfig(rarity: string): { color: number; emoji: string; squareEmoji: string; label: string } { + return RARITY_CONFIG[rarity] ?? (RARITY_CONFIG["C"]!); +} +``` + +- [ ] **Step 2: Verify nothing is broken** + +Run: `bun test` +Expected: All existing tests pass (lootbox and other rarity consumers still work since they access `emoji`, not `squareEmoji`). + +- [ ] **Step 3: Commit** + +```bash +git add shared/lib/rarity.ts +git commit -m "feat(inventory): add squareEmoji to RARITY_CONFIG" +``` + +--- + +### Task 2: Build the inventory list view (CV2) + +**Files:** +- Rewrite: `bot/modules/inventory/inventory.view.ts` + +This task rewrites `getInventoryEmbed` โ†’ `getInventoryListMessage` and adds `getItemDetailMessage`. The existing `getLootboxResultMessage` function stays untouched. + +- [ ] **Step 1: Define constants and types at the top of inventory.view.ts** + +Replace the existing `InventoryEntry` interface and add constants. Keep all existing imports and add the new ones needed: + +```typescript +import { + EmbedBuilder, + AttachmentBuilder, + ContainerBuilder, + SectionBuilder, + TextDisplayBuilder, + MediaGalleryBuilder, + MediaGalleryItemBuilder, + ThumbnailBuilder, + SeparatorBuilder, + SeparatorSpacingSize, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + MessageFlags, +} from "discord.js"; +import { resolveAssetUrl, isLocalAssetUrl } from "@shared/lib/assets"; +import { getRarityConfig, defaultName, stripQuery } from "@shared/lib/rarity"; +import { ItemType } from "@shared/lib/constants"; +import type { ItemUsageData } from "@shared/lib/types"; +import { join } from "path"; +import { existsSync } from "fs"; + +export const ITEMS_PER_PAGE = 5; + +const RARITY_SORT_ORDER: Record = { + SSR: 0, + SR: 1, + R: 2, + C: 3, +}; + +export interface InventoryItem { + id: number; + name: string; + description: string | null; + rarity: string | null; + type: string; + price: bigint | null; + iconUrl: string; + imageUrl: string; + usageData: unknown; +} + +export interface InventoryEntry { + quantity: bigint | null; + item: InventoryItem; +} +``` + +- [ ] **Step 2: Add the sortInventoryItems helper** + +```typescript +export function sortInventoryItems(entries: InventoryEntry[]): InventoryEntry[] { + return [...entries].sort((a, b) => { + const rarityA = RARITY_SORT_ORDER[a.item.rarity ?? "C"] ?? 3; + const rarityB = RARITY_SORT_ORDER[b.item.rarity ?? "C"] ?? 3; + if (rarityA !== rarityB) return rarityA - rarityB; + return a.item.name.localeCompare(b.item.name); + }); +} +``` + +- [ ] **Step 3: Implement getInventoryListMessage** + +```typescript +export function getInventoryListMessage( + entries: InventoryEntry[], + username: string, + page: number, + viewerId: string, + ownerId: string, +) { + const sorted = sortInventoryItems(entries); + const totalPages = Math.max(1, Math.ceil(sorted.length / ITEMS_PER_PAGE)); + const safePage = Math.min(page, totalPages - 1); + const pageItems = sorted.slice(safePage * ITEMS_PER_PAGE, (safePage + 1) * ITEMS_PER_PAGE); + + // Accent color from highest-rarity item on page + const highestRarity = pageItems[0]?.item.rarity ?? "C"; + const accentColor = getRarityConfig(highestRarity).color; + + const container = new ContainerBuilder() + .setAccentColor(accentColor) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`# ๐Ÿ“ฆ ${username}'s Inventory`), + new TextDisplayBuilder().setContent(`-# ${sorted.length} item${sorted.length !== 1 ? "s" : ""} total`) + ); + + container.addSeparatorComponents( + new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small) + ); + + // Item rows + const lines = pageItems.map((entry) => { + const rc = getRarityConfig(entry.item.rarity ?? "C"); + return `${rc.squareEmoji} **${entry.item.name}** โ€” ${rc.label} ยท ${entry.item.type} ยท ร—${entry.quantity}`; + }); + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(lines.join("\n")) + ); + + container.addSeparatorComponents( + new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small) + ); + + // Select menu with current page items + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`inv_select_${viewerId}`) + .setPlaceholder("Select an item for details"); + + for (const entry of pageItems) { + const rc = getRarityConfig(entry.item.rarity ?? "C"); + selectMenu.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(entry.item.name) + .setDescription(`${rc.label} ยท ${entry.item.type}`) + .setValue(entry.item.id.toString()) + ); + } + + container.addActionRowComponents( + new ActionRowBuilder().addComponents(selectMenu) + ); + + // Pagination buttons + const navRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`inv_prev_${viewerId}`) + .setLabel("โ—€ Previous") + .setStyle(ButtonStyle.Secondary) + .setDisabled(safePage <= 0), + new ButtonBuilder() + .setCustomId(`inv_page_${viewerId}`) + .setLabel(`Page ${safePage + 1}/${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`inv_next_${viewerId}`) + .setLabel("Next โ–ถ") + .setStyle(ButtonStyle.Secondary) + .setDisabled(safePage >= totalPages - 1), + ); + + container.addActionRowComponents(navRow); + + return { + components: [container] as any, + files: [] as AttachmentBuilder[], + flags: MessageFlags.IsComponentsV2, + embeds: [], + }; +} +``` + +- [ ] **Step 4: Implement getEmptyInventoryMessage** + +```typescript +export function getEmptyInventoryMessage(username: string) { + const container = new ContainerBuilder() + .setAccentColor(0x95A5A6) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`# ๐Ÿ“ฆ ${username}'s Inventory`), + new TextDisplayBuilder().setContent("*No items yet. Visit the shop or complete quests to earn items!*") + ); + + return { + components: [container] as any, + files: [], + flags: MessageFlags.IsComponentsV2, + embeds: [], + }; +} +``` + +- [ ] **Step 5: Implement getItemDetailMessage** + +```typescript +export function getItemDetailMessage( + entry: InventoryEntry, + viewerId: string, + ownerId: string, +) { + const { item } = entry; + const rc = getRarityConfig(item.rarity ?? "C"); + const files: AttachmentBuilder[] = []; + + const container = new ContainerBuilder().setAccentColor(rc.color); + + // Header section with thumbnail + const section = new SectionBuilder().addTextDisplayComponents( + new TextDisplayBuilder().setContent(`${rc.squareEmoji} **${item.name}**`), + new TextDisplayBuilder().setContent(`-# ${rc.label} ยท ${item.type}`) + ); + + // Resolve icon thumbnail + const iconUrl = resolveItemUrl(item.iconUrl, files); + if (iconUrl) { + section.setThumbnailAccessory(new ThumbnailBuilder().setURL(iconUrl)); + } + + container.addSectionComponents(section); + + // Artwork via MediaGallery + const imageUrl = resolveItemUrl(item.imageUrl, files); + if (imageUrl && item.imageUrl !== item.iconUrl) { + container.addMediaGalleryComponents( + new MediaGalleryBuilder().addItems( + new MediaGalleryItemBuilder().setURL(imageUrl) + ) + ); + } + + // Description + if (item.description) { + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(item.description) + ); + } + + container.addSeparatorComponents( + new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Small) + ); + + // Stats row + const priceText = item.price ? `${item.price} ๐Ÿช™` : "Not tradeable"; + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `Owned: **ร—${entry.quantity}** ยท Value: **${priceText}**` + ) + ); + + // Action buttons + const isOwner = viewerId === ownerId; + const usageData = item.usageData as ItemUsageData | null; + const isUsable = isOwner && item.type === ItemType.CONSUMABLE && + usageData?.effects && usageData.effects.length > 0; + + const actionRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`inv_back_${viewerId}`) + .setLabel("โ—€ Back") + .setStyle(ButtonStyle.Primary) + ); + + if (isUsable) { + actionRow.addComponents( + new ButtonBuilder() + .setCustomId(`inv_use_${viewerId}`) + .setLabel("๐Ÿงช Use") + .setStyle(ButtonStyle.Success) + ); + } + + if (isOwner) { + actionRow.addComponents( + new ButtonBuilder() + .setCustomId(`inv_discard_${viewerId}`) + .setLabel("๐Ÿ—‘ Discard") + .setStyle(ButtonStyle.Danger) + ); + } + + container.addActionRowComponents(actionRow); + + return { + components: [container] as any, + files, + flags: MessageFlags.IsComponentsV2, + embeds: [], + }; +} +``` + +- [ ] **Step 6: Implement getDiscardConfirmMessage and the resolveItemUrl helper** + +```typescript +export function getDiscardConfirmMessage(entry: InventoryEntry, viewerId: string) { + const rc = getRarityConfig(entry.item.rarity ?? "C"); + + const container = new ContainerBuilder() + .setAccentColor(0xED4245) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `Are you sure you want to discard 1ร— **${entry.item.name}**?` + ) + ) + .addActionRowComponents( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`inv_discard_confirm_${viewerId}`) + .setLabel("Confirm") + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`inv_discard_cancel_${viewerId}`) + .setLabel("Cancel") + .setStyle(ButtonStyle.Secondary) + ) + ); + + return { + components: [container] as any, + files: [], + flags: MessageFlags.IsComponentsV2, + embeds: [], + }; +} + +/** + * Resolves an item URL (icon or image) for use in CV2 components. + * Handles both local assets and remote URLs. + * Pushes AttachmentBuilders to `files` array for local assets. + */ +function resolveItemUrl(url: string | null | undefined, files: AttachmentBuilder[]): string | null { + if (!url) return null; + + if (isLocalAssetUrl(url)) { + const filePath = join(process.cwd(), "bot/assets/graphics", stripQuery(url).replace(/^\/?assets\//, "")); + if (existsSync(filePath)) { + const fileName = defaultName(url); + if (!files.find(f => f.name === fileName)) { + files.push(new AttachmentBuilder(filePath, { name: fileName })); + } + return `attachment://${fileName}`; + } + return null; + } + + return resolveAssetUrl(url); +} +``` + +- [ ] **Step 7: Verify the file compiles** + +Run: `bunx tsc --noEmit bot/modules/inventory/inventory.view.ts` +Expected: No type errors. (Note: `getLootboxResultMessage` remains unchanged below all the new code.) + +- [ ] **Step 8: Commit** + +```bash +git add bot/modules/inventory/inventory.view.ts +git commit -m "feat(inventory): rewrite inventory view with CV2 list and detail builders" +``` + +--- + +### Task 3: Create the inventory interaction handler + +**Files:** +- Create: `bot/modules/inventory/inventory.interaction.ts` + +- [ ] **Step 1: Create the interaction handler file** + +```typescript +import type { StringSelectMenuInteraction, ButtonInteraction, MessageFlags } from "discord.js"; +import { inventoryService } from "@shared/modules/inventory/inventory.service"; +import { getLootboxResultMessage } from "./inventory.view"; +import type { ItemUsageData } from "@shared/lib/types"; +import { getGuildConfig } from "@shared/lib/config"; + +export interface InventoryState { + ownerId: string; + viewerId: string; + page: number; + selectedItemId: number | null; +} + +/** + * Extracts the viewer user ID from an inventory custom ID. + * Custom IDs follow the format: inv_{action}_{viewerId} + */ +export function parseInventoryCustomId(customId: string): { action: string; viewerId: string } | null { + const match = customId.match(/^inv_(\w+?)_(\d+)$/); + if (!match) return null; + return { action: match[1], viewerId: match[2] }; +} + +/** + * Checks if a custom ID belongs to the inventory system. + */ +export function isInventoryInteraction(customId: string): boolean { + return customId.startsWith("inv_"); +} + +/** + * Handles the "Use" button โ€” executes item effects. + * Returns the result messages array from inventoryService.useItem, + * plus handles role-based effects that require the guild member. + */ +export async function executeItemUse( + interaction: ButtonInteraction, + userId: string, + itemId: number, +): Promise<{ results: any[]; usageData: ItemUsageData | null; item: any }> { + const result = await inventoryService.useItem(userId, itemId); + + // Handle role effects (same logic as /use command) + const usageData = result.usageData; + if (usageData) { + const guildConfig = await getGuildConfig(interaction.guildId!); + const colorRoles = guildConfig.colorRoles ?? []; + + for (const effect of usageData.effects) { + if (effect.type === "TEMP_ROLE" || effect.type === "COLOR_ROLE") { + try { + const member = await interaction.guild?.members.fetch(userId); + if (member) { + if (effect.type === "TEMP_ROLE") { + await member.roles.add(effect.roleId); + } else if (effect.type === "COLOR_ROLE") { + const rolesToRemove = colorRoles.filter((r: string) => member.roles.cache.has(r)); + if (rolesToRemove.length > 0) await member.roles.remove(rolesToRemove); + await member.roles.add(effect.roleId); + } + } + } catch (e) { + console.error("Failed to assign role in inventory use:", e); + result.results.push("โš ๏ธ Failed to assign role (Check bot permissions)"); + } + } + } + } + + return result; +} +``` + +- [ ] **Step 2: Verify the file compiles** + +Run: `bunx tsc --noEmit bot/modules/inventory/inventory.interaction.ts` +Expected: No type errors. + +- [ ] **Step 3: Commit** + +```bash +git add bot/modules/inventory/inventory.interaction.ts +git commit -m "feat(inventory): add inventory interaction handler utilities" +``` + +--- + +### Task 4: Rewrite the inventory command + +**Files:** +- Rewrite: `bot/commands/inventory/inventory.ts` + +- [ ] **Step 1: Rewrite the command with subcommands, collector, and interaction routing** + +```typescript +import { createCommand } from "@shared/lib/utils"; +import { SlashCommandBuilder, MessageFlags, ComponentType } from "discord.js"; +import { inventoryService } from "@shared/modules/inventory/inventory.service"; +import { userService } from "@shared/modules/user/user.service"; +import { createWarningEmbed, createErrorEmbed } from "@lib/embeds"; +import { + getInventoryListMessage, + getEmptyInventoryMessage, + getItemDetailMessage, + getDiscardConfirmMessage, + sortInventoryItems, + ITEMS_PER_PAGE, + type InventoryEntry, +} from "@/modules/inventory/inventory.view"; +import { getLootboxResultMessage } from "@/modules/inventory/inventory.view"; +import { + parseInventoryCustomId, + isInventoryInteraction, + executeItemUse, +} from "@/modules/inventory/inventory.interaction"; +import { UserError } from "@shared/lib/errors"; + +export const inventory = createCommand({ + data: new SlashCommandBuilder() + .setName("inventory") + .setDescription("View your or another user's inventory") + .addSubcommand(sub => + sub.setName("list") + .setDescription("View your or another user's inventory") + .addUserOption(option => + option.setName("user") + .setDescription("User to view") + .setRequired(false) + ) + ) + .addSubcommand(sub => + sub.setName("view") + .setDescription("View details of a specific item") + .addNumberOption(option => + option.setName("item") + .setDescription("The item to view") + .setRequired(true) + .setAutocomplete(true) + ) + ), + execute: async (interaction) => { + await interaction.deferReply(); + + const viewerId = interaction.user.id; + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === "view") { + // Direct item detail view + const itemId = interaction.options.getNumber("item", true); + const user = await userService.getOrCreateUser(viewerId, interaction.user.username); + if (!user) { + await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] }); + return; + } + + const entries = await inventoryService.getInventory(user.id.toString()); + const entry = entries.find((e: any) => e.item.id === itemId); + if (!entry) { + await interaction.editReply({ embeds: [createWarningEmbed("Item not found in your inventory.", "Not Found")] }); + return; + } + + const ownerId = user.id.toString(); + let currentPage = 0; + let selectedItemId: number | null = itemId; + + const response = await interaction.editReply( + getItemDetailMessage(entry as InventoryEntry, viewerId, ownerId) + ); + + await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId); + return; + } + + // "list" subcommand + const targetUser = interaction.options.getUser("user") || interaction.user; + + if (targetUser.bot) { + await interaction.editReply({ embeds: [createWarningEmbed("Bots do not have inventories.", "Inventory Check")] }); + return; + } + + const user = await userService.getOrCreateUser(targetUser.id, targetUser.username); + if (!user) { + await interaction.editReply({ embeds: [createWarningEmbed("Failed to load user data.", "Error")] }); + return; + } + + const ownerId = user.id.toString(); + const entries = await inventoryService.getInventory(ownerId); + + if (!entries || entries.length === 0) { + await interaction.editReply(getEmptyInventoryMessage(user.username)); + return; + } + + let currentPage = 0; + let selectedItemId: number | null = null; + + const response = await interaction.editReply( + getInventoryListMessage(entries as InventoryEntry[], user.username, currentPage, viewerId, ownerId) + ); + + await setupCollector(interaction, response, viewerId, ownerId, user.username, currentPage, selectedItemId); + }, + autocomplete: async (interaction) => { + const focusedValue = interaction.options.getFocused(); + const userId = interaction.user.id; + const results = await inventoryService.getAutocompleteItems(userId, focusedValue); + await interaction.respond(results); + }, +}); + +async function setupCollector( + interaction: any, + response: any, + viewerId: string, + ownerId: string, + username: string, + initialPage: number, + initialItemId: number | null, +) { + let currentPage = initialPage; + let selectedItemId = initialItemId; + + const collector = response.createMessageComponentCollector({ + time: 120_000, + }); + + collector.on("collect", async (i: any) => { + if (i.user.id !== viewerId) return; + + const parsed = parseInventoryCustomId(i.customId); + if (!parsed) return; + + try { + await i.deferUpdate(); + + // Re-fetch inventory for fresh data + const entries = await inventoryService.getInventory(ownerId); + const sorted = sortInventoryItems(entries as InventoryEntry[]); + + switch (parsed.action) { + case "select": { + const itemId = parseInt(i.values[0]); + const entry = sorted.find(e => e.item.id === itemId); + if (!entry) break; + selectedItemId = itemId; + await interaction.editReply( + getItemDetailMessage(entry, viewerId, ownerId) + ); + break; + } + + case "prev": { + currentPage = Math.max(0, currentPage - 1); + selectedItemId = null; + await interaction.editReply( + getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId) + ); + break; + } + + case "next": { + currentPage = currentPage + 1; + selectedItemId = null; + await interaction.editReply( + getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId) + ); + break; + } + + case "back": { + selectedItemId = null; + if (sorted.length === 0) { + await interaction.editReply(getEmptyInventoryMessage(username)); + } else { + await interaction.editReply( + getInventoryListMessage(sorted, username, currentPage, viewerId, ownerId) + ); + } + break; + } + + case "use": { + if (viewerId !== ownerId || !selectedItemId) break; + try { + const result = await executeItemUse(i, viewerId, selectedItemId); + const message = getLootboxResultMessage(result.results, result.item); + await interaction.editReply(message as any); + + // After showing result, wait briefly then return to detail or list + setTimeout(async () => { + try { + const freshEntries = await inventoryService.getInventory(ownerId); + const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]); + const freshEntry = freshSorted.find(e => e.item.id === selectedItemId); + + if (freshEntry) { + await interaction.editReply( + getItemDetailMessage(freshEntry, viewerId, ownerId) + ); + } else { + selectedItemId = null; + if (freshSorted.length === 0) { + await interaction.editReply(getEmptyInventoryMessage(username)); + } else { + await interaction.editReply( + getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId) + ); + } + } + } catch {} + }, 3000); + } catch (error) { + if (error instanceof UserError) { + await interaction.editReply({ + embeds: [createErrorEmbed(error.message)], + components: [], + flags: undefined, + }); + } else { + throw error; + } + } + break; + } + + case "discard": { + if (viewerId !== ownerId || !selectedItemId) break; + const entry = sorted.find(e => e.item.id === selectedItemId); + if (!entry) break; + await interaction.editReply( + getDiscardConfirmMessage(entry, viewerId) + ); + break; + } + + case "discard_confirm": { + if (viewerId !== ownerId || !selectedItemId) break; + try { + await inventoryService.removeItem(ownerId, selectedItemId, 1n); + + const freshEntries = await inventoryService.getInventory(ownerId); + const freshSorted = sortInventoryItems(freshEntries as InventoryEntry[]); + const freshEntry = freshSorted.find(e => e.item.id === selectedItemId); + + if (freshEntry) { + await interaction.editReply( + getItemDetailMessage(freshEntry, viewerId, ownerId) + ); + } else { + selectedItemId = null; + if (freshSorted.length === 0) { + await interaction.editReply(getEmptyInventoryMessage(username)); + } else { + await interaction.editReply( + getInventoryListMessage(freshSorted, username, currentPage, viewerId, ownerId) + ); + } + } + } catch (error) { + if (error instanceof UserError) { + await interaction.editReply({ + embeds: [createErrorEmbed(error.message)], + components: [], + flags: undefined, + }); + } else { + throw error; + } + } + break; + } + + case "discard_cancel": { + if (!selectedItemId) break; + const entry = sorted.find(e => e.item.id === selectedItemId); + if (!entry) break; + await interaction.editReply( + getItemDetailMessage(entry, viewerId, ownerId) + ); + break; + } + } + } catch (error) { + console.error("Inventory interaction error:", error); + } + }); + + collector.on("end", () => { + interaction.editReply({ components: [] }).catch(() => {}); + }); +} +``` + +- [ ] **Step 2: Verify the file compiles** + +Run: `bunx tsc --noEmit bot/commands/inventory/inventory.ts` +Expected: No type errors. + +- [ ] **Step 3: Commit** + +```bash +git add bot/commands/inventory/inventory.ts +git commit -m "feat(inventory): rewrite command with CV2 pagination and detail view" +``` + +--- + +### Task 5: Integration testing and verification + +**Files:** +- All modified files + +- [ ] **Step 1: Run the full test suite** + +Run: `bun test` +Expected: All tests pass. The inventory service tests should still pass since we didn't change the service. + +- [ ] **Step 2: Verify TypeScript compiles cleanly** + +Run: `bunx tsc --noEmit` +Expected: No type errors across the entire project. + +- [ ] **Step 3: Verify the bot starts** + +Run: `bun --watch bot/index.ts` (start and verify no startup errors, then stop) +Expected: Bot initializes and registers commands without errors. + +- [ ] **Step 4: Final commit if any fixes were needed** + +```bash +git add -A +git commit -m "fix(inventory): address integration issues from inventory redesign" +``` + +(Only if fixes were needed in the previous steps.)