From eae2be6458c09d639ae04d8c9f499873cf6d5efd Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 4 Jul 2026 18:52:17 -0700 Subject: [PATCH] feat(store): confirm plutonium and caps purchases before charging (#4510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #4218 Currency purchases (Plutonium and Caps) fired immediately on click with no confirmation. This adds a confirmation modal — reusing the existing `confirm-dialog` Lit component — that gates every currency purchase behind an explicit "Confirm". There were two paths that could trigger a currency purchase, and both are now gated: - **The Plutonium / Caps price buttons** — `PurchaseButton` no longer calls `onPurchaseHard`/`onPurchaseSoft` directly; it opens a `confirm-dialog` ("Buy {item} for {amount} {currency}?", warning variant) and only runs the purchase on confirm. Cancel / backdrop click dismisses. - **Whole-card click** — `CosmeticContainer` auto-fires the purchase when there's exactly one payment option, which bypassed the button entirely for currency-only items. That path now delegates to the purchase button's new `requestCurrencyPurchase()` so it goes through the same dialog. The existing purchase flow (busy guard, loading overlay, insufficient-currency dialog) is unchanged and runs after confirmation. Dollar purchases are untouched (they go through Stripe checkout, which is its own confirmation step). New i18n keys: `store.confirm_purchase_title`, `store.confirm_purchase_body` (en.json only, per Crowdin convention). ## Test plan - [x] ESLint, `tsc --noEmit`, Prettier pass - [ ] Manual check in staging: click a Plutonium or Caps price button → dialog appears with the right currency name and amount; confirm purchases, cancel doesn't - [ ] Manual check: click the card body of a currency-only item → same dialog (not an instant purchase) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- resources/lang/en.json | 2 + src/client/components/CosmeticContainer.ts | 14 ++++++ src/client/components/PurchaseButton.ts | 56 +++++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index b1f4913cc..66fa02726 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1295,6 +1295,8 @@ "change_tier_success": "Switched to {tier}.", "checkout_failed": "Failed to create checkout session.", "confirm_downgrade": "Downgrade to {tier}? You'll get account credit for the unused portion of your current plan.", + "confirm_purchase_body": "Buy {item} for {amount, number} {currency}?", + "confirm_purchase_title": "Confirm Purchase", "confirm_upgrade": "Upgrade to {tier}? You'll be charged the prorated difference now.", "currency_pack_purchase_success": "Currency pack purchase successful!", "effects": "Effects", diff --git a/src/client/components/CosmeticContainer.ts b/src/client/components/CosmeticContainer.ts index e262ea4fe..04038275e 100644 --- a/src/client/components/CosmeticContainer.ts +++ b/src/client/components/CosmeticContainer.ts @@ -344,6 +344,19 @@ export class CosmeticContainer extends LitElement { this.onPurchaseSoft, ].filter(Boolean); if (handlers.length === 1 && !this._loading) { + // Currency purchases go through the confirmation dialog instead of + // firing immediately. + if ( + handlers[0] === this.onPurchaseHard || + handlers[0] === this.onPurchaseSoft + ) { + ( + this.querySelector("purchase-button") as PurchaseButton | null + )?.requestCurrencyPurchase( + handlers[0] === this.onPurchaseHard ? "hard" : "soft", + ); + return; + } this._loading = true; this._showLoadingOverlay(); Promise.resolve(handlers[0]!()) @@ -468,6 +481,7 @@ export class CosmeticContainer extends LitElement { .rarity=${this.rarity} .dollarLabelKey=${this.dollarLabelKey} .priceSuffix=${this.priceSuffix} + .itemName=${this.name} .onPurchaseDollar=${this.onPurchaseDollar} .onPurchaseHard=${this.onPurchaseHard} .onPurchaseSoft=${this.onPurchaseSoft} diff --git a/src/client/components/PurchaseButton.ts b/src/client/components/PurchaseButton.ts index 8f390ad0d..19bea97c9 100644 --- a/src/client/components/PurchaseButton.ts +++ b/src/client/components/PurchaseButton.ts @@ -4,6 +4,7 @@ import { Product } from "../../core/CosmeticSchemas"; import type { InsufficientCurrency, PurchaseResult } from "../Cosmetics"; import { translateText } from "../Utils"; import "./CapIcon"; +import "./ConfirmDialog"; import "./InsufficientCurrencyDialog"; import "./PlutoniumIcon"; @@ -201,6 +202,10 @@ export class PurchaseButton extends LitElement { @property({ type: String }) priceSuffix: string = ""; + /** Display name of the item, used in the currency confirmation dialog. */ + @property({ type: String }) + itemName: string = ""; + @property({ type: Function }) onPurchaseDollar?: () => Promise; @@ -212,6 +217,8 @@ export class PurchaseButton extends LitElement { /** Set when a purchase fails for lack of funds; drives the dialog. */ @state() private insufficient: InsufficientCurrency | null = null; + /** Which currency purchase is awaiting confirmation, if any. */ + @state() private confirmingCurrency: "hard" | "soft" | null = null; private busy = false; createRenderRoot() { @@ -220,6 +227,18 @@ export class PurchaseButton extends LitElement { private handleClick(e: Event, handler?: () => Promise) { e.stopPropagation(); + this.executePurchase(handler); + } + + /** Opens the currency confirmation dialog; the purchase runs on confirm. */ + requestCurrencyPurchase(method: "hard" | "soft") { + const handler = + method === "hard" ? this.onPurchaseHard : this.onPurchaseSoft; + if (!handler || this.busy) return; + this.confirmingCurrency = method; + } + + private executePurchase(handler?: () => Promise) { if (!handler || this.busy) return; this.busy = true; const container = this.closest("cosmetic-container") as HTMLElement | null; @@ -263,7 +282,10 @@ export class PurchaseButton extends LitElement {