@idosgames/mcp 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +124 -124
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +9 -9
- package/registry/modules/boosts.json +7 -7
- package/registry/modules/character.json +6 -6
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +5 -5
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +5 -5
- package/registry/modules/events.json +5 -5
- package/registry/modules/friends.json +5 -5
- package/registry/modules/idle-rpg.json +8 -8
- package/registry/modules/inventory.json +5 -5
- package/registry/modules/invite.json +5 -5
- package/registry/modules/leaderboard.json +5 -5
- package/registry/modules/lootboxes.json +6 -6
- package/registry/modules/mailbox.json +5 -5
- package/registry/modules/marketplace.json +6 -6
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +7 -7
- package/registry/modules/pvp.json +7 -7
- package/registry/modules/quests.json +5 -5
- package/registry/modules/rewards.json +5 -5
- package/registry/modules/season.json +5 -5
- package/registry/modules/store.json +5 -5
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/community-marketing-system.json +1 -1
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/mailbox-system.json +1 -1
- package/registry/skills/match-system.json +2 -2
- package/registry/skills/multiplayer-realtime.json +2 -2
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"version": "0.1.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@idosgames/core": "0.
|
|
36
|
-
"@idosgames/module-sdk": "0.8.
|
|
37
|
-
"@idosgames/react": "0.4.
|
|
35
|
+
"@idosgames/core": "0.21.0",
|
|
36
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
37
|
+
"@idosgames/react": "0.4.3",
|
|
38
38
|
"react": "19.2.7"
|
|
39
39
|
},
|
|
40
|
-
"contentHash": "
|
|
40
|
+
"contentHash": "6c8a331edfae931985270afe5648e401f71770c30ec3d9de6c36406808fd48e7",
|
|
41
41
|
"files": [
|
|
42
42
|
{
|
|
43
43
|
"path": "i18n.ts",
|
|
@@ -49,11 +49,11 @@
|
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "LootboxesScreen.tsx",
|
|
52
|
-
"content": "import { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n type LootboxSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection<LootboxSection>(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {
|
|
52
|
+
"content": "import { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n Popup,\n ResourceList,\n configSection,\n errorText,\n grantedBy,\n outlined,\n useCatalog,\n useCelebrate,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport {\n BULK,\n canOpenBulk,\n canPay,\n lootboxName,\n lootboxOptions,\n lootboxesOpenedBy,\n openableWithItems,\n type InventoryView,\n type LootboxOption,\n type LootboxSection,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// Chests (Unity LootboxOpeningPopUp): every lootbox of the title with every way to open it — once,\n// or ten at a time where the ceiling and the wallet allow. The box shakes while the server rolls it,\n// bursts, and the rewards fly out. The inventory hands a chest or key item over here\n// (`features.open(\"lootboxes\", { itemID })`): the screen then shows what that item opens.\n\nexport function LootboxesScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const stagger = useStagger();\n const section = configSection<LootboxSection>(client, \"Lootbox\");\n const all = useMemo(() => lootboxOptions(section), [section]);\n const itemID = typeof args?.itemID === \"string\" ? args.itemID : null;\n const options = itemID ? lootboxesOpenedBy(itemID, all) : all;\n const [opening, setOpening] = useState<{\n option: LootboxOption;\n count: number;\n } | null>(null);\n\n // Handed an item that opens exactly one chest — open its popup right away.\n useEffect(() => {\n if (itemID && options.length === 1)\n setOpening({ option: options[0]!, count: 1 });\n }, [itemID]);\n\n const inventory = state?.InventoryV2 as InventoryView | undefined;\n if (options.length === 0)\n return <EmptyState glyph=\"chest\" text={t(\"noChests\")} />;\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"repeat(auto-fill, minmax(170px, 1fr))\",\n gap: 12,\n }}\n >\n {options.map((o, i) => {\n const affordable = canPay(o, inventory);\n const bulk = canOpenBulk(o, inventory);\n return (\n <Card\n key={`${o.lootboxID}:${o.optionID}`}\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 8,\n ...stagger(i),\n }}\n >\n <div style={{ ...outlined, fontSize: 15, textAlign: \"center\" }}>\n {lootboxName(o.lootboxID, catalog.localize)}\n </div>\n <div className=\"idos-bounce\">\n <Icon glyph={o.icon} size={64} />\n </div>\n {o.name ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {catalog.localize(o.name)}\n </div>\n ) : null}\n <Button\n tone=\"gold\"\n attract={affordable}\n disabled={!affordable}\n onClick={() => setOpening({ option: o, count: 1 })}\n style={{ width: \"100%\" }}\n data-tutorial-anchor={`lootbox:${o.lootboxID}`}\n >\n {o.price.length === 0 ? (\n t(\"open\")\n ) : (\n <ResourceList lines={o.price} size={20} />\n )}\n </Button>\n {bulk ? (\n <Button\n tone=\"blue\"\n size=\"sm\"\n onClick={() => setOpening({ option: o, count: BULK })}\n style={{ width: \"100%\" }}\n >\n {t(\"openTimes\")} ×{BULK}\n </Button>\n ) : null}\n </Card>\n );\n })}\n {opening ? (\n <LootboxOpening\n option={opening.option}\n count={opening.count}\n onClose={() => setOpening(null)}\n />\n ) : null}\n </div>\n );\n}\n\n/** The box shakes while the server rolls it, bursts open, and the rewards pop out. */\nfunction LootboxOpening({\n option,\n count,\n onClose,\n}: {\n option: LootboxOption;\n count: number;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const kit = useUiKit();\n const boxRef = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n\n const openBox = async (close: () => void) => {\n setBusy(true);\n const started = Date.now();\n const res = await client.lootbox.open(\n option.lootboxID,\n count,\n option.optionID,\n );\n // Let the shake play for a moment even on a fast server — the anticipation is the point.\n const wait = Math.max(\n 0,\n (kit.motion.on ? 700 : 0) - (Date.now() - started),\n );\n await new Promise((r) => setTimeout(r, wait));\n setBusy(false);\n if (!res.ok) {\n toast(errorText(res.error), \"error\");\n return;\n }\n play(\"lootbox\");\n kit.fx.burst(boxRef.current);\n close();\n // `Resources` is the whole operation — every box of a bulk opening already summed by the server\n // (`Results` is the same per box; adding both would count everything twice).\n celebrate(grantedBy(res.data), { sound: \"levelUp\" });\n };\n\n return (\n <Popup\n title={\n count > 1\n ? `${lootboxName(option.lootboxID, catalog.localize)} ×${count}`\n : lootboxName(option.lootboxID, catalog.localize)\n }\n onClose={onClose}\n variant=\"center\"\n >\n <div\n style={{\n display: \"grid\",\n justifyItems: \"center\",\n gap: 12,\n padding: \"10px 0\",\n }}\n >\n <div\n ref={boxRef}\n style={{\n animation: busy\n ? \"idos-shake .35s ease-in-out infinite\"\n : \"idos-bounce 1.2s ease-in-out infinite\",\n }}\n >\n <Icon glyph={option.icon} size={120} />\n </div>\n <ResourceList\n lines={option.price.map((l) => ({ ...l, amount: l.amount * count }))}\n />\n </div>\n <OpenButton busy={busy} onOpen={openBox} />\n </Popup>\n );\n}\n\nfunction OpenButton({\n busy,\n onOpen,\n}: {\n busy: boolean;\n onOpen: (close: () => void) => Promise<void>;\n}): ReactNode {\n const close = usePopupClose();\n return (\n <Button\n tone=\"gold\"\n size=\"lg\"\n attract\n busy={busy}\n onClick={() => void onOpen(close)}\n >\n {busy ? t(\"opening\") : t(\"open\")}\n </Button>\n );\n}\n\n/** Badge: chests the player can open right now with the items they hold (a chest item or a key). */\nexport function makeLootboxesWatcher(features: FeatureRegistry) {\n return function LootboxesBadge(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n const update = () =>\n features.setBadge(\n \"lootboxes\",\n openableWithItems(\n lootboxOptions(configSection<LootboxSection>(client, \"Lootbox\")),\n client.data.user.state?.InventoryV2 as InventoryView | undefined,\n ),\n );\n update();\n return client.on(\"user:anyUpdated\", update);\n }, [client]);\n return null;\n };\n}\n"
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "model.ts",
|
|
56
|
-
"content": "// Pure helpers of the chests screen — covered by model.test.ts.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface LootboxView {\n LootboxID?: string;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Name?: string | null; Cost?: unknown } | null\n > | null;\n MaxOpenCount?: number | null;\n}\n\n/** The title's Lootbox section, as far as the chests screen cares. */\nexport interface LootboxSection {\n Definitions?: Record<string, LootboxView | null> | null;\n Settings?: { MaxOpenCount?: number | null } | null;\n}\n\n/** What the player holds, as far as paying for a chest goes. */\nexport interface InventoryView {\n Items?: Record<\n string,\n { StackableAmount?: number; TotalAmount?: number } | null\n > | null;\n VirtualCurrencies?: Record<string, { Amount?: number } | null> | null;\n}\n\nexport interface LootboxOption {\n lootboxID: string;\n optionID: string;\n name: string | null;\n icon: string;\n price: ResourceLine[];\n /** Most boxes one open() may take (the server clamps a bigger count to it). */\n maxOpen: number;\n}\n\n/** The platform's ceiling when neither the lootbox nor the module sets one. */\nconst DEFAULT_MAX_OPEN = 100;\n\n/** Every way to open every lootbox of the title (one entry per price option). */\nexport function lootboxOptions(\n section: LootboxSection | null | undefined,\n): LootboxOption[] {\n const out: LootboxOption[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const maxOpen = Number(\n def.MaxOpenCount ?? section?.Settings?.MaxOpenCount ?? DEFAULT_MAX_OPEN,\n );\n if (maxOpen <= 0) continue;\n for (const [optionID, option] of Object.entries(def.PriceOptions ?? {})) {\n if (!option) continue;\n out.push({\n lootboxID: def.LootboxID ?? id,\n optionID: option.OptionID || optionID,\n name: option.Name ?? null,\n icon: def.AssetPaths?.icon ?? \"chest\",\n price: costLines(option.Cost as never),\n maxOpen,\n });\n }\n }\n return out.sort(\n (a, b) =>\n a.lootboxID.localeCompare(b.lootboxID) ||\n a.optionID.localeCompare(b.optionID),\n );\n}\n\n/** Lootboxes an item opens: options whose price consumes that item (a chest or a key). */\nexport function lootboxesOpenedBy(\n itemID: string,\n options: LootboxOption[],\n): LootboxOption[] {\n return options.filter((o) =>\n o.price.some((line) => line.kind === \"item\" && line.id === itemID),\n );\n}\n\n/** Whether the player holds what `times` openings of an option cost (currencies and items). */\nexport function canPay(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n times = 1,\n): boolean {\n return option.price.every((line) => {\n const need = line.amount * times;\n if (line.kind === \"currency\")\n return (\n Number(inventory?.VirtualCurrencies?.[line.id]?.Amount ?? 0) >= need\n );\n if (line.kind === \"item\")\n return (\n Number(\n inventory?.Items?.[line.id]?.TotalAmount ??\n inventory?.Items?.[line.id]?.StackableAmount ??\n 0,\n ) >= need\n );\n return true;\n });\n}\n\n/** The bulk opening offered next to a single one: ten boxes, when the ceiling and the wallet allow. */\nexport const BULK = 10;\nexport function canOpenBulk(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n): boolean {\n return option.maxOpen >= BULK && canPay(option, inventory, BULK);\n}\n\n/** Badge: lootboxes the player can open right now with items (chests, keys) — not with currency. */\nexport function openableWithItems(\n options: LootboxOption[],\n inventory: InventoryView | null | undefined,\n): number {\n return options.filter(\n (o) => o.price.some((l) => l.kind === \"item\") && canPay(o, inventory),\n ).length;\n}\n\n/** Whether the title has lootboxes — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: LootboxSection | undefined): boolean {\n return lootboxOptions(section).length > 0;\n}\n"
|
|
56
|
+
"content": "// Pure helpers of the chests screen — covered by model.test.ts.\n\nimport { costLines, type ResourceLine } from \"@idosgames/react/ui\";\n\nexport interface LootboxView {\n LootboxID?: string;\n AssetPaths?: Record<string, string> | null;\n PriceOptions?: Record<\n string,\n { OptionID?: string | null; Name?: string | null; Cost?: unknown } | null\n > | null;\n MaxOpenCount?: number | null;\n}\n\n/** The title's Lootbox section, as far as the chests screen cares. */\nexport interface LootboxSection {\n Definitions?: Record<string, LootboxView | null> | null;\n Settings?: { MaxOpenCount?: number | null } | null;\n}\n\n/** What the player holds, as far as paying for a chest goes. */\nexport interface InventoryView {\n Items?: Record<\n string,\n { StackableAmount?: number; TotalAmount?: number } | null\n > | null;\n VirtualCurrencies?: Record<string, { Amount?: number } | null> | null;\n}\n\nexport interface LootboxOption {\n lootboxID: string;\n optionID: string;\n name: string | null;\n icon: string;\n price: ResourceLine[];\n /** Most boxes one open() may take (the server clamps a bigger count to it). */\n maxOpen: number;\n}\n\n/** The platform's ceiling when neither the lootbox nor the module sets one. */\nconst DEFAULT_MAX_OPEN = 100;\n\n/**\n * A lootbox has no display name of its own in the engine — only its id. Shown as is, the player read\n * \"wooden_chest\"; this makes the id readable (\"Wooden chest\") when the title has no translation for it.\n */\nexport function lootboxName(\n id: string,\n localize: (text: string) => string,\n): string {\n const translated = localize(id);\n if (translated && translated !== id) return translated;\n const words = id.replace(/[_-]+/g, \" \").trim();\n return words ? words[0]!.toUpperCase() + words.slice(1) : id;\n}\n\n/** Every way to open every lootbox of the title (one entry per price option). */\nexport function lootboxOptions(\n section: LootboxSection | null | undefined,\n): LootboxOption[] {\n const out: LootboxOption[] = [];\n for (const [id, def] of Object.entries(section?.Definitions ?? {})) {\n if (!def) continue;\n const maxOpen = Number(\n def.MaxOpenCount ?? section?.Settings?.MaxOpenCount ?? DEFAULT_MAX_OPEN,\n );\n if (maxOpen <= 0) continue;\n for (const [optionID, option] of Object.entries(def.PriceOptions ?? {})) {\n if (!option) continue;\n out.push({\n lootboxID: def.LootboxID ?? id,\n optionID: option.OptionID || optionID,\n name: option.Name ?? null,\n icon: def.AssetPaths?.icon ?? \"chest\",\n price: costLines(option.Cost as never),\n maxOpen,\n });\n }\n }\n return out.sort(\n (a, b) =>\n a.lootboxID.localeCompare(b.lootboxID) ||\n a.optionID.localeCompare(b.optionID),\n );\n}\n\n/** Lootboxes an item opens: options whose price consumes that item (a chest or a key). */\nexport function lootboxesOpenedBy(\n itemID: string,\n options: LootboxOption[],\n): LootboxOption[] {\n return options.filter((o) =>\n o.price.some((line) => line.kind === \"item\" && line.id === itemID),\n );\n}\n\n/** Whether the player holds what `times` openings of an option cost (currencies and items). */\nexport function canPay(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n times = 1,\n): boolean {\n return option.price.every((line) => {\n const need = line.amount * times;\n if (line.kind === \"currency\")\n return (\n Number(inventory?.VirtualCurrencies?.[line.id]?.Amount ?? 0) >= need\n );\n if (line.kind === \"item\")\n return (\n Number(\n inventory?.Items?.[line.id]?.TotalAmount ??\n inventory?.Items?.[line.id]?.StackableAmount ??\n 0,\n ) >= need\n );\n return true;\n });\n}\n\n/** The bulk opening offered next to a single one: ten boxes, when the ceiling and the wallet allow. */\nexport const BULK = 10;\nexport function canOpenBulk(\n option: LootboxOption,\n inventory: InventoryView | null | undefined,\n): boolean {\n return option.maxOpen >= BULK && canPay(option, inventory, BULK);\n}\n\n/** Badge: lootboxes the player can open right now with items (chests, keys) — not with currency. */\nexport function openableWithItems(\n options: LootboxOption[],\n inventory: InventoryView | null | undefined,\n): number {\n return options.filter(\n (o) => o.price.some((l) => l.kind === \"item\") && canPay(o, inventory),\n ).length;\n}\n\n/** Whether the title has lootboxes — until it does, the lobby hides the tab. */\nexport function hasTitleData(section: LootboxSection | undefined): boolean {\n return lootboxOptions(section).length > 0;\n}\n"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "module.ts",
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
"version": "0.1.0"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.8.
|
|
41
|
-
"@idosgames/react": "0.4.
|
|
39
|
+
"@idosgames/core": "0.21.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
41
|
+
"@idosgames/react": "0.4.3",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "fcc65bab202ca1ecae11ee184aee0064e11bc9e34253866ec34733eb339b66cb",
|
|
45
45
|
"files": [
|
|
46
46
|
{
|
|
47
47
|
"path": "i18n.ts",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "MailboxScreen.tsx",
|
|
56
|
-
"content": "import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n MailboxCategory,\n MailboxDefinitions,\n MailboxGiftStatusResponse,\n MailboxLetter,\n MailboxPendingReward,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n ResourceBadge,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n bundleLines,\n isGlyph,\n outlined,\n pickLanguage,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n type Glyph,\n} from \"@idosgames/react/ui\";\nimport {\n afterFee,\n claimableCount,\n expiresSoon,\n feePercent,\n letterState,\n letterText,\n mailBadge,\n mergePages,\n newRequestID,\n transferEntries,\n transferOptions,\n type TransferInventory,\n type TransferOption,\n} from \"./model\";\nimport { builtInText, mailError, t } from \"./i18n\";\n\n// Mailbox (Unity V2/Runtime/UI/Mailbox): letters newest first by category, a letter opened in a\n// popup, \"Claim\" / \"Claim all\", missed rewards of other systems on top, and \"Send gifts\" — a free\n// daily gift to friends and a transfer of own resources. Everything the server decides (expiry,\n// limits, friendship) comes back as its codes; the screen only words them.\n\ntype Tab = \"all\" | \"Reward\" | \"Gift\" | \"System\";\n\n/** What `ctx.features.open(\"mailbox\", args)` understands — the friends system sends it. */\ninterface MailboxArgs {\n friendUserID?: string;\n friendName?: string;\n mode?: \"gift\" | \"transfer\";\n}\n\ninterface Friend {\n UserID: string;\n PublicData?: { Username?: string | null; AvatarUrl?: string | null } | null;\n}\n\nfunction words(error: string | null | undefined): string {\n return mailError(error) ?? errorText(error);\n}\n\nfunction friendName(f: Friend): string {\n const name = f.PublicData?.Username?.trim();\n if (name) return name;\n return f.UserID.length > 10\n ? `${f.UserID.slice(0, 5)}…${f.UserID.slice(-4)}`\n : f.UserID;\n}\n\nconst CATEGORY_GLYPH: Record<MailboxCategory, Glyph> = {\n System: \"bell\",\n Reward: \"chest\",\n Gift: \"gift\",\n Social: \"friends\",\n};\n\nconst MODULE_GLYPH: Record<string, Glyph> = {\n Leaderboard: \"trophy\",\n CoopEvent: \"swords\",\n Season: \"crown\",\n Referral: \"link\",\n};\n\n/** Badge on login and on coming back to the tab; mailbox actions refresh it themselves. */\nexport function makeMailboxWatcher(features: FeatureRegistry) {\n return function MailboxWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n const off = client.on(\"mailbox:countersChanged\", (c) =>\n features.setBadge(\"mailbox\", mailBadge(c)),\n );\n void client.mailbox.getUnreadCount();\n const onVisible = () => {\n if (document.visibilityState === \"visible\")\n void client.mailbox.getUnreadCount();\n };\n document.addEventListener(\"visibilitychange\", onVisible);\n return () => {\n off();\n document.removeEventListener(\"visibilitychange\", onVisible);\n };\n }, [client]);\n return null;\n };\n}\n\nexport function makeMailboxScreen(features: FeatureRegistry) {\n return function MailboxScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const defs = configSection<MailboxDefinitions>(client, \"Mailbox\");\n if (defs?.Enabled !== true)\n return <EmptyState glyph=\"gift\" text={t(\"disabled\")} />;\n return (\n <Mailbox\n features={features}\n defs={defs}\n args={(args ?? {}) as MailboxArgs}\n />\n );\n };\n}\n\nfunction Mailbox({\n features,\n defs,\n args,\n}: {\n features: FeatureRegistry;\n defs: MailboxDefinitions;\n args: MailboxArgs;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const now = useNow(30_000);\n const [tab, setTab] = useState<Tab>(\"all\");\n const [letters, setLetters] = useState<MailboxLetter[] | null>(null);\n const [pending, setPending] = useState<MailboxPendingReward[]>([]);\n const [cursor, setCursor] = useState<string | null>(null);\n const [loadingMore, setLoadingMore] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const [opened, setOpened] = useState<MailboxLetter | null>(null);\n const [askClean, setAskClean] = useState(false);\n const [gifts, setGifts] = useState<MailboxArgs | null>(\n args.friendUserID || args.mode ? args : null,\n );\n\n const category = tab === \"all\" ? undefined : tab;\n // Letters in the language the screen speaks: the game's own locale when it has localization\n // tables, else the kit's language — otherwise a Russian screen showed English letters.\n const language = client.localization.locale ?? pickLanguage();\n const load = useCallback(async () => {\n const res = await client.mailbox.getMessages({ category, language });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n setLetters([]);\n return;\n }\n setLetters(res.data.Messages);\n setPending(res.data.PendingRewards ?? []);\n setCursor(res.data.NextCursor ?? null);\n }, [client, category, language]);\n useEffect(() => {\n setLetters(null);\n void load();\n }, [load]);\n\n const more = async () => {\n if (!cursor) return;\n setLoadingMore(true);\n const res = await client.mailbox.getMessages({\n category,\n cursor,\n language,\n });\n setLoadingMore(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n setLetters((shown) => mergePages(shown ?? [], res.data.Messages));\n setCursor(res.data.NextCursor ?? null);\n };\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.mailbox.claimAll();\n setBusyAll(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n const got = grantedBy(res.data);\n if (got.length > 0) celebrate(got);\n if (res.data.Failed.length > 0)\n toast(words(res.data.Failed[0]?.Error), \"error\");\n await load();\n };\n\n const cleanRead = async () => {\n const res = await client.mailbox.deleteMessages(\"allRead\");\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n toast(t(\"deleted\"));\n await load();\n };\n\n const giftsOn =\n Object.values(defs.Gifts?.Social ?? {}).some(\n (g) => g && g.Enabled !== false,\n ) || defs.Gifts?.Transfer?.Enabled === true;\n const shownPending = tab === \"all\" || tab === \"Reward\" ? pending : [];\n const toClaim = letters ? claimableCount(letters, shownPending, now) : 0;\n const hasRead = (letters ?? []).some(\n (l) => l.ReadAt && letterState(l, now) !== \"claimable\",\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <Tabs<Tab>\n tabs={[\n { id: \"all\", label: t(\"tabAll\") },\n { id: \"Reward\", label: t(\"tabReward\"), glyph: \"chest\" },\n { id: \"Gift\", label: t(\"tabGift\"), glyph: \"gift\" },\n { id: \"System\", label: t(\"tabSystem\"), glyph: \"bell\" },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n {toClaim > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n style={{ flex: 1 }}\n >\n {t(\"claimAll\")} · {toClaim}\n </Button>\n ) : null}\n {giftsOn ? (\n <Button\n tone=\"gold\"\n onClick={() => setGifts({})}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"mailbox:gifts\"\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"sendGifts\")}\n </Button>\n ) : null}\n </div>\n\n {letters === null ? (\n <Center>\n <Spinner />\n </Center>\n ) : (\n <>\n {shownPending.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"waiting\")}\n </div>\n ) : null}\n {shownPending.map((p, i) => (\n <PendingRow\n key={p.MessageID}\n reward={p}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n {letters.length === 0 && shownPending.length === 0 ? (\n <EmptyState\n glyph=\"gift\"\n text={tab === \"all\" ? t(\"empty\") : t(\"emptyTab\")}\n />\n ) : null}\n {letters.map((l, i) => (\n <LetterRow\n key={l.MessageID}\n letter={l}\n now={now}\n onOpen={() => setOpened(l)}\n onClaimed={load}\n style={stagger(i + shownPending.length)}\n />\n ))}\n {cursor ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={loadingMore}\n onClick={() => void more()}\n >\n {t(\"more\")}\n </Button>\n ) : null}\n {hasRead ? (\n <Button tone=\"grey\" size=\"sm\" onClick={() => setAskClean(true)}>\n <Icon glyph=\"trash\" size={16} /> {t(\"deleteRead\")}\n </Button>\n ) : null}\n </>\n )}\n\n {opened ? (\n <LetterPopup\n letter={opened}\n features={features}\n onClose={() => setOpened(null)}\n onRead={(id, readAt) =>\n setLetters((shown) =>\n (shown ?? []).map((l) =>\n l.MessageID === id ? { ...l, ReadAt: readAt } : l,\n ),\n )\n }\n onChanged={load}\n />\n ) : null}\n {askClean ? (\n <ConfirmPopup\n title={t(\"deleteRead\")}\n tone=\"red\"\n onClose={() => setAskClean(false)}\n onConfirm={cleanRead}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"deleteReadAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n {gifts ? (\n <GiftsPopup\n defs={defs}\n initial={gifts}\n onClose={() => setGifts(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction LetterIcon({\n icon,\n fallback,\n size = 34,\n}: {\n icon: string | null | undefined;\n fallback: Glyph;\n size?: number;\n}): ReactNode {\n const glyph =\n icon && (isGlyph(icon) || !/^https?:/i.test(icon)) ? icon : null;\n if (icon && /^https?:/i.test(icon))\n return (\n <img\n src={icon}\n alt=\"\"\n style={{\n width: size,\n height: size,\n borderRadius: 8,\n objectFit: \"cover\",\n }}\n />\n );\n return <Icon glyph={glyph ?? fallback} size={size} />;\n}\n\nfunction LetterRow({\n letter,\n now,\n onOpen,\n onClaimed,\n style,\n}: {\n letter: MailboxLetter;\n now: number;\n onOpen: () => void;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const state = letterState(letter, now);\n const text = letterText(letter, builtInText);\n const unread = !letter.ReadAt;\n const reward = grantLines(letter.Rewards);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(letter.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : reward);\n await onClaimed();\n };\n\n return (\n <div\n ref={ref}\n onClick={onOpen}\n className=\"idos-press\"\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n border: state === \"claimable\" ? `2px solid ${v.gold}` : panel.border,\n opacity: state === \"claimed\" || state === \"expired\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ position: \"relative\" }}>\n <LetterIcon\n icon={letter.Icon}\n fallback={CATEGORY_GLYPH[letter.Category]}\n />\n {unread ? (\n <span\n style={{\n position: \"absolute\",\n top: -2,\n right: -2,\n width: 10,\n height: 10,\n borderRadius: 5,\n background: v.red,\n }}\n />\n ) : null}\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n fontWeight: unread ? 800 : 600,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {text.subject || t(\"title\")}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n }}\n >\n {letter.SenderName ? <span>{letter.SenderName}</span> : null}\n <span>{new Date(letter.SentAt).toLocaleDateString()}</span>\n {state === \"claimable\" && expiresSoon(letter, now) ? (\n <Timer at={letter.ExpiresAt} now={now} label={t(\"expiresIn\")} />\n ) : null}\n </div>\n {reward.length > 0 ? <ResourceList lines={reward} size={20} /> : null}\n </div>\n {state === \"claimable\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={(e) => {\n e.stopPropagation();\n void claim();\n }}\n >\n {t(\"claim\")}\n </Button>\n ) : state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : state === \"expired\" ? (\n <Icon glyph=\"clock\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction PendingRow({\n reward,\n onClaimed,\n style,\n}: {\n reward: MailboxPendingReward;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const text = letterText(reward, builtInText);\n const lines = grantLines(reward.Rewards);\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(reward.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : lines);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: `2px solid ${v.gold}`,\n ...style,\n }}\n >\n <Icon glyph={MODULE_GLYPH[reward.Module] ?? \"chest\"} size={34} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{text.subject}</div>\n {text.body ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {text.body}\n </div>\n ) : null}\n {lines.length > 0 ? <ResourceList lines={lines} size={20} /> : null}\n </div>\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction LetterPopup({\n letter,\n features,\n onClose,\n onRead,\n onChanged,\n}: {\n letter: MailboxLetter;\n features: FeatureRegistry;\n onClose: () => void;\n onRead: (id: string, readAt: string) => void;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const now = useNow(30_000);\n const [busy, setBusy] = useState(false);\n const [ask, setAsk] = useState<\"decline\" | null>(null);\n const [claimedNow, setClaimedNow] = useState(false);\n const text = letterText(letter, builtInText);\n const reward = grantLines(letter.Rewards);\n const state = claimedNow ? \"claimed\" : letterState(letter, now);\n\n // Opening a letter reads it — once, and only an unread one.\n useEffect(() => {\n if (letter.ReadAt) return;\n void client.mailbox.readMessages([letter.MessageID]).then((r) => {\n if (r.ok) onRead(letter.MessageID, new Date().toISOString());\n });\n }, [letter.MessageID]);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(letter.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : reward);\n setClaimedNow(true);\n await onChanged();\n };\n const remove = async (force: boolean) => {\n const res = await client.mailbox.deleteMessages([letter.MessageID], {\n force,\n });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n toast(force ? t(\"declined\") : t(\"deleted\"));\n await onChanged();\n onClose();\n };\n const link = letter.Link;\n const follow = () => {\n if (link?.FeatureID) {\n onClose();\n features.open(link.FeatureID, link.Args ?? undefined);\n } else if (link?.Url && /^https:\\/\\//i.test(link.Url)) {\n window.open(link.Url, \"_blank\", \"noopener,noreferrer\");\n }\n };\n const canFollow =\n (!!link?.FeatureID && !!features.get(link.FeatureID)?.available) ||\n (!!link?.Url && /^https:\\/\\//i.test(link.Url));\n\n return (\n <Popup title={text.subject || t(\"title\")} onClose={onClose} width={440}>\n {letter.Banner && /^https?:/i.test(letter.Banner) ? (\n <img\n src={letter.Banner}\n alt=\"\"\n style={{\n width: \"100%\",\n borderRadius: 10,\n objectFit: \"cover\",\n maxHeight: 180,\n }}\n />\n ) : null}\n {letter.SenderName ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}: {letter.SenderName} ·{\" \"}\n {new Date(letter.SentAt).toLocaleString()}\n </div>\n ) : null}\n {text.body ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n whiteSpace: \"pre-wrap\",\n lineHeight: 1.4,\n }}\n >\n {text.body}\n </div>\n ) : null}\n {letter.SenderNote ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n fontStyle: \"italic\",\n }}\n >\n «{letter.SenderNote}»\n </div>\n ) : null}\n {reward.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <ResourceList lines={reward} size={34} gap={12} />\n {state === \"claimable\" && letter.ExpiresAt ? (\n <Timer at={letter.ExpiresAt} now={now} label={t(\"expiresIn\")} />\n ) : null}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {state === \"claimable\" ? (\n <Button tone=\"green\" attract busy={busy} onClick={() => void claim()}>\n {t(\"claim\")}\n </Button>\n ) : state === \"claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </div>\n ) : state === \"expired\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.textDim }}>\n {t(\"expired\")}\n </div>\n ) : null}\n {canFollow ? (\n <Button tone=\"blue\" onClick={follow}>\n {link?.Label || t(\"open\")}\n </Button>\n ) : null}\n {state === \"claimable\" && letter.IsTransfer ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"decline\")}>\n {t(\"decline\")}\n </Button>\n ) : state !== \"claimable\" ? (\n <Button tone=\"grey\" size=\"sm\" onClick={() => void remove(false)}>\n <Icon glyph=\"trash\" size={16} /> {t(\"delete\")}\n </Button>\n ) : null}\n </div>\n {ask ? (\n <ConfirmPopup\n title={t(\"decline\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={() => remove(true)}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"declineAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n\n// ── Gifts ──────────────────────────────────────────────────────────────────\n\nfunction GiftsPopup({\n defs,\n initial,\n onClose,\n}: {\n defs: MailboxDefinitions;\n initial: MailboxArgs;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const social = Object.entries(defs.Gifts?.Social ?? {}).filter(\n ([, g]) => g && g.Enabled !== false,\n );\n const transferOn = defs.Gifts?.Transfer?.Enabled === true;\n const [mode, setMode] = useState<\"gift\" | \"transfer\">(\n initial.mode === \"transfer\" && transferOn\n ? \"transfer\"\n : social.length > 0\n ? \"gift\"\n : \"transfer\",\n );\n const [friends, setFriends] = useState<Friend[] | null>(null);\n const [status, setStatus] = useState<MailboxGiftStatusResponse | null>(null);\n\n const refresh = useCallback(async () => {\n const s = await client.mailbox.getGiftStatus();\n if (s.ok) setStatus(s.data);\n }, [client]);\n useEffect(() => {\n void client.social.getFriendsList().then((r) => {\n const list = (r.ok ? (r.data.Friends ?? []) : []) as Friend[];\n setFriends(\n [...list].sort((a, b) => friendName(a).localeCompare(friendName(b))),\n );\n });\n void refresh();\n }, [client, refresh]);\n\n return (\n <Popup title={t(\"sendGifts\")} onClose={onClose} width={460}>\n <Tabs<\"gift\" | \"transfer\">\n tabs={[\n ...(social.length > 0\n ? [{ id: \"gift\" as const, label: t(\"gift\"), glyph: \"gift\" }]\n : []),\n ...(transferOn\n ? [{ id: \"transfer\" as const, label: t(\"transfer\"), glyph: \"bag\" }]\n : []),\n ]}\n value={mode}\n onChange={setMode}\n />\n {friends === null ? (\n <Center minHeight={160}>\n <Spinner />\n </Center>\n ) : friends.length === 0 ? (\n <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />\n ) : mode === \"gift\" ? (\n <SocialGifts\n gifts={social}\n friends={friends}\n status={status}\n initialFriend={initial.friendUserID}\n onSent={refresh}\n />\n ) : (\n <Transfer\n defs={defs}\n friends={friends}\n status={status}\n initialFriend={initial.friendUserID}\n onSent={refresh}\n onDone={onClose}\n />\n )}\n </Popup>\n );\n}\n\nfunction FriendChip({\n friend,\n selected,\n done,\n onClick,\n}: {\n friend: Friend;\n selected: boolean;\n done?: boolean;\n onClick?: () => void;\n}): ReactNode {\n return (\n <div\n onClick={done ? undefined : onClick}\n className={done ? undefined : \"idos-press\"}\n style={{\n ...panel,\n padding: \"8px 10px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n cursor: done ? \"default\" : \"pointer\",\n border: selected ? `2px solid ${v.gold}` : panel.border,\n opacity: done ? 0.55 : 1,\n }}\n >\n {friend.PublicData?.AvatarUrl ? (\n <img\n src={friend.PublicData.AvatarUrl}\n alt=\"\"\n style={{\n width: 28,\n height: 28,\n borderRadius: 14,\n objectFit: \"cover\",\n }}\n />\n ) : (\n <Icon glyph=\"user\" size={26} />\n )}\n <span\n style={{\n ...outlined,\n fontSize: 14,\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {friendName(friend)}\n </span>\n {done ? (\n <Icon glyph=\"check\" size={18} />\n ) : selected ? (\n <Icon glyph=\"gift\" size={18} />\n ) : null}\n </div>\n );\n}\n\nfunction SocialGifts({\n gifts,\n friends,\n status,\n initialFriend,\n onSent,\n}: {\n gifts: Array<\n [\n string,\n NonNullable<NonNullable<MailboxDefinitions[\"Gifts\"]>[\"Social\"]>[string],\n ]\n >;\n friends: Friend[];\n status: MailboxGiftStatusResponse | null;\n initialFriend?: string;\n onSent: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [giftID, setGiftID] = useState(gifts[0]?.[0] ?? \"\");\n const [picked, setPicked] = useState<Set<string>>(\n () =>\n new Set(\n initialFriend && friends.some((f) => f.UserID === initialFriend)\n ? [initialFriend]\n : [],\n ),\n );\n const [busy, setBusy] = useState(false);\n const now = useNow(30_000);\n const mine = status?.Social?.[giftID];\n const sentTo = new Set(mine?.SentTo ?? []);\n const open = friends.filter((f) => !sentTo.has(f.UserID));\n const gift = gifts.find(([id]) => id === giftID)?.[1];\n const price = bundleLines(gift?.Cost);\n\n const send = async (ids: string[]) => {\n if (ids.length === 0) return;\n setBusy(true);\n const res = await client.mailbox.sendGift(giftID, ids);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n if (res.data.Sent.length > 0) toast(t(\"giftSent\"), \"success\");\n if (res.data.Failed.length > 0)\n toast(`${t(\"someFailed\")}: ${words(res.data.Failed[0]?.Error)}`, \"error\");\n setPicked(new Set());\n await onSent();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"giftHint\")}\n </div>\n {gifts.length > 1 ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {gifts.map(([id, g]) => (\n <Card\n key={id}\n highlight={id === giftID}\n onClick={() => {\n setGiftID(id);\n setPicked(new Set());\n }}\n style={{ padding: 8 }}\n >\n <ResourceList lines={bundleLines(g?.Grant)} size={24} />\n </Card>\n ))}\n </div>\n ) : gift ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <ResourceList lines={bundleLines(gift.Grant)} size={30} gap={12} />\n </div>\n ) : null}\n {price.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n }}\n >\n {t(\"pricePerFriend\")}: <ResourceList lines={price} size={18} />\n </div>\n ) : null}\n {mine ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"leftInPeriod\")}: {mine.RemainingInPeriod}\n </span>\n <Timer at={mine.ResetsAt} now={now} label={t(\"resetsIn\")} />\n </div>\n ) : null}\n <div\n style={{ display: \"grid\", gap: 6, maxHeight: 280, overflowY: \"auto\" }}\n >\n {friends.map((f) => (\n <FriendChip\n key={f.UserID}\n friend={f}\n done={sentTo.has(f.UserID)}\n selected={picked.has(f.UserID)}\n onClick={() =>\n setPicked((s) => {\n const next = new Set(s);\n if (next.has(f.UserID)) next.delete(f.UserID);\n else next.add(f.UserID);\n return next;\n })\n }\n />\n ))}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n busy={busy}\n disabled={picked.size === 0}\n onClick={() => void send([...picked])}\n style={{ flex: 1 }}\n >\n {t(\"sendTo\")}\n {picked.size > 0 ? ` · ${picked.size}` : \"\"}\n </Button>\n <Button\n tone=\"gold\"\n attract={open.length > 0}\n busy={busy}\n disabled={open.length === 0}\n onClick={() => void send(open.map((f) => f.UserID))}\n style={{ flex: 1 }}\n >\n {t(\"toEveryone\")}\n </Button>\n </div>\n </div>\n );\n}\n\nfunction Transfer({\n defs,\n friends,\n status,\n initialFriend,\n onSent,\n onDone,\n}: {\n defs: MailboxDefinitions;\n friends: Friend[];\n status: MailboxGiftStatusResponse | null;\n initialFriend?: string;\n onSent: () => Promise<void>;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const state = useUserState();\n const catalog = useCatalog();\n const [to, setTo] = useState<string | null>(\n initialFriend && friends.some((f) => f.UserID === initialFriend)\n ? initialFriend\n : null,\n );\n const [amounts, setAmounts] = useState<Record<string, number>>({});\n const [note, setNote] = useState(\"\");\n const [ask, setAsk] = useState(false);\n // One key per attempt: a retry after a timeout reuses it, so the server never charges twice.\n const requestID = useRef(newRequestID());\n const options = useMemo(\n () =>\n transferOptions(\n defs.Gifts?.Transfer?.AllowedResources,\n state?.InventoryV2 as TransferInventory | undefined,\n ),\n [defs, state?.InventoryV2],\n );\n const entries = transferEntries(options, amounts);\n const friend = friends.find((f) => f.UserID === to) ?? null;\n const now = useNow(30_000);\n const fee = feePercent(defs.Gifts?.Transfer?.FeePercent);\n const receives = afterFee(entries, fee);\n const fixedFee = bundleLines(defs.Gifts?.Transfer?.FeeCost);\n\n const send = async () => {\n if (!to) return false;\n const res = await client.mailbox.sendTransfer(to, entries, {\n requestID: requestID.current,\n note: note.trim() || undefined,\n });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n requestID.current = newRequestID();\n toast(t(\"transferSent\"), \"success\");\n await onSent();\n onDone();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"transferHint\")}\n </div>\n {status?.Transfer ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"leftInPeriod\")}: {status.Transfer.RemainingInPeriod}\n </span>\n <Timer\n at={status.Transfer.ResetsAt}\n now={now}\n label={t(\"resetsIn\")}\n />\n </div>\n ) : null}\n {fee > 0 || fixedFee.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n }}\n >\n {fee > 0 ? (\n <span>{t(\"commission\").replace(\"{p}\", String(fee))}</span>\n ) : null}\n {fixedFee.length > 0 ? (\n <>\n <span>{t(\"fixedFee\")}:</span>\n <ResourceList lines={fixedFee} size={18} />\n </>\n ) : null}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 13 }}>{t(\"pickFriend\")}</div>\n <div\n style={{ display: \"grid\", gap: 6, maxHeight: 180, overflowY: \"auto\" }}\n >\n {friends.map((f) => (\n <FriendChip\n key={f.UserID}\n friend={f}\n selected={to === f.UserID}\n onClick={() => setTo(f.UserID)}\n />\n ))}\n </div>\n <div style={{ display: \"grid\", gap: 6 }}>\n {options.map((o) => (\n <TransferRow\n key={o.key}\n option={o}\n value={amounts[o.key] ?? 0}\n name={\n o.kind === \"currency\"\n ? catalog.currencyName(o.id)\n : catalog.itemName(o.id)\n }\n onChange={(n) => setAmounts((a) => ({ ...a, [o.key]: n }))}\n />\n ))}\n </div>\n <input\n value={note}\n onChange={(e) => setNote(e.target.value)}\n maxLength={120}\n placeholder={t(\"notePlaceholder\")}\n style={{\n ...panel,\n padding: \"10px 12px\",\n fontSize: 14,\n color: v.text,\n outline: \"none\",\n }}\n />\n <Button\n tone=\"green\"\n disabled={!to || entries.length === 0 || receives.length === 0}\n onClick={() =>\n entries.length === 0 ? toast(t(\"nothingToSend\")) : setAsk(true)\n }\n >\n {t(\"sendTo\")}\n </Button>\n {ask && friend ? (\n <ConfirmPopup\n title={t(\"transfer\")}\n tone=\"gold\"\n onClose={() => setAsk(false)}\n onConfirm={send}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"transferAsk\").replace(\"{name}\", friendName(friend))}\n </div>\n <ResourceList\n lines={bundleLines({ Entries: entries })}\n size={28}\n gap={12}\n />\n {fee > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"friendGets\")}\n </div>\n <ResourceList\n lines={bundleLines({ Entries: receives })}\n size={22}\n gap={10}\n />\n </div>\n ) : null}\n {fixedFee.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"fixedFee\")}: <ResourceList lines={fixedFee} size={18} />\n </div>\n ) : null}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction TransferRow({\n option,\n value,\n name,\n onChange,\n}: {\n option: TransferOption;\n value: number;\n name: string;\n onChange: (n: number) => void;\n}): ReactNode {\n const disabled = option.max <= 0;\n const set = (n: number) =>\n onChange(Math.max(0, Math.min(option.max, Math.floor(n) || 0)));\n return (\n <div\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n opacity: disabled ? 0.5 : 1,\n }}\n >\n <ResourceBadge\n line={{ kind: option.kind, id: option.id, amount: option.owned }}\n size={24}\n />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 13 }}>{name}</div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"owned\")}: {option.owned}\n </div>\n </div>\n <input\n type=\"number\"\n min={0}\n max={option.max}\n disabled={disabled}\n value={value || \"\"}\n placeholder=\"0\"\n onChange={(e) => set(Number(e.target.value))}\n style={{\n ...panel,\n width: 80,\n padding: \"6px 8px\",\n fontSize: 15,\n textAlign: \"center\",\n color: v.text,\n outline: \"none\",\n }}\n />\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={disabled}\n onClick={() => set(option.max)}\n >\n max\n </Button>\n </div>\n );\n}\n"
|
|
56
|
+
"content": "import {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type {\n MailboxCategory,\n MailboxDefinitions,\n MailboxGiftStatusResponse,\n MailboxLetter,\n MailboxPendingReward,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n ConfirmPopup,\n EmptyState,\n Icon,\n Popup,\n ResourceBadge,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n configSection,\n errorText,\n grantLines,\n grantedBy,\n bundleLines,\n isGlyph,\n outlined,\n pickLanguage,\n panel,\n useCatalog,\n useCelebrate,\n useNow,\n useStagger,\n useToast,\n useUiKit,\n v,\n type Glyph,\n} from \"@idosgames/react/ui\";\nimport {\n afterFee,\n claimableCount,\n expiresSoon,\n feePercent,\n letterState,\n letterText,\n mailBadge,\n mergePages,\n newRequestID,\n transferEntries,\n transferOptions,\n type TransferInventory,\n type TransferOption,\n} from \"./model\";\nimport { builtInText, mailError, t } from \"./i18n\";\n\n// Mailbox (Unity V2/Runtime/UI/Mailbox): letters newest first by category, a letter opened in a\n// popup, \"Claim\" / \"Claim all\", missed rewards of other systems on top, and \"Send gifts\" — a free\n// daily gift to friends and a transfer of own resources. Everything the server decides (expiry,\n// limits, friendship) comes back as its codes; the screen only words them.\n\ntype Tab = \"all\" | \"Reward\" | \"Gift\" | \"System\";\n\n/** What `ctx.features.open(\"mailbox\", args)` understands — the friends system sends it. */\ninterface MailboxArgs {\n friendUserID?: string;\n friendName?: string;\n mode?: \"gift\" | \"transfer\";\n}\n\ninterface Friend {\n UserID: string;\n PublicData?: { Username?: string | null; AvatarUrl?: string | null } | null;\n}\n\nfunction words(error: string | null | undefined): string {\n return mailError(error) ?? errorText(error);\n}\n\nfunction friendName(f: Friend): string {\n const name = f.PublicData?.Username?.trim();\n if (name) return name;\n return f.UserID.length > 10\n ? `${f.UserID.slice(0, 5)}…${f.UserID.slice(-4)}`\n : f.UserID;\n}\n\nconst CATEGORY_GLYPH: Record<MailboxCategory, Glyph> = {\n System: \"bell\",\n Reward: \"chest\",\n Gift: \"gift\",\n Social: \"friends\",\n};\n\nconst MODULE_GLYPH: Record<string, Glyph> = {\n Leaderboard: \"trophy\",\n CoopEvent: \"swords\",\n Season: \"crown\",\n Referral: \"link\",\n};\n\n/** How often the badge re-reads the counters while the game is on screen. */\nconst MAIL_POLL_MS = 2 * 60_000;\n\n/**\n * Badge on login, on coming back to the tab and every few minutes; mailbox actions refresh it\n * themselves.\n */\nexport function makeMailboxWatcher(features: FeatureRegistry) {\n return function MailboxWatcher(): ReactNode {\n const client = useIDosGamesClient();\n useEffect(() => {\n const off = client.on(\"mailbox:countersChanged\", (c) =>\n features.setBadge(\"mailbox\", mailBadge(c)),\n );\n // The badge comes from the read's own answer, not only from the event: the counters usually\n // arrive WITH the login (before this watcher subscribed), and a read the central cache answers\n // emits nothing.\n const load = () =>\n void client.mailbox\n .getUnreadCount()\n .then((r) => r.ok && features.setBadge(\"mailbox\", mailBadge(r.data)));\n load();\n const onVisible = () => {\n if (document.visibilityState === \"visible\") load();\n };\n document.addEventListener(\"visibilitychange\", onVisible);\n // Letters also come while the player stays in the game (a friend's gift, a leaderboard prize):\n // look now and then, or the badge waited for the next tab switch.\n const id = setInterval(() => {\n if (document.visibilityState === \"visible\") load();\n }, MAIL_POLL_MS);\n return () => {\n off();\n clearInterval(id);\n document.removeEventListener(\"visibilitychange\", onVisible);\n };\n }, [client]);\n return null;\n };\n}\n\nexport function makeMailboxScreen(features: FeatureRegistry) {\n return function MailboxScreen({ args }: FeatureScreenProps): ReactNode {\n const client = useIDosGamesClient();\n const defs = configSection<MailboxDefinitions>(client, \"Mailbox\");\n if (defs?.Enabled !== true)\n return <EmptyState glyph=\"gift\" text={t(\"disabled\")} />;\n return (\n <Mailbox\n features={features}\n defs={defs}\n args={(args ?? {}) as MailboxArgs}\n />\n );\n };\n}\n\nfunction Mailbox({\n features,\n defs,\n args,\n}: {\n features: FeatureRegistry;\n defs: MailboxDefinitions;\n args: MailboxArgs;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const stagger = useStagger();\n const now = useNow(30_000);\n const [tab, setTab] = useState<Tab>(\"all\");\n const [letters, setLetters] = useState<MailboxLetter[] | null>(null);\n const [pending, setPending] = useState<MailboxPendingReward[]>([]);\n const [cursor, setCursor] = useState<string | null>(null);\n const [loadingMore, setLoadingMore] = useState(false);\n const [busyAll, setBusyAll] = useState(false);\n const [opened, setOpened] = useState<MailboxLetter | null>(null);\n const [askClean, setAskClean] = useState(false);\n const [gifts, setGifts] = useState<MailboxArgs | null>(\n args.friendUserID || args.mode ? args : null,\n );\n\n const category = tab === \"all\" ? undefined : tab;\n // Letters in the language the screen speaks: the game's own locale when it has localization\n // tables, else the kit's language — otherwise a Russian screen showed English letters.\n const language = client.localization.locale ?? pickLanguage();\n const load = useCallback(async () => {\n const res = await client.mailbox.getMessages({ category, language });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n setLetters([]);\n return;\n }\n setLetters(res.data.Messages);\n setPending(res.data.PendingRewards ?? []);\n setCursor(res.data.NextCursor ?? null);\n }, [client, category, language]);\n useEffect(() => {\n setLetters(null);\n void load();\n }, [load]);\n\n const more = async () => {\n if (!cursor) return;\n setLoadingMore(true);\n const res = await client.mailbox.getMessages({\n category,\n cursor,\n language,\n });\n setLoadingMore(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n setLetters((shown) => mergePages(shown ?? [], res.data.Messages));\n setCursor(res.data.NextCursor ?? null);\n };\n\n const claimAll = async () => {\n setBusyAll(true);\n const res = await client.mailbox.claimAll();\n setBusyAll(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n const got = grantedBy(res.data);\n if (got.length > 0) celebrate(got);\n if (res.data.Failed.length > 0)\n toast(words(res.data.Failed[0]?.Error), \"error\");\n await load();\n };\n\n const cleanRead = async () => {\n const res = await client.mailbox.deleteMessages(\"allRead\");\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n toast(t(\"deleted\"));\n await load();\n };\n\n const giftsOn =\n Object.values(defs.Gifts?.Social ?? {}).some(\n (g) => g && g.Enabled !== false,\n ) || defs.Gifts?.Transfer?.Enabled === true;\n const shownPending = tab === \"all\" || tab === \"Reward\" ? pending : [];\n const toClaim = letters ? claimableCount(letters, shownPending, now) : 0;\n const hasRead = (letters ?? []).some(\n (l) => l.ReadAt && letterState(l, now) !== \"claimable\",\n );\n\n return (\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 10,\n }}\n >\n <Tabs<Tab>\n tabs={[\n { id: \"all\", label: t(\"tabAll\") },\n { id: \"Reward\", label: t(\"tabReward\"), glyph: \"chest\" },\n { id: \"Gift\", label: t(\"tabGift\"), glyph: \"gift\" },\n { id: \"System\", label: t(\"tabSystem\"), glyph: \"bell\" },\n ]}\n value={tab}\n onChange={setTab}\n />\n <div style={{ display: \"flex\", gap: 8, flexWrap: \"wrap\" }}>\n {toClaim > 1 ? (\n <Button\n tone=\"green\"\n attract\n busy={busyAll}\n onClick={() => void claimAll()}\n style={{ flex: 1 }}\n >\n {t(\"claimAll\")} · {toClaim}\n </Button>\n ) : null}\n {giftsOn ? (\n <Button\n tone=\"gold\"\n onClick={() => setGifts({})}\n style={{ flex: 1 }}\n data-tutorial-anchor=\"mailbox:gifts\"\n >\n <Icon glyph=\"gift\" size={18} /> {t(\"sendGifts\")}\n </Button>\n ) : null}\n </div>\n\n {letters === null ? (\n <Center>\n <Spinner />\n </Center>\n ) : (\n <>\n {shownPending.length > 0 ? (\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"waiting\")}\n </div>\n ) : null}\n {shownPending.map((p, i) => (\n <PendingRow\n key={p.MessageID}\n reward={p}\n onClaimed={load}\n style={stagger(i)}\n />\n ))}\n {letters.length === 0 && shownPending.length === 0 ? (\n <EmptyState\n glyph=\"gift\"\n text={tab === \"all\" ? t(\"empty\") : t(\"emptyTab\")}\n />\n ) : null}\n {letters.map((l, i) => (\n <LetterRow\n key={l.MessageID}\n letter={l}\n now={now}\n onOpen={() => setOpened(l)}\n onClaimed={load}\n style={stagger(i + shownPending.length)}\n />\n ))}\n {cursor ? (\n <Button\n tone=\"grey\"\n size=\"sm\"\n busy={loadingMore}\n onClick={() => void more()}\n >\n {t(\"more\")}\n </Button>\n ) : null}\n {hasRead ? (\n <Button tone=\"grey\" size=\"sm\" onClick={() => setAskClean(true)}>\n <Icon glyph=\"trash\" size={16} /> {t(\"deleteRead\")}\n </Button>\n ) : null}\n </>\n )}\n\n {opened ? (\n <LetterPopup\n letter={opened}\n features={features}\n onClose={() => setOpened(null)}\n onRead={(id, readAt) =>\n setLetters((shown) =>\n (shown ?? []).map((l) =>\n l.MessageID === id ? { ...l, ReadAt: readAt } : l,\n ),\n )\n }\n onChanged={load}\n />\n ) : null}\n {askClean ? (\n <ConfirmPopup\n title={t(\"deleteRead\")}\n tone=\"red\"\n onClose={() => setAskClean(false)}\n onConfirm={cleanRead}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"deleteReadAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n {gifts ? (\n <GiftsPopup\n defs={defs}\n initial={gifts}\n onClose={() => setGifts(null)}\n />\n ) : null}\n </div>\n );\n}\n\nfunction LetterIcon({\n icon,\n fallback,\n size = 34,\n}: {\n icon: string | null | undefined;\n fallback: Glyph;\n size?: number;\n}): ReactNode {\n const glyph =\n icon && (isGlyph(icon) || !/^https?:/i.test(icon)) ? icon : null;\n if (icon && /^https?:/i.test(icon))\n return (\n <img\n src={icon}\n alt=\"\"\n style={{\n width: size,\n height: size,\n borderRadius: 8,\n objectFit: \"cover\",\n }}\n />\n );\n return <Icon glyph={glyph ?? fallback} size={size} />;\n}\n\nfunction LetterRow({\n letter,\n now,\n onOpen,\n onClaimed,\n style,\n}: {\n letter: MailboxLetter;\n now: number;\n onOpen: () => void;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const state = letterState(letter, now);\n const text = letterText(letter, builtInText);\n const unread = !letter.ReadAt;\n const reward = grantLines(letter.Rewards);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(letter.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : reward);\n await onClaimed();\n };\n\n return (\n <div\n ref={ref}\n onClick={onOpen}\n className=\"idos-press\"\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n cursor: \"pointer\",\n border: state === \"claimable\" ? `2px solid ${v.gold}` : panel.border,\n opacity: state === \"claimed\" || state === \"expired\" ? 0.6 : 1,\n ...style,\n }}\n >\n <div style={{ position: \"relative\" }}>\n <LetterIcon\n icon={letter.Icon}\n fallback={CATEGORY_GLYPH[letter.Category]}\n />\n {unread ? (\n <span\n style={{\n position: \"absolute\",\n top: -2,\n right: -2,\n width: 10,\n height: 10,\n borderRadius: 5,\n background: v.red,\n }}\n />\n ) : null}\n </div>\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div\n style={{\n ...outlined,\n fontSize: 15,\n fontWeight: unread ? 800 : 600,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {text.subject || t(\"title\")}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n }}\n >\n {letter.SenderName ? <span>{letter.SenderName}</span> : null}\n <span>{new Date(letter.SentAt).toLocaleDateString()}</span>\n {state === \"claimable\" && expiresSoon(letter, now) ? (\n <Timer at={letter.ExpiresAt} now={now} label={t(\"expiresIn\")} />\n ) : null}\n </div>\n {reward.length > 0 ? <ResourceList lines={reward} size={20} /> : null}\n </div>\n {state === \"claimable\" ? (\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={(e) => {\n e.stopPropagation();\n void claim();\n }}\n >\n {t(\"claim\")}\n </Button>\n ) : state === \"claimed\" ? (\n <Icon glyph=\"check\" size={22} />\n ) : state === \"expired\" ? (\n <Icon glyph=\"clock\" size={22} />\n ) : null}\n </div>\n );\n}\n\nfunction PendingRow({\n reward,\n onClaimed,\n style,\n}: {\n reward: MailboxPendingReward;\n onClaimed: () => Promise<void>;\n style: CSSProperties;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const ref = useRef<HTMLDivElement>(null);\n const [busy, setBusy] = useState(false);\n const text = letterText(reward, builtInText);\n const lines = grantLines(reward.Rewards);\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(reward.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n kit.fx.burst(ref.current);\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : lines);\n await onClaimed();\n };\n return (\n <div\n ref={ref}\n style={{\n ...panel,\n padding: 10,\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n border: `2px solid ${v.gold}`,\n ...style,\n }}\n >\n <Icon glyph={MODULE_GLYPH[reward.Module] ?? \"chest\"} size={34} />\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 3 }}>\n <div style={{ ...outlined, fontSize: 15 }}>{text.subject}</div>\n {text.body ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {text.body}\n </div>\n ) : null}\n {lines.length > 0 ? <ResourceList lines={lines} size={20} /> : null}\n </div>\n <Button\n size=\"sm\"\n tone=\"green\"\n attract\n busy={busy}\n onClick={() => void claim()}\n >\n {t(\"claim\")}\n </Button>\n </div>\n );\n}\n\nfunction LetterPopup({\n letter,\n features,\n onClose,\n onRead,\n onChanged,\n}: {\n letter: MailboxLetter;\n features: FeatureRegistry;\n onClose: () => void;\n onRead: (id: string, readAt: string) => void;\n onChanged: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const celebrate = useCelebrate();\n const kit = useUiKit();\n const now = useNow(30_000);\n const [busy, setBusy] = useState(false);\n const [ask, setAsk] = useState<\"decline\" | null>(null);\n const [claimedNow, setClaimedNow] = useState(false);\n const text = letterText(letter, builtInText);\n const reward = grantLines(letter.Rewards);\n const state = claimedNow ? \"claimed\" : letterState(letter, now);\n\n // Opening a letter reads it — once, and only an unread one.\n useEffect(() => {\n if (letter.ReadAt) return;\n void client.mailbox.readMessages([letter.MessageID]).then((r) => {\n if (r.ok) onRead(letter.MessageID, new Date().toISOString());\n });\n }, [letter.MessageID]);\n\n const claim = async () => {\n setBusy(true);\n const res = await client.mailbox.claimReward(letter.MessageID);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n kit.play(\"claim\");\n const got = grantedBy(res.data);\n celebrate(got.length > 0 ? got : reward);\n setClaimedNow(true);\n await onChanged();\n };\n const remove = async (force: boolean) => {\n const res = await client.mailbox.deleteMessages([letter.MessageID], {\n force,\n });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n toast(force ? t(\"declined\") : t(\"deleted\"));\n await onChanged();\n onClose();\n };\n const link = letter.Link;\n const follow = () => {\n if (link?.FeatureID) {\n onClose();\n features.open(link.FeatureID, link.Args ?? undefined);\n } else if (link?.Url && /^https:\\/\\//i.test(link.Url)) {\n window.open(link.Url, \"_blank\", \"noopener,noreferrer\");\n }\n };\n const canFollow =\n (!!link?.FeatureID && !!features.get(link.FeatureID)?.available) ||\n (!!link?.Url && /^https:\\/\\//i.test(link.Url));\n\n return (\n <Popup title={text.subject || t(\"title\")} onClose={onClose} width={440}>\n {letter.Banner && /^https?:/i.test(letter.Banner) ? (\n <img\n src={letter.Banner}\n alt=\"\"\n style={{\n width: \"100%\",\n borderRadius: 10,\n objectFit: \"cover\",\n maxHeight: 180,\n }}\n />\n ) : null}\n {letter.SenderName ? (\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}: {letter.SenderName} ·{\" \"}\n {new Date(letter.SentAt).toLocaleString()}\n </div>\n ) : null}\n {text.body ? (\n <div\n style={{\n ...outlined,\n fontSize: 14,\n whiteSpace: \"pre-wrap\",\n lineHeight: 1.4,\n }}\n >\n {text.body}\n </div>\n ) : null}\n {letter.SenderNote ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n fontStyle: \"italic\",\n }}\n >\n «{letter.SenderNote}»\n </div>\n ) : null}\n {reward.length > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 6 }}>\n <ResourceList lines={reward} size={34} gap={12} />\n {state === \"claimable\" && letter.ExpiresAt ? (\n <Timer at={letter.ExpiresAt} now={now} label={t(\"expiresIn\")} />\n ) : null}\n </div>\n ) : null}\n <div style={{ display: \"grid\", gap: 8 }}>\n {state === \"claimable\" ? (\n <Button tone=\"green\" attract busy={busy} onClick={() => void claim()}>\n {t(\"claim\")}\n </Button>\n ) : state === \"claimed\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.green }}>\n <Icon glyph=\"check\" size={18} /> {t(\"claimed\")}\n </div>\n ) : state === \"expired\" ? (\n <div style={{ ...outlined, textAlign: \"center\", color: v.textDim }}>\n {t(\"expired\")}\n </div>\n ) : null}\n {canFollow ? (\n <Button tone=\"blue\" onClick={follow}>\n {link?.Label || t(\"open\")}\n </Button>\n ) : null}\n {state === \"claimable\" && letter.IsTransfer ? (\n <Button tone=\"grey\" onClick={() => setAsk(\"decline\")}>\n {t(\"decline\")}\n </Button>\n ) : state !== \"claimable\" ? (\n <Button tone=\"grey\" size=\"sm\" onClick={() => void remove(false)}>\n <Icon glyph=\"trash\" size={16} /> {t(\"delete\")}\n </Button>\n ) : null}\n </div>\n {ask ? (\n <ConfirmPopup\n title={t(\"decline\")}\n tone=\"red\"\n onClose={() => setAsk(null)}\n onConfirm={() => remove(true)}\n >\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"declineAsk\")}\n </div>\n </ConfirmPopup>\n ) : null}\n </Popup>\n );\n}\n\n// ── Gifts ──────────────────────────────────────────────────────────────────\n\nfunction GiftsPopup({\n defs,\n initial,\n onClose,\n}: {\n defs: MailboxDefinitions;\n initial: MailboxArgs;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const social = Object.entries(defs.Gifts?.Social ?? {}).filter(\n ([, g]) => g && g.Enabled !== false,\n );\n const transferOn = defs.Gifts?.Transfer?.Enabled === true;\n const [mode, setMode] = useState<\"gift\" | \"transfer\">(\n initial.mode === \"transfer\" && transferOn\n ? \"transfer\"\n : social.length > 0\n ? \"gift\"\n : \"transfer\",\n );\n const [friends, setFriends] = useState<Friend[] | null>(null);\n const [status, setStatus] = useState<MailboxGiftStatusResponse | null>(null);\n\n const refresh = useCallback(async () => {\n const s = await client.mailbox.getGiftStatus();\n if (s.ok) setStatus(s.data);\n }, [client]);\n useEffect(() => {\n void client.social.getFriendsList().then((r) => {\n const list = (r.ok ? (r.data.Friends ?? []) : []) as Friend[];\n setFriends(\n [...list].sort((a, b) => friendName(a).localeCompare(friendName(b))),\n );\n });\n void refresh();\n }, [client, refresh]);\n\n return (\n <Popup title={t(\"sendGifts\")} onClose={onClose} width={460}>\n <Tabs<\"gift\" | \"transfer\">\n tabs={[\n ...(social.length > 0\n ? [{ id: \"gift\" as const, label: t(\"gift\"), glyph: \"gift\" }]\n : []),\n ...(transferOn\n ? [{ id: \"transfer\" as const, label: t(\"transfer\"), glyph: \"bag\" }]\n : []),\n ]}\n value={mode}\n onChange={setMode}\n />\n {friends === null ? (\n <Center minHeight={160}>\n <Spinner />\n </Center>\n ) : friends.length === 0 ? (\n <EmptyState glyph=\"friends\" text={t(\"noFriends\")} />\n ) : mode === \"gift\" ? (\n <SocialGifts\n gifts={social}\n friends={friends}\n status={status}\n initialFriend={initial.friendUserID}\n onSent={refresh}\n />\n ) : (\n <Transfer\n defs={defs}\n friends={friends}\n status={status}\n initialFriend={initial.friendUserID}\n onSent={refresh}\n onDone={onClose}\n />\n )}\n </Popup>\n );\n}\n\nfunction FriendChip({\n friend,\n selected,\n done,\n onClick,\n}: {\n friend: Friend;\n selected: boolean;\n done?: boolean;\n onClick?: () => void;\n}): ReactNode {\n return (\n <div\n onClick={done ? undefined : onClick}\n className={done ? undefined : \"idos-press\"}\n style={{\n ...panel,\n padding: \"8px 10px\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n cursor: done ? \"default\" : \"pointer\",\n border: selected ? `2px solid ${v.gold}` : panel.border,\n opacity: done ? 0.55 : 1,\n }}\n >\n {friend.PublicData?.AvatarUrl ? (\n <img\n src={friend.PublicData.AvatarUrl}\n alt=\"\"\n style={{\n width: 28,\n height: 28,\n borderRadius: 14,\n objectFit: \"cover\",\n }}\n />\n ) : (\n <Icon glyph=\"user\" size={26} />\n )}\n <span\n style={{\n ...outlined,\n fontSize: 14,\n flex: 1,\n minWidth: 0,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {friendName(friend)}\n </span>\n {done ? (\n <Icon glyph=\"check\" size={18} />\n ) : selected ? (\n <Icon glyph=\"gift\" size={18} />\n ) : null}\n </div>\n );\n}\n\nfunction SocialGifts({\n gifts,\n friends,\n status,\n initialFriend,\n onSent,\n}: {\n gifts: Array<\n [\n string,\n NonNullable<NonNullable<MailboxDefinitions[\"Gifts\"]>[\"Social\"]>[string],\n ]\n >;\n friends: Friend[];\n status: MailboxGiftStatusResponse | null;\n initialFriend?: string;\n onSent: () => Promise<void>;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const [giftID, setGiftID] = useState(gifts[0]?.[0] ?? \"\");\n const [picked, setPicked] = useState<Set<string>>(\n () =>\n new Set(\n initialFriend && friends.some((f) => f.UserID === initialFriend)\n ? [initialFriend]\n : [],\n ),\n );\n const [busy, setBusy] = useState(false);\n const now = useNow(30_000);\n const mine = status?.Social?.[giftID];\n const sentTo = new Set(mine?.SentTo ?? []);\n const open = friends.filter((f) => !sentTo.has(f.UserID));\n const gift = gifts.find(([id]) => id === giftID)?.[1];\n const price = bundleLines(gift?.Cost);\n\n const send = async (ids: string[]) => {\n if (ids.length === 0) return;\n setBusy(true);\n const res = await client.mailbox.sendGift(giftID, ids);\n setBusy(false);\n if (!res.ok) return toast(words(res.error), \"error\");\n if (res.data.Sent.length > 0) toast(t(\"giftSent\"), \"success\");\n if (res.data.Failed.length > 0)\n toast(`${t(\"someFailed\")}: ${words(res.data.Failed[0]?.Error)}`, \"error\");\n setPicked(new Set());\n await onSent();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"giftHint\")}\n </div>\n {gifts.length > 1 ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n justifyContent: \"center\",\n }}\n >\n {gifts.map(([id, g]) => (\n <Card\n key={id}\n highlight={id === giftID}\n onClick={() => {\n setGiftID(id);\n setPicked(new Set());\n }}\n style={{ padding: 8 }}\n >\n <ResourceList lines={bundleLines(g?.Grant)} size={24} />\n </Card>\n ))}\n </div>\n ) : gift ? (\n <div style={{ display: \"flex\", justifyContent: \"center\" }}>\n <ResourceList lines={bundleLines(gift.Grant)} size={30} gap={12} />\n </div>\n ) : null}\n {price.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n }}\n >\n {t(\"pricePerFriend\")}: <ResourceList lines={price} size={18} />\n </div>\n ) : null}\n {mine ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"leftInPeriod\")}: {mine.RemainingInPeriod}\n </span>\n <Timer at={mine.ResetsAt} now={now} label={t(\"resetsIn\")} />\n </div>\n ) : null}\n <div\n style={{ display: \"grid\", gap: 6, maxHeight: 280, overflowY: \"auto\" }}\n >\n {friends.map((f) => (\n <FriendChip\n key={f.UserID}\n friend={f}\n done={sentTo.has(f.UserID)}\n selected={picked.has(f.UserID)}\n onClick={() =>\n setPicked((s) => {\n const next = new Set(s);\n if (next.has(f.UserID)) next.delete(f.UserID);\n else next.add(f.UserID);\n return next;\n })\n }\n />\n ))}\n </div>\n <div style={{ display: \"flex\", gap: 8 }}>\n <Button\n tone=\"green\"\n busy={busy}\n disabled={picked.size === 0}\n onClick={() => void send([...picked])}\n style={{ flex: 1 }}\n >\n {t(\"sendTo\")}\n {picked.size > 0 ? ` · ${picked.size}` : \"\"}\n </Button>\n <Button\n tone=\"gold\"\n attract={open.length > 0}\n busy={busy}\n disabled={open.length === 0}\n onClick={() => void send(open.map((f) => f.UserID))}\n style={{ flex: 1 }}\n >\n {t(\"toEveryone\")}\n </Button>\n </div>\n </div>\n );\n}\n\nfunction Transfer({\n defs,\n friends,\n status,\n initialFriend,\n onSent,\n onDone,\n}: {\n defs: MailboxDefinitions;\n friends: Friend[];\n status: MailboxGiftStatusResponse | null;\n initialFriend?: string;\n onSent: () => Promise<void>;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const toast = useToast();\n const state = useUserState();\n const catalog = useCatalog();\n const [to, setTo] = useState<string | null>(\n initialFriend && friends.some((f) => f.UserID === initialFriend)\n ? initialFriend\n : null,\n );\n const [amounts, setAmounts] = useState<Record<string, number>>({});\n const [note, setNote] = useState(\"\");\n const [ask, setAsk] = useState(false);\n // One key per attempt: a retry after a timeout reuses it, so the server never charges twice.\n const requestID = useRef(newRequestID());\n const options = useMemo(\n () =>\n transferOptions(\n defs.Gifts?.Transfer?.AllowedResources,\n state?.InventoryV2 as TransferInventory | undefined,\n ),\n [defs, state?.InventoryV2],\n );\n const entries = transferEntries(options, amounts);\n const friend = friends.find((f) => f.UserID === to) ?? null;\n const now = useNow(30_000);\n const fee = feePercent(defs.Gifts?.Transfer?.FeePercent);\n const receives = afterFee(entries, fee);\n const fixedFee = bundleLines(defs.Gifts?.Transfer?.FeeCost);\n\n const send = async () => {\n if (!to) return false;\n const res = await client.mailbox.sendTransfer(to, entries, {\n requestID: requestID.current,\n note: note.trim() || undefined,\n });\n if (!res.ok) {\n toast(words(res.error), \"error\");\n return false;\n }\n requestID.current = newRequestID();\n toast(t(\"transferSent\"), \"success\");\n await onSent();\n onDone();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 10 }}>\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"transferHint\")}\n </div>\n {status?.Transfer ? (\n <div\n style={{\n display: \"flex\",\n gap: 8,\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n <span>\n {t(\"leftInPeriod\")}: {status.Transfer.RemainingInPeriod}\n </span>\n <Timer\n at={status.Transfer.ResetsAt}\n now={now}\n label={t(\"resetsIn\")}\n />\n </div>\n ) : null}\n {fee > 0 || fixedFee.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n justifyContent: \"center\",\n ...outlined,\n fontSize: 12,\n }}\n >\n {fee > 0 ? (\n <span>{t(\"commission\").replace(\"{p}\", String(fee))}</span>\n ) : null}\n {fixedFee.length > 0 ? (\n <>\n <span>{t(\"fixedFee\")}:</span>\n <ResourceList lines={fixedFee} size={18} />\n </>\n ) : null}\n </div>\n ) : null}\n <div style={{ ...outlined, fontSize: 13 }}>{t(\"pickFriend\")}</div>\n <div\n style={{ display: \"grid\", gap: 6, maxHeight: 180, overflowY: \"auto\" }}\n >\n {friends.map((f) => (\n <FriendChip\n key={f.UserID}\n friend={f}\n selected={to === f.UserID}\n onClick={() => setTo(f.UserID)}\n />\n ))}\n </div>\n <div style={{ display: \"grid\", gap: 6 }}>\n {options.map((o) => (\n <TransferRow\n key={o.key}\n option={o}\n value={amounts[o.key] ?? 0}\n name={\n o.kind === \"currency\"\n ? catalog.currencyName(o.id)\n : catalog.itemName(o.id)\n }\n onChange={(n) => setAmounts((a) => ({ ...a, [o.key]: n }))}\n />\n ))}\n </div>\n <input\n value={note}\n onChange={(e) => setNote(e.target.value)}\n maxLength={120}\n placeholder={t(\"notePlaceholder\")}\n style={{\n ...panel,\n padding: \"10px 12px\",\n fontSize: 14,\n color: v.text,\n outline: \"none\",\n }}\n />\n <Button\n tone=\"green\"\n disabled={!to || entries.length === 0 || receives.length === 0}\n onClick={() =>\n entries.length === 0 ? toast(t(\"nothingToSend\")) : setAsk(true)\n }\n >\n {t(\"sendTo\")}\n </Button>\n {ask && friend ? (\n <ConfirmPopup\n title={t(\"transfer\")}\n tone=\"gold\"\n onClose={() => setAsk(false)}\n onConfirm={send}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <div style={{ ...outlined, fontSize: 14, textAlign: \"center\" }}>\n {t(\"transferAsk\").replace(\"{name}\", friendName(friend))}\n </div>\n <ResourceList\n lines={bundleLines({ Entries: entries })}\n size={28}\n gap={12}\n />\n {fee > 0 ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"friendGets\")}\n </div>\n <ResourceList\n lines={bundleLines({ Entries: receives })}\n size={22}\n gap={10}\n />\n </div>\n ) : null}\n {fixedFee.length > 0 ? (\n <div\n style={{\n display: \"flex\",\n gap: 6,\n alignItems: \"center\",\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n }}\n >\n {t(\"fixedFee\")}: <ResourceList lines={fixedFee} size={18} />\n </div>\n ) : null}\n </div>\n </ConfirmPopup>\n ) : null}\n </div>\n );\n}\n\nfunction TransferRow({\n option,\n value,\n name,\n onChange,\n}: {\n option: TransferOption;\n value: number;\n name: string;\n onChange: (n: number) => void;\n}): ReactNode {\n const disabled = option.max <= 0;\n const set = (n: number) =>\n onChange(Math.max(0, Math.min(option.max, Math.floor(n) || 0)));\n return (\n <div\n style={{\n ...panel,\n padding: 8,\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n opacity: disabled ? 0.5 : 1,\n }}\n >\n <ResourceBadge\n line={{ kind: option.kind, id: option.id, amount: option.owned }}\n size={24}\n />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 13 }}>{name}</div>\n <div style={{ ...outlined, fontSize: 11, color: v.textDim }}>\n {t(\"owned\")}: {option.owned}\n </div>\n </div>\n <input\n type=\"number\"\n min={0}\n max={option.max}\n disabled={disabled}\n value={value || \"\"}\n placeholder=\"0\"\n onChange={(e) => set(Number(e.target.value))}\n style={{\n ...panel,\n width: 80,\n padding: \"6px 8px\",\n fontSize: 15,\n textAlign: \"center\",\n color: v.text,\n outline: \"none\",\n }}\n />\n <Button\n size=\"sm\"\n tone=\"grey\"\n disabled={disabled}\n onClick={() => set(option.max)}\n >\n max\n </Button>\n </div>\n );\n}\n"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "model.ts",
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
"version": "0.1.1"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@idosgames/core": "0.
|
|
40
|
-
"@idosgames/module-sdk": "0.8.
|
|
41
|
-
"@idosgames/react": "0.4.
|
|
39
|
+
"@idosgames/core": "0.21.0",
|
|
40
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
41
|
+
"@idosgames/react": "0.4.3",
|
|
42
42
|
"react": "19.2.7"
|
|
43
43
|
},
|
|
44
|
-
"contentHash": "
|
|
44
|
+
"contentHash": "0ba5427e5a9bd1f0c9f2a7375e210a832525cbd4ecda0e6641c0b9c743a113fc",
|
|
45
45
|
"files": [
|
|
46
46
|
{
|
|
47
47
|
"path": "i18n.ts",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"path": "MarketplaceScreen.tsx",
|
|
56
|
-
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n MarketplaceDefinitions,\n MarketplaceGroupedOfferView,\n MarketplaceHistoryEntryView,\n MarketplaceMyStateResponse,\n MarketplaceOfferView,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n IconButton,\n ItemTile,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n bundleLines,\n canAfford,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n plural,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n auctionDurations,\n commissionPercent,\n listingDurations,\n minNextBid,\n offerPrice,\n groupCounts,\n priceBundle,\n sellableItems,\n sortOffers,\n tradableCurrencies,\n type SellableItem,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace (Unity Alikhan/Marketplace): a window of tabs — the market by item, my lots, my\n// bids, trades, things to claim, history — and everything with detail in popups: an item's lots, one\n// lot (buy / bid / take back), and selling (pick an item → listing or auction → price and duration).\n// The server holds every escrow and settles every trade.\n\ntype Tab = \"market\" | \"mine\" | \"bids\" | \"trades\" | \"claims\" | \"history\";\n\nexport function makeMarketplaceScreen(features: FeatureRegistry) {\n return function MarketplaceScreen(_: FeatureScreenProps): ReactNode {\n return <Marketplace features={features} />;\n };\n}\n\nfunction Marketplace({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<{\n defs: MarketplaceDefinitions | null;\n open: boolean;\n } | null>(null);\n const [mine, setMine] = useState<MarketplaceMyStateResponse | null>(null);\n const [tab, setTab] = useState<Tab>(\"market\");\n const [selling, setSelling] = useState(false);\n const [offer, setOffer] = useState<MarketplaceOfferView | null>(null);\n const [itemID, setItemID] = useState<string | null>(null);\n const [version, setVersion] = useState(0);\n\n const loadMine = useCallback(async () => {\n const res = await client.marketplace.getMyState();\n if (res.ok) {\n setMine(res.data);\n features.setBadge(\n \"marketplace\",\n (res.data.Claimables?.length ?? 0) +\n (res.data.IncomingTrades?.length ?? 0),\n );\n }\n }, [client, features]);\n\n useEffect(() => {\n void client.marketplace.getDefinitions().then((res) =>\n setDefs(\n res.ok\n ? {\n defs: res.data.Definitions ?? null,\n open:\n res.data.IsOpenNow !== false && res.data.GatePassed !== false,\n }\n : { defs: null, open: false },\n ),\n );\n void client.user.getUserInventory();\n void loadMine();\n }, [client, loadMine]);\n\n const refresh = () => {\n setVersion((n) => n + 1);\n void loadMine();\n };\n\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs.defs || defs.defs.Enabled === false)\n return <EmptyState glyph=\"market\" text={t(\"disabled\")} />;\n\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"market\", label: t(\"market\") },\n { id: \"mine\", label: t(\"myLots\"), badge: 0 },\n { id: \"bids\", label: t(\"myBids\") },\n ...((mine?.IncomingTrades?.length ?? 0) +\n (mine?.OutgoingTrades?.length ?? 0) >\n 0\n ? [\n {\n id: \"trades\" as const,\n label: t(\"trades\"),\n badge: mine?.IncomingTrades?.length ?? 0,\n },\n ]\n : []),\n { id: \"claims\", label: t(\"claims\"), badge: mine?.Claimables?.length ?? 0 },\n { id: \"history\", label: t(\"history\") },\n ];\n\n return (\n // minmax(0, 1fr): the tab row scrolls instead of widening the column past the screen.\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 14,\n }}\n >\n {!defs.open ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n color: v.gold,\n }}\n >\n {t(\"closed\")}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n <Button\n tone=\"gold\"\n size=\"sm\"\n disabled={!defs.open}\n onClick={() => setSelling(true)}\n data-tutorial-anchor=\"market:sell\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"sell\")}\n </Button>\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"market\" ? <MarketTab onPick={setItemID} /> : null}\n {tab === \"mine\" ? (\n <OfferList\n offers={[...(mine?.MyOffers ?? []), ...(mine?.MyBuyOrders ?? [])]}\n onPick={setOffer}\n />\n ) : null}\n {tab === \"bids\" ? (\n <OfferList offers={mine?.MyBids ?? []} onPick={setOffer} />\n ) : null}\n {tab === \"trades\" ? <Trades mine={mine} onDone={refresh} /> : null}\n {tab === \"claims\" ? (\n <Claims offers={mine?.Claimables ?? []} onDone={refresh} />\n ) : null}\n {tab === \"history\" ? <History /> : null}\n </div>\n {itemID ? (\n <ItemOffersPopup\n itemID={itemID}\n version={version}\n onPick={(o) => setOffer(o)}\n onClose={() => setItemID(null)}\n />\n ) : null}\n {offer ? (\n <OfferPopup\n offer={offer}\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setOffer(null)}\n />\n ) : null}\n {selling ? (\n <SellPopup\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setSelling(false)}\n />\n ) : null}\n </div>\n );\n}\n\n// ── Market ───────────────────────────────────────────────────────────────────────────────────\n\nfunction MarketTab({\n onPick,\n}: {\n onPick: (itemID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [groups, setGroups] = useState<MarketplaceGroupedOfferView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getGroupedOffers()\n .then((res) => setGroups(res.ok ? (res.data.Groups ?? []) : []));\n }, [client]);\n if (!groups)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const items = groups.filter((g) => g.GoodsItemID);\n if (items.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GoodsItemID!}\n onClick={() => onPick(g.GoodsItemID!)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(i),\n }}\n >\n <ItemTile itemID={g.GoodsItemID!} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(g.GoodsItemID!)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {countsLine(groupCounts(g))}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction countsLine({\n listings,\n auctions,\n}: {\n listings: number;\n auctions: number;\n}): string {\n return [\n listings > 0\n ? `${formatAmount(listings)} ${plural(listings, t(\"lots\"))}`\n : \"\",\n auctions > 0\n ? `${formatAmount(auctions)} ${plural(auctions, t(\"auctions\"))}`\n : \"\",\n ]\n .filter(Boolean)\n .join(\" · \");\n}\n\nfunction OfferRow({\n offer,\n onPick,\n index,\n}: {\n offer: MarketplaceOfferView;\n onPick: (o: MarketplaceOfferView) => void;\n index: number;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const now = useNow();\n const me = client.auth.context?.userID;\n const auction = offer.OfferType === \"Auction\";\n const bid = Number(\n offer.Auction?.CurrentBid ?? offer.Auction?.StartingBid ?? 0,\n );\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n return (\n <Card\n onClick={() => onPick(offer)}\n highlight={auction && offer.Auction?.CurrentBidderID === me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(index),\n }}\n >\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={52}\n />\n ) : (\n <Icon glyph=\"coin\" size={40} />\n )}\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 2 }}>\n <span style={{ ...outlined, fontSize: 14 }}>\n {offer.GoodsItemID\n ? catalog.itemName(offer.GoodsItemID)\n : offer.GoodsCurrencyID}\n </span>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {auction\n ? t(\"auction\")\n : offer.OfferType === \"Listing\"\n ? t(\"listing\")\n : offer.OfferType}{\" \"}\n ·{\" \"}\n {offer.CreatorUserID === me\n ? t(\"you\")\n : (offer.CreatorPublicData?.Username ??\n `${t(\"seller\")} ${String(offer.CreatorUserID ?? \"\").slice(-4)}`)}\n </span>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n </div>\n <ResourceList lines={auction ? bidLine : offerPrice(offer)} size={18} />\n </Card>\n );\n}\n\nfunction OfferList({\n offers,\n onPick,\n}: {\n offers: MarketplaceOfferView[];\n onPick: (o: MarketplaceOfferView) => void;\n}): ReactNode {\n if (offers.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.map((o, i) => (\n <OfferRow key={o.OfferID ?? i} offer={o} onPick={onPick} index={i} />\n ))}\n </div>\n );\n}\n\n// `version` bumps after any trade on the screen: the list re-reads, so a bought or withdrawn lot leaves it.\nfunction ItemOffersPopup({\n itemID,\n version,\n onPick,\n onClose,\n}: {\n itemID: string;\n version: number;\n onPick: (o: MarketplaceOfferView) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [offers, setOffers] = useState<MarketplaceOfferView[] | null>(null);\n useEffect(() => {\n void client.marketplace\n .getOffersByItem(itemID)\n .then((res) =>\n setOffers(res.ok ? sortOffers(res.data.Offers ?? []) : []),\n );\n }, [client, itemID, version]);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose} width={480}>\n {offers === null ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : (\n <OfferList offers={offers} onPick={onPick} />\n )}\n </Popup>\n );\n}\n\n// ── One lot ──────────────────────────────────────────────────────────────────────────────────\n\nfunction OfferPopup({\n offer,\n defs,\n onDone,\n onClose,\n}: {\n offer: MarketplaceOfferView;\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const state = useUserState();\n const now = useNow();\n const me = client.auth.context?.userID;\n const own = offer.CreatorUserID === me;\n const auction = offer.OfferType === \"Auction\";\n const ended = offer.ExpiresAt\n ? new Date(offer.ExpiresAt).getTime() <= now\n : false;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const price = offerPrice(offer);\n const [bid, setBid] = useState(minNextBid(offer, defs));\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n\n return (\n <Popup\n title={\n offer.GoodsItemID ? catalog.itemName(offer.GoodsItemID) : t(\"market\")\n }\n onClose={onClose}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={96}\n />\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {auction ? t(\"auction\") : t(\"listing\")} ·{\" \"}\n {own ? t(\"you\") : (offer.CreatorPublicData?.Username ?? t(\"seller\"))}\n </div>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n {auction ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {offer.Auction?.CurrentBidderID\n ? t(\"currentBid\")\n : t(\"startingBid\")}\n </span>\n <ResourceList\n lines={\n offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\",\n id: offer.Auction.BidCurrencyID,\n amount: Number(\n offer.Auction.CurrentBid ??\n offer.Auction.StartingBid ??\n 0,\n ),\n },\n ]\n : []\n }\n size={24}\n />\n {offer.Auction?.CurrentBidderID === me ? (\n <span style={{ ...outlined, fontSize: 13, color: v.green }}>\n {t(\"leading\")}\n </span>\n ) : null}\n </div>\n ) : (\n <ResourceList lines={price} size={24} />\n )}\n </div>\n {own ? (\n auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n // An auction runs to its end: bids are escrowed, so the seller cannot pull it.\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"auctionRuns\")}\n </div>\n )\n ) : (\n <Act\n label={t(\"cancel\")}\n tone=\"grey\"\n run={() => client.marketplace.cancelListing(offer.OfferID!)}\n onDone={onDone}\n />\n )\n ) : auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n <>\n <Stepper\n value={bid}\n min={minNextBid(offer, defs)}\n onChange={setBid}\n />\n <Act\n label={\n <>\n {t(\"bid\")} <ResourceList lines={bidLine} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(bidLine, balances)}\n run={() => client.marketplace.placeBid(offer.OfferID!, bid)}\n onDone={onDone}\n sound=\"purchase\"\n />\n </>\n )\n ) : (\n <Act\n label={\n <>\n {t(\"buy\")} <ResourceList lines={price} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(price, balances)}\n run={() => client.marketplace.buy(offer.OfferID!)}\n onDone={onDone}\n sound=\"purchase\"\n celebrateGoods={offer}\n />\n )}\n </Popup>\n );\n}\n\ntype Result =\n { ok: true; data: unknown } | { ok: false; error?: string | null };\n\n/** A button that runs one marketplace call, then closes the popup and celebrates what came in. */\nfunction Act({\n label,\n tone,\n run,\n onDone,\n disabled = false,\n sound = \"claim\",\n celebrateGoods,\n}: {\n label: ReactNode;\n tone: \"gold\" | \"grey\" | \"green\" | \"red\";\n run: () => Promise<Result>;\n onDone: () => void;\n disabled?: boolean;\n sound?: \"claim\" | \"purchase\";\n celebrateGoods?: MarketplaceOfferView;\n}): ReactNode {\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone={tone}\n size=\"lg\"\n disabled={disabled}\n busy={busy}\n onClick={() => {\n setBusy(true);\n void run().then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(sound);\n close();\n onDone();\n const granted = grantedBy(res.data);\n if (granted.length > 0) celebrate(granted);\n else if (celebrateGoods?.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: celebrateGoods.GoodsItemID,\n amount: Number(celebrateGoods.GoodsAmount ?? 1),\n },\n ]);\n });\n }}\n >\n {label}\n </Button>\n );\n}\n\nfunction Stepper({\n value,\n min,\n onChange,\n max,\n}: {\n value: number;\n min: number;\n onChange: (n: number) => void;\n max?: number;\n}): ReactNode {\n const clamp = (n: number) =>\n Math.max(min, max !== undefined ? Math.min(max, n) : n);\n const step = Math.max(1, Math.round(value * 0.1));\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <IconButton\n glyph=\"back\"\n label=\"-\"\n onClick={() => onChange(clamp(value - step))}\n size={36}\n />\n <input\n type=\"number\"\n inputMode=\"numeric\"\n value={value}\n min={min}\n max={max}\n onChange={(e) =>\n onChange(clamp(Math.floor(Number(e.target.value) || min)))\n }\n style={{\n width: 110,\n textAlign: \"center\",\n padding: \"8px 10px\",\n borderRadius: 12,\n border: `2px solid ${v.panelEdge}`,\n background: v.wellSoft,\n color: v.text,\n fontFamily: v.font,\n fontSize: 18,\n fontWeight: 800,\n }}\n />\n <IconButton\n glyph=\"plus\"\n label=\"+\"\n onClick={() => onChange(clamp(value + step))}\n size={36}\n />\n </div>\n );\n}\n\n// ── Trades, claims, history ──────────────────────────────────────────────────────────────────\n\nfunction Trades({\n mine,\n onDone,\n}: {\n mine: MarketplaceMyStateResponse | null;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const celebrate = useCelebrate();\n const incoming = mine?.IncomingTrades ?? [];\n const outgoing = mine?.OutgoingTrades ?? [];\n const answer = async (offer: MarketplaceOfferView, accept: boolean) => {\n const res = await client.marketplace.respondDirectTrade(\n offer.OfferID!,\n accept,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (accept && offer.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: offer.GoodsItemID,\n amount: Number(offer.GoodsAmount ?? 1),\n },\n ]);\n onDone();\n };\n if (incoming.length + outgoing.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {incoming.map((o) => (\n <Card key={o.OfferID!} highlight style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n {o.GoodsItemID ? (\n <ItemTile itemID={o.GoodsItemID} size={52} />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.GoodsItemID ? catalog.itemName(o.GoodsItemID) : \"\"}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}{\" \"}\n {o.CreatorPublicData?.Username ??\n String(o.CreatorUserID ?? \"\").slice(-6)}{\" \"}\n ·{\" \"}\n {bundleLines(o.Price as never).length ? t(\"wants\") : t(\"gift\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(o.Price as never)} />\n </div>\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={() => void answer(o, false)}>\n {t(\"decline\")}\n </Button>\n <Button tone=\"green\" size=\"sm\" onClick={() => void answer(o, true)}>\n {t(\"accept\")}\n </Button>\n </div>\n </Card>\n ))}\n {outgoing.map((o, i) => (\n <OfferRow\n key={o.OfferID!}\n offer={o}\n index={i}\n onPick={() =>\n void client.marketplace\n .cancelDirectTrade(o.OfferID!)\n .then(() => onDone())\n }\n />\n ))}\n </div>\n );\n}\n\nfunction Claims({\n offers,\n onDone,\n}: {\n offers: MarketplaceOfferView[];\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const claim = async (offer: MarketplaceOfferView | null) => {\n setBusy(offer?.OfferID ?? \"all\");\n const res = offer\n ? offer.OfferType === \"Auction\"\n ? await client.marketplace.claimAuction(offer.OfferID!)\n : await client.marketplace.claimBack(offer.OfferID!)\n : await client.marketplace.claimBack();\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n onDone();\n };\n if (offers.length === 0) return <EmptyState glyph=\"gift\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.length > 1 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"all\"}\n onClick={() => void claim(null)}\n >\n {t(\"claimAll\")}\n </Button>\n ) : null}\n {offers.map((o, i) => (\n <div\n key={o.OfferID!}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 8,\n alignItems: \"center\",\n }}\n >\n <OfferRow offer={o} index={i} onPick={() => void claim(o)} />\n <Button\n tone=\"gold\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void claim(o)}\n >\n {t(\"claim\")}\n </Button>\n </div>\n ))}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const me = client.auth.context?.userID;\n const [entries, setEntries] = useState<MarketplaceHistoryEntryView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getHistory()\n .then((res) => setEntries(res.ok ? (res.data.Entries ?? []) : []));\n }, [client]);\n if (!entries)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (entries.length === 0)\n return <EmptyState glyph=\"clock\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {entries.map((e, i) => (\n <Card\n key={e.OfferID ?? i}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n ...stagger(i),\n }}\n >\n {e.GoodsItemID ? (\n <ItemTile\n itemID={e.GoodsItemID}\n count={Number(e.GoodsAmount ?? 1)}\n size={48}\n />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {e.GoodsItemID ? catalog.itemName(e.GoodsItemID) : \"\"}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: e.SellerUserID === me ? v.green : v.gold,\n }}\n >\n {e.SellerUserID === me ? t(\"sold\") : t(\"bought\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(e.PricePaid as never)} size={16} />\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Selling ──────────────────────────────────────────────────────────────────────────────────\n\nfunction SellPopup({\n defs,\n onDone,\n onClose,\n}: {\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const [picked, setPicked] = useState<SellableItem | null>(null);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string; IsTradable?: boolean | null }\n >;\n const sellable = sellableItems(state?.InventoryV2 as never, items);\n const currencies = tradableCurrencies(catalog.currencies as never);\n const [kind, setKind] = useState<\"listing\" | \"auction\">(\"listing\");\n const [amount, setAmount] = useState(1);\n const [currency, setCurrency] = useState(currencies[0] ?? \"CO\");\n const [price, setPrice] = useState(100);\n const durations =\n kind === \"listing\" ? listingDurations(defs) : auctionDurations(defs);\n const [hours, setHours] = useState(durations[0] ?? 24);\n const auctionsOn = defs.Auctions?.Enabled !== false;\n\n if (!picked)\n return (\n <Popup title={t(\"pickItem\")} onClose={onClose} width={480}>\n {currencies.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"noTradableCurrency\")} />\n ) : sellable.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"nothingToSell\")} />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 10,\n justifyContent: \"center\",\n }}\n >\n {sellable.map((s) => (\n <ItemTile\n key={s.itemID}\n itemID={s.itemID}\n count={s.amount}\n size={72}\n onClick={() => setPicked(s)}\n />\n ))}\n </div>\n )}\n </Popup>\n );\n\n const create = () =>\n kind === \"listing\"\n ? client.marketplace.createListing(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n priceBundle(currency, price) as never,\n hours,\n picked.instances.length ? [picked.instances[0]!] : undefined,\n )\n : client.marketplace.createAuction(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n \"VirtualCurrency\" as never,\n price,\n hours,\n {\n bidCurrencyID: currency,\n itemInstanceIDs: picked.instances.length\n ? [picked.instances[0]!]\n : undefined,\n },\n );\n\n return (\n <Popup\n title={catalog.itemName(picked.itemID)}\n onClose={onClose}\n width={440}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <ItemTile\n itemID={picked.itemID}\n count={picked.instances.length ? 1 : amount}\n size={88}\n />\n </div>\n {auctionsOn ? (\n <Tabs\n tabs={[\n { id: \"listing\", label: t(\"listing\") },\n { id: \"auction\", label: t(\"auction\") },\n ]}\n value={kind}\n onChange={(k) => {\n setKind(k);\n setHours(\n (k === \"listing\"\n ? listingDurations(defs)\n : auctionDurations(defs))[0] ?? 24,\n );\n }}\n />\n ) : null}\n {picked.instances.length === 0 && picked.amount > 1 ? (\n <Field label={t(\"amount\")}>\n <Stepper\n value={amount}\n min={1}\n max={picked.amount}\n onChange={setAmount}\n />\n </Field>\n ) : null}\n <Field label={kind === \"listing\" ? t(\"price\") : t(\"startingBid\")}>\n {currencies.length > 1 ? (\n <Tabs\n tabs={currencies.map((c) => ({\n id: c,\n label: catalog.currencyName(c),\n glyph: catalog.iconOf({ kind: \"currency\", id: c, amount: 0 }),\n }))}\n value={currency}\n onChange={setCurrency}\n />\n ) : null}\n <Stepper value={price} min={1} onChange={setPrice} />\n </Field>\n <Field label={t(\"duration\")}>\n <Tabs\n tabs={durations.map((h) => ({\n id: String(h),\n label: `${h} ${t(\"hours\")}`,\n }))}\n value={String(hours)}\n onChange={(h) => setHours(Number(h))}\n />\n </Field>\n {commissionPercent(defs) > 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"commission\")}: {commissionPercent(defs)}%\n </div>\n ) : null}\n <Act label={t(\"create\")} tone=\"gold\" run={create} onDone={onDone} />\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
|
|
56
|
+
"content": "import { useCallback, useEffect, useState, type ReactNode } from \"react\";\nimport type {\n MarketplaceDefinitions,\n MarketplaceGroupedOfferView,\n MarketplaceHistoryEntryView,\n MarketplaceMyStateResponse,\n MarketplaceOfferView,\n} from \"@idosgames/core\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient, useUserState } from \"@idosgames/react\";\nimport {\n Button,\n Card,\n Center,\n EmptyState,\n Icon,\n IconButton,\n ItemTile,\n Popup,\n ResourceList,\n Spinner,\n Tabs,\n Timer,\n bundleLines,\n canAfford,\n errorText,\n formatAmount,\n grantedBy,\n outlined,\n panel,\n plural,\n useCatalog,\n useCelebrate,\n useNow,\n usePopupClose,\n useSound,\n useStagger,\n useToast,\n v,\n type ItemDefinitionView,\n} from \"@idosgames/react/ui\";\nimport {\n auctionDurations,\n commissionPercent,\n listingDurations,\n minNextBid,\n offerPrice,\n groupCounts,\n priceBundle,\n sellableItems,\n sortOffers,\n tradableCurrencies,\n type SellableItem,\n} from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace (Unity Alikhan/Marketplace): a window of tabs — the market by item, my lots, my\n// bids, trades, things to claim, history — and everything with detail in popups: an item's lots, one\n// lot (buy / bid / take back), and selling (pick an item → listing or auction → price and duration).\n// The server holds every escrow and settles every trade.\n\ntype Tab = \"market\" | \"mine\" | \"bids\" | \"trades\" | \"claims\" | \"history\";\n\nexport function makeMarketplaceScreen(features: FeatureRegistry) {\n return function MarketplaceScreen(_: FeatureScreenProps): ReactNode {\n return <Marketplace features={features} />;\n };\n}\n\nfunction Marketplace({ features }: { features: FeatureRegistry }): ReactNode {\n const client = useIDosGamesClient();\n const [defs, setDefs] = useState<{\n defs: MarketplaceDefinitions | null;\n open: boolean;\n } | null>(null);\n const [mine, setMine] = useState<MarketplaceMyStateResponse | null>(null);\n const [tab, setTab] = useState<Tab>(\"market\");\n const [selling, setSelling] = useState(false);\n const [offer, setOffer] = useState<MarketplaceOfferView | null>(null);\n const [itemID, setItemID] = useState<string | null>(null);\n const [version, setVersion] = useState(0);\n\n const loadMine = useCallback(async () => {\n const res = await client.marketplace.getMyState();\n if (res.ok) {\n setMine(res.data);\n features.setBadge(\n \"marketplace\",\n (res.data.Claimables?.length ?? 0) +\n (res.data.IncomingTrades?.length ?? 0),\n );\n }\n }, [client, features]);\n\n useEffect(() => {\n void client.marketplace.getDefinitions().then((res) =>\n setDefs(\n res.ok\n ? {\n defs: res.data.Definitions ?? null,\n open:\n res.data.IsOpenNow !== false && res.data.GatePassed !== false,\n }\n : { defs: null, open: false },\n ),\n );\n void client.cache.ensureState([\"InventoryV2\"], { maxAgeMs: 60_000 });\n void loadMine();\n }, [client, loadMine]);\n\n const refresh = () => {\n setVersion((n) => n + 1);\n void loadMine();\n };\n\n if (!defs)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (!defs.defs || defs.defs.Enabled === false)\n return <EmptyState glyph=\"market\" text={t(\"disabled\")} />;\n\n const tabs: Array<{ id: Tab; label: string; badge?: number }> = [\n { id: \"market\", label: t(\"market\") },\n { id: \"mine\", label: t(\"myLots\"), badge: 0 },\n { id: \"bids\", label: t(\"myBids\") },\n ...((mine?.IncomingTrades?.length ?? 0) +\n (mine?.OutgoingTrades?.length ?? 0) >\n 0\n ? [\n {\n id: \"trades\" as const,\n label: t(\"trades\"),\n badge: mine?.IncomingTrades?.length ?? 0,\n },\n ]\n : []),\n { id: \"claims\", label: t(\"claims\"), badge: mine?.Claimables?.length ?? 0 },\n { id: \"history\", label: t(\"history\") },\n ];\n\n return (\n // minmax(0, 1fr): the tab row scrolls instead of widening the column past the screen.\n <div\n style={{\n display: \"grid\",\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gap: 14,\n }}\n >\n {!defs.open ? (\n <div\n style={{\n ...panel,\n padding: 10,\n ...outlined,\n fontSize: 13,\n textAlign: \"center\",\n color: v.gold,\n }}\n >\n {t(\"closed\")}\n </div>\n ) : null}\n <div style={{ display: \"flex\", gap: 10, alignItems: \"center\" }}>\n <div style={{ flex: 1, minWidth: 0 }}>\n <Tabs tabs={tabs} value={tab} onChange={setTab} />\n </div>\n <Button\n tone=\"gold\"\n size=\"sm\"\n disabled={!defs.open}\n onClick={() => setSelling(true)}\n data-tutorial-anchor=\"market:sell\"\n >\n <Icon glyph=\"plus\" size={16} /> {t(\"sell\")}\n </Button>\n </div>\n <div key={`${tab}:${version}`}>\n {tab === \"market\" ? <MarketTab onPick={setItemID} /> : null}\n {tab === \"mine\" ? (\n <OfferList\n offers={[...(mine?.MyOffers ?? []), ...(mine?.MyBuyOrders ?? [])]}\n onPick={setOffer}\n />\n ) : null}\n {tab === \"bids\" ? (\n <OfferList offers={mine?.MyBids ?? []} onPick={setOffer} />\n ) : null}\n {tab === \"trades\" ? <Trades mine={mine} onDone={refresh} /> : null}\n {tab === \"claims\" ? (\n <Claims offers={mine?.Claimables ?? []} onDone={refresh} />\n ) : null}\n {tab === \"history\" ? <History /> : null}\n </div>\n {itemID ? (\n <ItemOffersPopup\n itemID={itemID}\n version={version}\n onPick={(o) => setOffer(o)}\n onClose={() => setItemID(null)}\n />\n ) : null}\n {offer ? (\n <OfferPopup\n offer={offer}\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setOffer(null)}\n />\n ) : null}\n {selling ? (\n <SellPopup\n defs={defs.defs}\n onDone={refresh}\n onClose={() => setSelling(false)}\n />\n ) : null}\n </div>\n );\n}\n\n// ── Market ───────────────────────────────────────────────────────────────────────────────────\n\nfunction MarketTab({\n onPick,\n}: {\n onPick: (itemID: string) => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const [groups, setGroups] = useState<MarketplaceGroupedOfferView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getGroupedOffers()\n .then((res) => setGroups(res.ok ? (res.data.Groups ?? []) : []));\n }, [client]);\n if (!groups)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n const items = groups.filter((g) => g.GoodsItemID);\n if (items.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {items.map((g, i) => (\n <Card\n key={g.GoodsItemID!}\n onClick={() => onPick(g.GoodsItemID!)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(i),\n }}\n >\n <ItemTile itemID={g.GoodsItemID!} size={56} />\n <div style={{ flex: 1, minWidth: 0 }}>\n <div style={{ ...outlined, fontSize: 15 }}>\n {catalog.itemName(g.GoodsItemID!)}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {countsLine(groupCounts(g))}\n </div>\n </div>\n </Card>\n ))}\n </div>\n );\n}\n\nfunction countsLine({\n listings,\n auctions,\n}: {\n listings: number;\n auctions: number;\n}): string {\n return [\n listings > 0\n ? `${formatAmount(listings)} ${plural(listings, t(\"lots\"))}`\n : \"\",\n auctions > 0\n ? `${formatAmount(auctions)} ${plural(auctions, t(\"auctions\"))}`\n : \"\",\n ]\n .filter(Boolean)\n .join(\" · \");\n}\n\nfunction OfferRow({\n offer,\n onPick,\n index,\n}: {\n offer: MarketplaceOfferView;\n onPick: (o: MarketplaceOfferView) => void;\n index: number;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const now = useNow();\n const me = client.auth.context?.userID;\n const auction = offer.OfferType === \"Auction\";\n const bid = Number(\n offer.Auction?.CurrentBid ?? offer.Auction?.StartingBid ?? 0,\n );\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n return (\n <Card\n onClick={() => onPick(offer)}\n highlight={auction && offer.Auction?.CurrentBidderID === me}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n padding: 10,\n ...stagger(index),\n }}\n >\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={52}\n />\n ) : (\n <Icon glyph=\"coin\" size={40} />\n )}\n <div style={{ flex: 1, minWidth: 0, display: \"grid\", gap: 2 }}>\n <span style={{ ...outlined, fontSize: 14 }}>\n {offer.GoodsItemID\n ? catalog.itemName(offer.GoodsItemID)\n : offer.GoodsCurrencyID}\n </span>\n <span style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {auction\n ? t(\"auction\")\n : offer.OfferType === \"Listing\"\n ? t(\"listing\")\n : offer.OfferType}{\" \"}\n ·{\" \"}\n {offer.CreatorUserID === me\n ? t(\"you\")\n : (offer.CreatorPublicData?.Username ??\n `${t(\"seller\")} ${String(offer.CreatorUserID ?? \"\").slice(-4)}`)}\n </span>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n </div>\n <ResourceList lines={auction ? bidLine : offerPrice(offer)} size={18} />\n </Card>\n );\n}\n\nfunction OfferList({\n offers,\n onPick,\n}: {\n offers: MarketplaceOfferView[];\n onPick: (o: MarketplaceOfferView) => void;\n}): ReactNode {\n if (offers.length === 0)\n return <EmptyState glyph=\"market\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.map((o, i) => (\n <OfferRow key={o.OfferID ?? i} offer={o} onPick={onPick} index={i} />\n ))}\n </div>\n );\n}\n\n// `version` bumps after any trade on the screen: the list re-reads, so a bought or withdrawn lot leaves it.\nfunction ItemOffersPopup({\n itemID,\n version,\n onPick,\n onClose,\n}: {\n itemID: string;\n version: number;\n onPick: (o: MarketplaceOfferView) => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const [offers, setOffers] = useState<MarketplaceOfferView[] | null>(null);\n useEffect(() => {\n void client.marketplace\n .getOffersByItem(itemID)\n .then((res) =>\n setOffers(res.ok ? sortOffers(res.data.Offers ?? []) : []),\n );\n }, [client, itemID, version]);\n return (\n <Popup title={catalog.itemName(itemID)} onClose={onClose} width={480}>\n {offers === null ? (\n <Center minHeight={120}>\n <Spinner />\n </Center>\n ) : (\n <OfferList offers={offers} onPick={onPick} />\n )}\n </Popup>\n );\n}\n\n// ── One lot ──────────────────────────────────────────────────────────────────────────────────\n\nfunction OfferPopup({\n offer,\n defs,\n onDone,\n onClose,\n}: {\n offer: MarketplaceOfferView;\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const state = useUserState();\n const now = useNow();\n const me = client.auth.context?.userID;\n const own = offer.CreatorUserID === me;\n const auction = offer.OfferType === \"Auction\";\n const ended = offer.ExpiresAt\n ? new Date(offer.ExpiresAt).getTime() <= now\n : false;\n const balances = state?.InventoryV2?.VirtualCurrencies as\n Record<string, { Amount?: number }> | undefined;\n const price = offerPrice(offer);\n const [bid, setBid] = useState(minNextBid(offer, defs));\n const bidLine = offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\" as const,\n id: offer.Auction.BidCurrencyID,\n amount: bid,\n },\n ]\n : [];\n\n return (\n <Popup\n title={\n offer.GoodsItemID ? catalog.itemName(offer.GoodsItemID) : t(\"market\")\n }\n onClose={onClose}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n {offer.GoodsItemID ? (\n <ItemTile\n itemID={offer.GoodsItemID}\n count={Number(offer.GoodsAmount ?? 1)}\n size={96}\n />\n ) : null}\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {auction ? t(\"auction\") : t(\"listing\")} ·{\" \"}\n {own ? t(\"you\") : (offer.CreatorPublicData?.Username ?? t(\"seller\"))}\n </div>\n {offer.ExpiresAt ? (\n <Timer at={offer.ExpiresAt} now={now} label={t(\"endsIn\")} />\n ) : null}\n {auction ? (\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 4 }}>\n <span style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {offer.Auction?.CurrentBidderID\n ? t(\"currentBid\")\n : t(\"startingBid\")}\n </span>\n <ResourceList\n lines={\n offer.Auction?.BidCurrencyID\n ? [\n {\n kind: \"currency\",\n id: offer.Auction.BidCurrencyID,\n amount: Number(\n offer.Auction.CurrentBid ??\n offer.Auction.StartingBid ??\n 0,\n ),\n },\n ]\n : []\n }\n size={24}\n />\n {offer.Auction?.CurrentBidderID === me ? (\n <span style={{ ...outlined, fontSize: 13, color: v.green }}>\n {t(\"leading\")}\n </span>\n ) : null}\n </div>\n ) : (\n <ResourceList lines={price} size={24} />\n )}\n </div>\n {own ? (\n auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n // An auction runs to its end: bids are escrowed, so the seller cannot pull it.\n <div\n style={{\n ...outlined,\n fontSize: 13,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"auctionRuns\")}\n </div>\n )\n ) : (\n <Act\n label={t(\"cancel\")}\n tone=\"grey\"\n run={() => client.marketplace.cancelListing(offer.OfferID!)}\n onDone={onDone}\n />\n )\n ) : auction ? (\n ended ? (\n <Act\n label={t(\"claim\")}\n tone=\"gold\"\n run={() => client.marketplace.claimAuction(offer.OfferID!)}\n onDone={onDone}\n />\n ) : (\n <>\n <Stepper\n value={bid}\n min={minNextBid(offer, defs)}\n onChange={setBid}\n />\n <Act\n label={\n <>\n {t(\"bid\")} <ResourceList lines={bidLine} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(bidLine, balances)}\n run={() => client.marketplace.placeBid(offer.OfferID!, bid)}\n onDone={onDone}\n sound=\"purchase\"\n />\n </>\n )\n ) : (\n <Act\n label={\n <>\n {t(\"buy\")} <ResourceList lines={price} size={18} />\n </>\n }\n tone=\"gold\"\n disabled={!canAfford(price, balances)}\n run={() => client.marketplace.buy(offer.OfferID!)}\n onDone={onDone}\n sound=\"purchase\"\n celebrateGoods={offer}\n />\n )}\n </Popup>\n );\n}\n\ntype Result =\n { ok: true; data: unknown } | { ok: false; error?: string | null };\n\n/** A button that runs one marketplace call, then closes the popup and celebrates what came in. */\nfunction Act({\n label,\n tone,\n run,\n onDone,\n disabled = false,\n sound = \"claim\",\n celebrateGoods,\n}: {\n label: ReactNode;\n tone: \"gold\" | \"grey\" | \"green\" | \"red\";\n run: () => Promise<Result>;\n onDone: () => void;\n disabled?: boolean;\n sound?: \"claim\" | \"purchase\";\n celebrateGoods?: MarketplaceOfferView;\n}): ReactNode {\n const close = usePopupClose();\n const celebrate = useCelebrate();\n const toast = useToast();\n const play = useSound();\n const [busy, setBusy] = useState(false);\n return (\n <Button\n tone={tone}\n size=\"lg\"\n disabled={disabled}\n busy={busy}\n onClick={() => {\n setBusy(true);\n void run().then((res) => {\n setBusy(false);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n play(sound);\n close();\n onDone();\n const granted = grantedBy(res.data);\n if (granted.length > 0) celebrate(granted);\n else if (celebrateGoods?.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: celebrateGoods.GoodsItemID,\n amount: Number(celebrateGoods.GoodsAmount ?? 1),\n },\n ]);\n });\n }}\n >\n {label}\n </Button>\n );\n}\n\nfunction Stepper({\n value,\n min,\n onChange,\n max,\n}: {\n value: number;\n min: number;\n onChange: (n: number) => void;\n max?: number;\n}): ReactNode {\n const clamp = (n: number) =>\n Math.max(min, max !== undefined ? Math.min(max, n) : n);\n const step = Math.max(1, Math.round(value * 0.1));\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n justifyContent: \"center\",\n }}\n >\n <IconButton\n glyph=\"back\"\n label=\"-\"\n onClick={() => onChange(clamp(value - step))}\n size={36}\n />\n <input\n type=\"number\"\n inputMode=\"numeric\"\n value={value}\n min={min}\n max={max}\n onChange={(e) =>\n onChange(clamp(Math.floor(Number(e.target.value) || min)))\n }\n style={{\n width: 110,\n textAlign: \"center\",\n padding: \"8px 10px\",\n borderRadius: 12,\n border: `2px solid ${v.panelEdge}`,\n background: v.wellSoft,\n color: v.text,\n fontFamily: v.font,\n fontSize: 18,\n fontWeight: 800,\n }}\n />\n <IconButton\n glyph=\"plus\"\n label=\"+\"\n onClick={() => onChange(clamp(value + step))}\n size={36}\n />\n </div>\n );\n}\n\n// ── Trades, claims, history ──────────────────────────────────────────────────────────────────\n\nfunction Trades({\n mine,\n onDone,\n}: {\n mine: MarketplaceMyStateResponse | null;\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const toast = useToast();\n const celebrate = useCelebrate();\n const incoming = mine?.IncomingTrades ?? [];\n const outgoing = mine?.OutgoingTrades ?? [];\n const answer = async (offer: MarketplaceOfferView, accept: boolean) => {\n const res = await client.marketplace.respondDirectTrade(\n offer.OfferID!,\n accept,\n );\n if (!res.ok) return toast(errorText(res.error), \"error\");\n if (accept && offer.GoodsItemID)\n celebrate([\n {\n kind: \"item\",\n id: offer.GoodsItemID,\n amount: Number(offer.GoodsAmount ?? 1),\n },\n ]);\n onDone();\n };\n if (incoming.length + outgoing.length === 0)\n return <EmptyState glyph=\"swords\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {incoming.map((o) => (\n <Card key={o.OfferID!} highlight style={{ display: \"grid\", gap: 8 }}>\n <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n {o.GoodsItemID ? (\n <ItemTile itemID={o.GoodsItemID} size={52} />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {o.GoodsItemID ? catalog.itemName(o.GoodsItemID) : \"\"}\n </div>\n <div style={{ ...outlined, fontSize: 12, color: v.textDim }}>\n {t(\"from\")}{\" \"}\n {o.CreatorPublicData?.Username ??\n String(o.CreatorUserID ?? \"\").slice(-6)}{\" \"}\n ·{\" \"}\n {bundleLines(o.Price as never).length ? t(\"wants\") : t(\"gift\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(o.Price as never)} />\n </div>\n <div\n style={{ display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 8 }}\n >\n <Button tone=\"grey\" size=\"sm\" onClick={() => void answer(o, false)}>\n {t(\"decline\")}\n </Button>\n <Button tone=\"green\" size=\"sm\" onClick={() => void answer(o, true)}>\n {t(\"accept\")}\n </Button>\n </div>\n </Card>\n ))}\n {outgoing.map((o, i) => (\n <OfferRow\n key={o.OfferID!}\n offer={o}\n index={i}\n onPick={() =>\n void client.marketplace\n .cancelDirectTrade(o.OfferID!)\n .then(() => onDone())\n }\n />\n ))}\n </div>\n );\n}\n\nfunction Claims({\n offers,\n onDone,\n}: {\n offers: MarketplaceOfferView[];\n onDone: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const celebrate = useCelebrate();\n const toast = useToast();\n const [busy, setBusy] = useState<string | null>(null);\n const claim = async (offer: MarketplaceOfferView | null) => {\n setBusy(offer?.OfferID ?? \"all\");\n const res = offer\n ? offer.OfferType === \"Auction\"\n ? await client.marketplace.claimAuction(offer.OfferID!)\n : await client.marketplace.claimBack(offer.OfferID!)\n : await client.marketplace.claimBack();\n setBusy(null);\n if (!res.ok) return toast(errorText(res.error), \"error\");\n celebrate(grantedBy(res.data));\n onDone();\n };\n if (offers.length === 0) return <EmptyState glyph=\"gift\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {offers.length > 1 ? (\n <Button\n tone=\"gold\"\n attract\n busy={busy === \"all\"}\n onClick={() => void claim(null)}\n >\n {t(\"claimAll\")}\n </Button>\n ) : null}\n {offers.map((o, i) => (\n <div\n key={o.OfferID!}\n style={{\n display: \"grid\",\n gridTemplateColumns: \"1fr auto\",\n gap: 8,\n alignItems: \"center\",\n }}\n >\n <OfferRow offer={o} index={i} onPick={() => void claim(o)} />\n <Button\n tone=\"gold\"\n size=\"sm\"\n busy={busy === o.OfferID}\n onClick={() => void claim(o)}\n >\n {t(\"claim\")}\n </Button>\n </div>\n ))}\n </div>\n );\n}\n\nfunction History(): ReactNode {\n const client = useIDosGamesClient();\n const catalog = useCatalog();\n const stagger = useStagger();\n const me = client.auth.context?.userID;\n const [entries, setEntries] = useState<MarketplaceHistoryEntryView[] | null>(\n null,\n );\n useEffect(() => {\n void client.marketplace\n .getHistory()\n .then((res) => setEntries(res.ok ? (res.data.Entries ?? []) : []));\n }, [client]);\n if (!entries)\n return (\n <Center>\n <Spinner />\n </Center>\n );\n if (entries.length === 0)\n return <EmptyState glyph=\"clock\" text={t(\"empty\")} />;\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n {entries.map((e, i) => (\n <Card\n key={e.OfferID ?? i}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n padding: 10,\n ...stagger(i),\n }}\n >\n {e.GoodsItemID ? (\n <ItemTile\n itemID={e.GoodsItemID}\n count={Number(e.GoodsAmount ?? 1)}\n size={48}\n />\n ) : null}\n <div style={{ flex: 1 }}>\n <div style={{ ...outlined, fontSize: 14 }}>\n {e.GoodsItemID ? catalog.itemName(e.GoodsItemID) : \"\"}\n </div>\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: e.SellerUserID === me ? v.green : v.gold,\n }}\n >\n {e.SellerUserID === me ? t(\"sold\") : t(\"bought\")}\n </div>\n </div>\n <ResourceList lines={bundleLines(e.PricePaid as never)} size={16} />\n </Card>\n ))}\n </div>\n );\n}\n\n// ── Selling ──────────────────────────────────────────────────────────────────────────────────\n\nfunction SellPopup({\n defs,\n onDone,\n onClose,\n}: {\n defs: MarketplaceDefinitions;\n onDone: () => void;\n onClose: () => void;\n}): ReactNode {\n const client = useIDosGamesClient();\n const state = useUserState();\n const catalog = useCatalog();\n const [picked, setPicked] = useState<SellableItem | null>(null);\n const items = catalog.items as Map<\n string,\n ItemDefinitionView & { CatalogID?: string; IsTradable?: boolean | null }\n >;\n const sellable = sellableItems(state?.InventoryV2 as never, items);\n const currencies = tradableCurrencies(catalog.currencies as never);\n const [kind, setKind] = useState<\"listing\" | \"auction\">(\"listing\");\n const [amount, setAmount] = useState(1);\n const [currency, setCurrency] = useState(currencies[0] ?? \"CO\");\n const [price, setPrice] = useState(100);\n const durations =\n kind === \"listing\" ? listingDurations(defs) : auctionDurations(defs);\n const [hours, setHours] = useState(durations[0] ?? 24);\n const auctionsOn = defs.Auctions?.Enabled !== false;\n\n if (!picked)\n return (\n <Popup title={t(\"pickItem\")} onClose={onClose} width={480}>\n {currencies.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"noTradableCurrency\")} />\n ) : sellable.length === 0 ? (\n <EmptyState glyph=\"market\" text={t(\"nothingToSell\")} />\n ) : (\n <div\n style={{\n display: \"flex\",\n flexWrap: \"wrap\",\n gap: 10,\n justifyContent: \"center\",\n }}\n >\n {sellable.map((s) => (\n <ItemTile\n key={s.itemID}\n itemID={s.itemID}\n count={s.amount}\n size={72}\n onClick={() => setPicked(s)}\n />\n ))}\n </div>\n )}\n </Popup>\n );\n\n const create = () =>\n kind === \"listing\"\n ? client.marketplace.createListing(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n priceBundle(currency, price) as never,\n hours,\n picked.instances.length ? [picked.instances[0]!] : undefined,\n )\n : client.marketplace.createAuction(\n picked.itemID,\n picked.catalogID,\n picked.instances.length ? 1 : amount,\n \"VirtualCurrency\" as never,\n price,\n hours,\n {\n bidCurrencyID: currency,\n itemInstanceIDs: picked.instances.length\n ? [picked.instances[0]!]\n : undefined,\n },\n );\n\n return (\n <Popup\n title={catalog.itemName(picked.itemID)}\n onClose={onClose}\n width={440}\n >\n <div style={{ display: \"grid\", justifyItems: \"center\" }}>\n <ItemTile\n itemID={picked.itemID}\n count={picked.instances.length ? 1 : amount}\n size={88}\n />\n </div>\n {auctionsOn ? (\n <Tabs\n tabs={[\n { id: \"listing\", label: t(\"listing\") },\n { id: \"auction\", label: t(\"auction\") },\n ]}\n value={kind}\n onChange={(k) => {\n setKind(k);\n setHours(\n (k === \"listing\"\n ? listingDurations(defs)\n : auctionDurations(defs))[0] ?? 24,\n );\n }}\n />\n ) : null}\n {picked.instances.length === 0 && picked.amount > 1 ? (\n <Field label={t(\"amount\")}>\n <Stepper\n value={amount}\n min={1}\n max={picked.amount}\n onChange={setAmount}\n />\n </Field>\n ) : null}\n <Field label={kind === \"listing\" ? t(\"price\") : t(\"startingBid\")}>\n {currencies.length > 1 ? (\n <Tabs\n tabs={currencies.map((c) => ({\n id: c,\n label: catalog.currencyName(c),\n glyph: catalog.iconOf({ kind: \"currency\", id: c, amount: 0 }),\n }))}\n value={currency}\n onChange={setCurrency}\n />\n ) : null}\n <Stepper value={price} min={1} onChange={setPrice} />\n </Field>\n <Field label={t(\"duration\")}>\n <Tabs\n tabs={durations.map((h) => ({\n id: String(h),\n label: `${h} ${t(\"hours\")}`,\n }))}\n value={String(hours)}\n onChange={(h) => setHours(Number(h))}\n />\n </Field>\n {commissionPercent(defs) > 0 ? (\n <div\n style={{\n ...outlined,\n fontSize: 12,\n color: v.textDim,\n textAlign: \"center\",\n }}\n >\n {t(\"commission\")}: {commissionPercent(defs)}%\n </div>\n ) : null}\n <Act label={t(\"create\")} tone=\"gold\" run={create} onDone={onDone} />\n </Popup>\n );\n}\n\nfunction Field({\n label,\n children,\n}: {\n label: string;\n children: ReactNode;\n}): ReactNode {\n return (\n <div style={{ display: \"grid\", gap: 6 }}>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>{label}</div>\n {children}\n </div>\n );\n}\n"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "model.ts",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": "module.ts",
|
|
64
|
-
"content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeMarketplaceScreen } from \"./MarketplaceScreen\";\nimport { hasTitleData, marketplaceBadge } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace as a feature of the game. Badge: things to claim (won lots, expired escrow) and\n// incoming trades waiting for an answer — read once at start and after every marketplace action.\nexport const marketplaceModule: Module = defineModule({\n id: \"marketplace\",\n meta: { name: \"Marketplace\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"marketplace\",\n label: t(\"title\"),\n icon: \"market\",\n group: \"economy\",\n order: 70,\n available: false,\n Screen: makeMarketplaceScreen(ctx.features),\n });\n ctx.features.setAvailable(\n
|
|
64
|
+
"content": "import { createElement, useEffect } from \"react\";\nimport {\n defineModule,\n type FeatureRegistry,\n type Module,\n} from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport { configSection } from \"@idosgames/react/ui\";\nimport { makeMarketplaceScreen } from \"./MarketplaceScreen\";\nimport { hasTitleData, marketplaceBadge } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The marketplace as a feature of the game. Badge: things to claim (won lots, expired escrow) and\n// incoming trades waiting for an answer — read once at start and after every marketplace action.\nexport const marketplaceModule: Module = defineModule({\n id: \"marketplace\",\n meta: { name: \"Marketplace\", type: \"app\", engine: \"dom\" },\n setup(ctx) {\n ctx.features.register({\n id: \"marketplace\",\n label: t(\"title\"),\n icon: \"market\",\n group: \"economy\",\n order: 70,\n available: false,\n Screen: makeMarketplaceScreen(ctx.features),\n });\n const decide = () =>\n ctx.features.setAvailable(\n \"marketplace\",\n hasTitleData(\n configSection<{ Enabled?: boolean | null }>(\n ctx.client,\n \"Marketplace\",\n ),\n ),\n \"the marketplace is not set up on the title, or switched off\",\n );\n decide();\n // The engine does not put Marketplace into the client's title config: read it once with its own\n // call (it lands in the same config section). Without this the tab never showed on any title.\n if (!configSection(ctx.client, \"Marketplace\"))\n void ctx.client.marketplace.getDefinitions().then(decide);\n ctx.registerPanel({\n id: \"badge\",\n slot: \"modal\",\n activeOnly: false,\n component: makeBadgeWatcher(ctx.features),\n });\n ctx.exposeToAgent({\n state: () => ({ ui: \"dom\", feature: \"marketplace\" }),\n describeActions: {},\n });\n },\n});\n\nfunction makeBadgeWatcher(features: FeatureRegistry) {\n return function MarketplaceBadge() {\n const client = useIDosGamesClient();\n useEffect(() => {\n // A title without the section has no marketplace — no request at all. The section arrives by\n // its own call (module setup), so the watcher starts once the definitions are in.\n let id: ReturnType<typeof setInterval> | null = null;\n const check = () =>\n void client.marketplace\n .getMyState()\n .then(\n (res) =>\n res.ok &&\n features.setBadge(\"marketplace\", marketplaceBadge(res.data)),\n );\n const start = () => {\n if (id || !hasTitleData(configSection(client, \"Marketplace\"))) return;\n check();\n id = setInterval(check, 5 * 60_000);\n };\n start();\n const off = client.on(\"marketplace:definitionsLoaded\", start);\n return () => {\n off();\n if (id) clearInterval(id);\n };\n }, [client]);\n return createElement(\"span\", { hidden: true });\n };\n}\n"
|
|
65
65
|
},
|
|
66
66
|
{
|
|
67
67
|
"path": "module.meta.json",
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"version": "0.1.1"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@idosgames/core": "0.
|
|
34
|
-
"@idosgames/module-sdk": "0.8.
|
|
35
|
-
"@idosgames/react": "0.4.
|
|
33
|
+
"@idosgames/core": "0.21.0",
|
|
34
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
35
|
+
"@idosgames/react": "0.4.3",
|
|
36
36
|
"react": "19.2.7"
|
|
37
37
|
},
|
|
38
38
|
"contentHash": "eb39139d93bfb84e5a20d2485b081a264234452b5d90978fdf15b940cf3b7ac0",
|