support for purchasing currency packs (#3629)

## Description:

Adds a currency pack system to the store. Players can purchase packs of
in-game currency (Plutonium and Caps) via Stripe checkout.

What's new:

* Pack schema (PackSchema) — new cosmetic type with currency
(hard/soft), amount, and displayName
* "Packs" tab in the Store — renders purchasable currency packs using
existing CosmeticButton infrastructure
* Stripe checkout flow — new createCurrencyPackCheckout API call and
handlePackPurchase handler
* Currency display in Account modal — shows Plutonium and Caps balances
when logged in
I* con components — <plutonium-icon> (animated green glow + rotate) and
<cap-icon> with new SVG assets
* Currency in UserMeResponse — player.currency.hard /
player.currency.soft added to the API schema

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-04-10 15:07:47 -07:00
committed by GitHub
parent de92a2721a
commit 696e727a39
13 changed files with 274 additions and 12 deletions
+45 -3
View File
@@ -18,10 +18,9 @@ import { translateText } from "./Utils";
@customElement("store-modal")
export class StoreModal extends BaseModal {
@state() private activeTab: "patterns" | "flags" = "patterns";
@state() private activeTab: "patterns" | "flags" | "packs" = "patterns";
private cosmetics: Cosmetics | null = null;
private userSettings: UserSettings = new UserSettings();
private isActive = false;
private affiliateCode: string | null = null;
private userMeResponse: UserMeResponse | false = false;
@@ -51,6 +50,15 @@ export class StoreModal extends BaseModal {
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
})}
<div class="flex items-center gap-2 justify-center pt-2">
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "packs"
? "bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
@click=${() => (this.activeTab = "packs")}
>
${translateText("store.packs")}
</button>
<button
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
.activeTab === "patterns"
@@ -149,6 +157,38 @@ export class StoreModal extends BaseModal {
`;
}
private renderPackGrid(): TemplateResult {
const items = resolveCosmetics(
this.cosmetics,
this.userMeResponse,
this.affiliateCode,
).filter((r) => r.type === "pack" && r.relationship === "purchasable");
if (items.length === 0) {
return html`<div
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
>
${translateText("store.no_packs")}
</div>`;
}
return html`
<div
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
>
${items.map(
(r) => html`
<cosmetic-button
.resolved=${r}
.onPurchase=${(rc: ResolvedCosmetic) =>
handlePurchase(rc.cosmetic!.product!)}
></cosmetic-button>
`,
)}
</div>
`;
}
render() {
if (!this.isActive && !this.inline) return html``;
@@ -158,7 +198,9 @@ export class StoreModal extends BaseModal {
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.renderFlagGrid()}
: this.activeTab === "flags"
? this.renderFlagGrid()
: this.renderPackGrid()}
</div>
</div>
`;