From 6a884eba1b0dacf785b767f6a7cef1b3913808ad Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:24:09 +0100 Subject: [PATCH] store popup (#4435) ## Description: change the generic popup: image into a popup i added for clan system: image caps doesn't have a "buy" button: image also works for win modal: image ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --------- Co-authored-by: Claude Opus 4.8 (1M context) --- resources/lang/en.json | 4 +- src/client/Cosmetics.ts | 39 +++++++++--- src/client/components/ConfirmDialog.ts | 63 +++++++++++++------ src/client/components/CosmeticButton.ts | 12 ++-- src/client/components/CosmeticContainer.ts | 23 ++++--- .../components/InsufficientCurrencyDialog.ts | 50 +++++++++++++++ src/client/components/PurchaseButton.ts | 38 ++++++++--- 7 files changed, 181 insertions(+), 48 deletions(-) create mode 100644 src/client/components/InsufficientCurrencyDialog.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 12a03fe0b..46f64046d 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1264,16 +1264,18 @@ "currency_pack_purchase_success": "Currency pack purchase successful!", "effects": "Effects", "flags": "Flags", + "insufficient_currency_body": "You need {amount, number} more {currency} to buy {item}.", + "insufficient_currency_title": "Insufficient {currency}", "login_required": "You must be logged in to purchase with currency.", "no_effects": "No effects available. Check back later for new items.", "no_flags": "No flags available. Check back later for new items.", "no_packs": "No packs available. Check back later for new items.", "no_skins": "No skins available. Check back later for new items.", "no_subscriptions": "No subscriptions available. Check back later for new items.", - "not_enough_currency": "Not enough currency for this purchase.", "packs": "Packs", "patterns": "Skins", "price_per_month": "/mo", + "purchase_currency": "Purchase {currency}", "purchase_failed": "Purchase failed. Please try again.", "purchase_success": "Purchase succeeded: {name}", "subscribed": "Subscribed", diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 8d74ab792..59a56576c 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -14,13 +14,13 @@ import { Skin, Subscription, } from "../core/CosmeticSchemas"; +import { UserSettings } from "../core/game/UserSettings"; import { PlayerCosmeticRefs, PlayerCosmetics, PlayerEffect, PlayerPattern, } from "../core/Schemas"; -import { UserSettings } from "../core/game/UserSettings"; import { changeSubscriptionTier, createCheckoutSession, @@ -65,10 +65,25 @@ export function getLocalSelectedSkin(): { name: string; url: string } | null { export type PaymentMethod = "dollar" | "hard" | "soft"; +/** Returned by {@link purchaseCosmetic} when the player can't afford an item. */ +export interface InsufficientCurrency { + /** Display name of the currency, e.g. "Plutonium". */ + currency: string; + /** How much more currency is needed (raw; localized in the dialog text). */ + shortfall: number; + /** Display name of the item being bought. */ + item: string; + /** Whether the currency can be topped up (hard currency only). */ + canTopUp: boolean; +} + +/** Outcome of a purchase: unaffordable details, or void on success/redirect. */ +export type PurchaseResult = InsufficientCurrency | void; + export async function purchaseCosmetic( resolved: ResolvedCosmetic, method: PaymentMethod, -): Promise { +): Promise { if (!resolved.cosmetic) return; const c = resolved.cosmetic; const colorPaletteName = resolved.colorPalette?.name; @@ -152,12 +167,20 @@ export async function purchaseCosmetic( ? (userMe.player.currency?.hard ?? 0) : (userMe.player.currency?.soft ?? 0); if (balance < price) { - alert(translateText("store.not_enough_currency")); - if (method === "hard") { - // Send the user to the packs tab so they can top up plutonium. - window.location.hash = "#modal=store&tab=packs"; - } - return; + const currencyName = translateText( + method === "hard" ? "cosmetics.hard" : "cosmetics.soft", + ); + const itemName = + resolved.type === "flag" + ? translateCosmetic("flags", c.name) + : translateCosmetic("territory_patterns.pattern", c.name); + return { + currency: currencyName, + shortfall: price - balance, + item: itemName, + // Only plutonium can be topped up; caps are dismiss-only. + canTopUp: method === "hard", + }; } const cosmeticType = resolved.type as "pattern" | "skin" | "flag" | "effect"; diff --git a/src/client/components/ConfirmDialog.ts b/src/client/components/ConfirmDialog.ts index f92e2f14d..f2dcdbaf3 100644 --- a/src/client/components/ConfirmDialog.ts +++ b/src/client/components/ConfirmDialog.ts @@ -3,7 +3,7 @@ import { customElement, property, state } from "lit/decorators.js"; import { translateText } from "../Utils"; /** - * A reusable inline confirmation dialog. + * A reusable inline confirmation / acknowledgement dialog. * * Usage: * ```html @@ -28,10 +28,16 @@ import { translateText } from "../Utils"; */ @customElement("confirm-dialog") export class ConfirmDialog extends LitElement { + @property() heading = ""; @property() message = ""; @property() variant: "danger" | "warning" = "danger"; @property() textareaPlaceholder = ""; + @property() confirmText = ""; @property({ type: Boolean }) disabled = false; + @property({ type: Boolean }) showClose = false; + @property({ type: Boolean }) wide = false; + @property() buttons: "confirmCancel" | "confirmOnly" | "none" = + "confirmCancel"; @state() private text = ""; @@ -74,14 +80,29 @@ export class ConfirmDialog extends LitElement { return html`
{ if (e.target === e.currentTarget) this.handleCancel(); }} >
+ ${this.showClose + ? html`` + : ""} + ${this.heading + ? html`

+ ${this.heading} +

` + : ""}

