mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 11:52:05 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user