feat(store): confirm plutonium and caps purchases before charging (#4510)

## 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 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-04 18:52:17 -07:00
committed by GitHub
parent 7fa81c6bb9
commit eae2be6458
3 changed files with 70 additions and 2 deletions
+2
View File
@@ -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",
@@ -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}
+54 -2
View File
@@ -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<PurchaseResult>;
@@ -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<PurchaseResult>) {
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<PurchaseResult>) {
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 {
<button
class="purchase-sparkle-btn-hard relative overflow-hidden w-full px-2 py-1.5 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-base font-bold cursor-pointer transition-all duration-200 flex items-center justify-center gap-2
hover:bg-green-500 hover:border-green-400 hover:text-white hover:shadow-[0_0_20px_rgba(74,222,128,0.6)]"
@click=${(e: Event) => this.handleClick(e, this.onPurchaseHard)}
@click=${(e: Event) => {
e.stopPropagation();
this.requestCurrencyPurchase("hard");
}}
>
<plutonium-icon .size=${20} style="margin-top:3px"></plutonium-icon>
${this.priceHard!.toLocaleString()}
@@ -276,7 +298,10 @@ export class PurchaseButton extends LitElement {
<button
class="purchase-sparkle-btn-soft relative overflow-hidden w-full px-2 py-1.5 bg-amber-700/20 text-amber-600 border border-amber-700/30 rounded-lg text-base font-bold cursor-pointer transition-all duration-200 flex items-center justify-center gap-2
hover:bg-amber-700 hover:border-amber-600 hover:text-white hover:shadow-[0_0_20px_rgba(217,119,6,0.6)]"
@click=${(e: Event) => this.handleClick(e, this.onPurchaseSoft)}
@click=${(e: Event) => {
e.stopPropagation();
this.requestCurrencyPurchase("soft");
}}
>
<cap-icon .size=${22} style="margin-top:3px"></cap-icon>
${this.priceSoft!.toLocaleString()}
@@ -312,6 +337,33 @@ export class PurchaseButton extends LitElement {
${hasSoft ? this.renderSoftButton() : null}
</div>
</div>
${this.confirmingCurrency
? html`<confirm-dialog
.heading=${translateText("store.confirm_purchase_title")}
.message=${translateText("store.confirm_purchase_body", {
item: this.itemName,
amount:
(this.confirmingCurrency === "hard"
? this.priceHard
: this.priceSoft) ?? 0,
currency: translateText(
this.confirmingCurrency === "hard"
? "cosmetics.hard"
: "cosmetics.soft",
),
})}
variant="warning"
@confirm=${() => {
const handler =
this.confirmingCurrency === "hard"
? this.onPurchaseHard
: this.onPurchaseSoft;
this.confirmingCurrency = null;
this.executePurchase(handler);
}}
@cancel=${() => (this.confirmingCurrency = null)}
></confirm-dialog>`
: nothing}
<insufficient-currency-dialog
.info=${this.insufficient}
@close=${() => (this.insufficient = null)}