${this.message}

${this.textareaPlaceholder ? html`` : ""} -
- - -
+ ${this.buttons === "none" + ? "" + : html`
+ ${this.buttons === "confirmCancel" + ? html`` + : ""} + +
`}
`; diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts index d2e7cdcee..ec1ba3f6d 100644 --- a/src/client/components/CosmeticButton.ts +++ b/src/client/components/CosmeticButton.ts @@ -11,6 +11,7 @@ import { import { PlayerPattern } from "../../core/Schemas"; import { PaymentMethod, + PurchaseResult, ResolvedCosmetic, translateCosmetic, } from "../Cosmetics"; @@ -34,7 +35,10 @@ export class CosmeticButton extends LitElement { onSelect?: (resolved: ResolvedCosmetic) => void; @property({ type: Function }) - onPurchase?: (resolved: ResolvedCosmetic, method: PaymentMethod) => void; + onPurchase?: ( + resolved: ResolvedCosmetic, + method: PaymentMethod, + ) => Promise; /** True if the user already has a subscription (any tier). */ @property({ type: Boolean }) @@ -309,13 +313,13 @@ export class CosmeticButton extends LitElement { .dollarLabelKey=${dollarLabelKey} .priceSuffix=${priceSuffix} .onPurchaseDollar=${isPurchasable && c?.product - ? () => this.onPurchase?.(this.activeResolved, "dollar") + ? async () => this.onPurchase?.(this.activeResolved, "dollar") : undefined} .onPurchaseHard=${isPurchasable && priceHard !== undefined - ? () => this.onPurchase?.(this.activeResolved, "hard") + ? async () => this.onPurchase?.(this.activeResolved, "hard") : undefined} .onPurchaseSoft=${isPurchasable && priceSoft !== undefined - ? () => this.onPurchase?.(this.activeResolved, "soft") + ? async () => this.onPurchase?.(this.activeResolved, "soft") : undefined} .name=${this.displayName} > diff --git a/src/client/components/CosmeticContainer.ts b/src/client/components/CosmeticContainer.ts index 113207348..f098d4a7d 100644 --- a/src/client/components/CosmeticContainer.ts +++ b/src/client/components/CosmeticContainer.ts @@ -1,7 +1,8 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; import { Product } from "../../core/CosmeticSchemas"; -import "./PurchaseButton"; +import type { PurchaseResult } from "../Cosmetics"; +import { PurchaseButton } from "./PurchaseButton"; type Rarity = "common" | "uncommon" | "rare" | "epic" | "legendary" | string; @@ -167,13 +168,13 @@ export class CosmeticContainer extends LitElement { priceSuffix: string = ""; @property({ type: Function }) - onPurchaseDollar?: () => void; + onPurchaseDollar?: () => Promise; @property({ type: Function }) - onPurchaseHard?: () => void; + onPurchaseHard?: () => Promise; @property({ type: Function }) - onPurchaseSoft?: () => void; + onPurchaseSoft?: () => Promise; private static _backdrop: HTMLDivElement | null = null; private static _ensureBackdrop(): HTMLDivElement { @@ -344,9 +345,17 @@ export class CosmeticContainer extends LitElement { if (handlers.length === 1 && !this._loading) { this._loading = true; this._showLoadingOverlay(); - Promise.resolve(handlers[0]!()).finally(() => { - this._hideLoadingOverlay(); - }); + Promise.resolve(handlers[0]!()) + .then((result) => { + if (result) { + ( + this.querySelector("purchase-button") as PurchaseButton | null + )?.showInsufficient(result); + } + }) + .finally(() => { + this._hideLoadingOverlay(); + }); } }; diff --git a/src/client/components/InsufficientCurrencyDialog.ts b/src/client/components/InsufficientCurrencyDialog.ts new file mode 100644 index 000000000..ced92f366 --- /dev/null +++ b/src/client/components/InsufficientCurrencyDialog.ts @@ -0,0 +1,50 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { InsufficientCurrency } from "../Cosmetics"; +import { translateText } from "../Utils"; +import "./ConfirmDialog"; + +/** + * Shown when the player can't afford a cosmetic. Set `.info` to display it and + * clear it on `@close`. Plutonium gets a top-up button; caps are dismiss-only. + */ +@customElement("insufficient-currency-dialog") +export class InsufficientCurrencyDialog extends LitElement { + @property({ attribute: false }) info: InsufficientCurrency | null = null; + + createRenderRoot() { + return this; + } + + private close() { + this.dispatchEvent(new CustomEvent("close")); + } + + render() { + const info = this.info; + if (!info) return nothing; + return html` this.close()} + @confirm=${() => { + this.close(); + // Home path (not just hash) so it also works from in-game (win modal). + window.location.href = "/#modal=store&tab=packs"; + }} + >`; + } +} diff --git a/src/client/components/PurchaseButton.ts b/src/client/components/PurchaseButton.ts index 44edd514c..8f390ad0d 100644 --- a/src/client/components/PurchaseButton.ts +++ b/src/client/components/PurchaseButton.ts @@ -1,8 +1,10 @@ import { html, LitElement, nothing } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { Product } from "../../core/CosmeticSchemas"; +import type { InsufficientCurrency, PurchaseResult } from "../Cosmetics"; import { translateText } from "../Utils"; import "./CapIcon"; +import "./InsufficientCurrencyDialog"; import "./PlutoniumIcon"; const PURCHASE_STYLE_ID = "purchase-button-styles"; @@ -200,21 +202,26 @@ export class PurchaseButton extends LitElement { priceSuffix: string = ""; @property({ type: Function }) - onPurchaseDollar?: () => void; + onPurchaseDollar?: () => Promise; @property({ type: Function }) - onPurchaseHard?: () => void; + onPurchaseHard?: () => Promise; @property({ type: Function }) - onPurchaseSoft?: () => void; + onPurchaseSoft?: () => Promise; + + /** Set when a purchase fails for lack of funds; drives the dialog. */ + @state() private insufficient: InsufficientCurrency | null = null; + private busy = false; createRenderRoot() { return this; } - private handleClick(e: Event, handler?: () => void) { + private handleClick(e: Event, handler?: () => Promise) { e.stopPropagation(); - if (!handler) return; + if (!handler || this.busy) return; + this.busy = true; const container = this.closest("cosmetic-container") as HTMLElement | null; if (container && !container.querySelector(".cosmetic-loading-overlay")) { const overlay = document.createElement("div"); @@ -222,9 +229,18 @@ export class PurchaseButton extends LitElement { overlay.innerHTML = `
`; container.appendChild(overlay); } - Promise.resolve(handler()).finally(() => { - container?.querySelector(".cosmetic-loading-overlay")?.remove(); - }); + Promise.resolve(handler()) + .then((result) => { + if (result) this.insufficient = result; + }) + .finally(() => { + this.busy = false; + container?.querySelector(".cosmetic-loading-overlay")?.remove(); + }); + } + + showInsufficient(result: InsufficientCurrency) { + this.insufficient = result; } private renderDollarButton() { @@ -296,6 +312,10 @@ export class PurchaseButton extends LitElement { ${hasSoft ? this.renderSoftButton() : null} + (this.insufficient = null)} + > `; } }