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
+29
View File
@@ -0,0 +1,29 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { assetUrl } from "../../core/AssetUrls";
@customElement("cap-icon")
export class CapIcon extends LitElement {
@property({ type: Number })
size: number = 48;
createRenderRoot() {
return this;
}
render() {
return html`
<div
class="inline-flex items-center justify-center"
style="width:${this.size}px; height:${this.size}px;"
>
<img
src=${assetUrl("images/BottleCapIcon.svg")}
alt="Caps"
style="width:${this.size}px; height:${this.size}px;"
draggable="false"
/>
</div>
`;
}
}
+36 -3
View File
@@ -1,12 +1,14 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Flag, Pattern } from "../../core/CosmeticSchemas";
import { Flag, Pack, Pattern } from "../../core/CosmeticSchemas";
import { PlayerPattern } from "../../core/Schemas";
import { ResolvedCosmetic, translateCosmetic } from "../Cosmetics";
import { translateText } from "../Utils";
import "./CapIcon";
import "./CosmeticContainer";
import "./CosmeticInfo";
import { renderPatternPreview } from "./PatternPreview";
import "./PlutoniumIcon";
@customElement("cosmetic-button")
export class CosmeticButton extends LitElement {
@@ -38,6 +40,9 @@ export class CosmeticButton extends LitElement {
if (this.resolved.type === "pattern") {
return translateCosmetic("territory_patterns.pattern", c.name);
}
if (this.resolved.type === "pack") {
return (c as Pack).displayName;
}
return translateCosmetic("flags", c.name);
}
@@ -55,6 +60,33 @@ export class CosmeticButton extends LitElement {
return renderPatternPreview(playerPattern, 150, 150);
}
if (this.resolved.type === "pack") {
const pack = this.resolved.cosmetic as Pack;
const isHard = pack.currency === "hard";
const icon = isHard
? html`<plutonium-icon
class="flex-1 flex items-center"
.size=${100}
></plutonium-icon>`
: html`<cap-icon
class="flex-1 flex items-center"
.size=${100}
></cap-icon>`;
const colorClass = isHard ? "text-green-400" : "text-amber-700";
const currencyKey = isHard ? "cosmetics.hard" : "cosmetics.soft";
return html`<div
class="flex flex-col items-center justify-end h-full w-full text-center gap-1 pb-1"
>
${icon}
<span class="text-lg font-black ${colorClass}"
>${pack.amount.toLocaleString()}</span
>
<span class="text-[10px] font-bold text-white/50 uppercase"
>${translateText(currencyKey)}</span
>
</div>`;
}
const c = this.resolved.cosmetic as Flag;
return html`<img
src=${c.url}
@@ -75,8 +107,9 @@ export class CosmeticButton extends LitElement {
render() {
const c = this.resolved.cosmetic;
const isPurchasable = this.resolved.relationship === "purchasable";
const isPattern = this.resolved.type === "pattern";
const sizeClass = isPattern ? "gap-2 p-3 w-48" : "gap-1 p-1.5 w-36";
const type = this.resolved.type;
const isPattern = type === "pattern";
const sizeClass = type === "flag" ? "gap-1 p-1.5 w-36" : "gap-2 p-3 w-48";
const crazygamesClass = isPattern ? "no-crazygames " : "";
return html`
+43
View File
@@ -0,0 +1,43 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { translateText } from "../Utils";
import "./CapIcon";
import "./PlutoniumIcon";
@customElement("currency-display")
export class CurrencyDisplay extends LitElement {
@property({ type: Number })
hard: number = 0;
@property({ type: Number })
soft: number = 0;
createRenderRoot() {
return this;
}
render() {
return html`
<div class="flex gap-3 justify-center">
<div
class="flex items-center gap-1.5"
title=${translateText("cosmetics.hard")}
>
<plutonium-icon .size=${16}></plutonium-icon>
<span class="text-sm font-bold text-green-400"
>${this.hard.toLocaleString()}</span
>
</div>
<div
class="flex items-center gap-1.5"
title=${translateText("cosmetics.soft")}
>
<cap-icon .size=${20} style="margin-top:3px"></cap-icon>
<span class="text-sm font-bold text-amber-700"
>${this.soft.toLocaleString()}</span
>
</div>
</div>
`;
}
}
+55
View File
@@ -0,0 +1,55 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { assetUrl } from "../../core/AssetUrls";
const STYLE_ID = "plutonium-icon-styles";
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
@keyframes plutonium-pulse {
0% { filter: drop-shadow(0 0 4px rgba(34,197,94,0.6)) drop-shadow(0 0 8px rgba(34,197,94,0.3)); scale: 1; }
50% { filter: drop-shadow(0 0 10px rgba(34,197,94,0.9)) drop-shadow(0 0 20px rgba(34,197,94,0.5)) drop-shadow(0 0 30px rgba(34,197,94,0.2)); scale: 1.04; }
100% { filter: drop-shadow(0 0 4px rgba(34,197,94,0.6)) drop-shadow(0 0 8px rgba(34,197,94,0.3)); scale: 1; }
}
@keyframes plutonium-rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes plutonium-jiggle {
0%, 100% { translate: 0 0; }
25% { translate: -0.4px 0.3px; }
50% { translate: 0.3px -0.4px; }
75% { translate: -0.3px -0.3px; }
}
`;
document.head.appendChild(style);
}
@customElement("plutonium-icon")
export class PlutoniumIcon extends LitElement {
@property({ type: Number })
size: number = 48;
createRenderRoot() {
return this;
}
render() {
return html`
<div
class="inline-flex items-center justify-center"
style="width:${this.size}px; height:${this
.size}px; animation: plutonium-pulse 2s ease-in-out infinite, plutonium-jiggle 0.15s linear infinite;"
>
<img
src=${assetUrl("images/PlutoniumIcon.svg")}
alt="Plutonium"
style="width:${this.size}px; height:${this
.size}px; animation: plutonium-rotate 7s linear infinite;"
draggable="false"
/>
</div>
`;
}
}