store popup (#4435)

## Description:

change the generic popup:
<img width="1095" height="540" alt="image"
src="https://github.com/user-attachments/assets/94d2c120-5ec5-4838-b8b4-09d43b4e83f8"
/>


into a popup i added for clan system:
<img width="1108" height="774" alt="image"
src="https://github.com/user-attachments/assets/d7de1666-7667-4422-a1bd-03b90b4ff8ab"
/>

caps doesn't have a "buy" button:
<img width="1141" height="803" alt="image"
src="https://github.com/user-attachments/assets/d26dd397-1f14-4963-8ac8-afa5f32ed8ec"
/>

also works for win modal:
<img width="1023" height="766" alt="image"
src="https://github.com/user-attachments/assets/83f7bc87-0ecc-4470-b84d-c5783560d6a3"
/>

## 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) <noreply@anthropic.com>
This commit is contained in:
Ryan
2026-06-29 21:24:09 +01:00
committed by GitHub
parent 0f1a95cbeb
commit 6a884eba1b
7 changed files with 181 additions and 48 deletions
+3 -1
View File
@@ -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",
+31 -8
View File
@@ -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<void> {
): Promise<PurchaseResult> {
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";
+44 -19
View File
@@ -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`
<div
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80"
class="fixed inset-0 z-[10020] flex items-center justify-center bg-black/80"
@click=${(e: Event) => {
if (e.target === e.currentTarget) this.handleCancel();
}}
>
<div
class="mx-4 w-full max-w-sm p-6 rounded-2xl border ${borderColor} ${cardBg} shadow-2xl"
class="relative mx-4 w-full ${this.wide
? "max-w-md"
: "max-w-sm"} p-6 rounded-2xl border ${borderColor} ${cardBg} shadow-2xl"
>
${this.showClose
? html`<button
@click=${() => this.handleCancel()}
class="absolute top-3 right-3 flex h-8 w-8 items-center justify-center rounded-lg text-xl leading-none text-white/50 hover:bg-white/10 hover:text-white transition-all"
>
×
</button>`
: ""}
${this.heading
? html`<h2 class="text-lg font-bold text-white mb-2 pr-8">
${this.heading}
</h2>`
: ""}
<p class="text-sm font-medium ${textColor} mb-5">${this.message}</p>
${this.textareaPlaceholder
? html`<textarea
@@ -94,22 +115,26 @@ export class ConfirmDialog extends LitElement {
class="w-full px-3 py-2 mb-4 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-amber-500/50 text-sm resize-none"
></textarea>`
: ""}
<div class="flex gap-3">
<button
@click=${() => this.handleCancel()}
?disabled=${this.disabled}
class="flex-1 px-4 py-2.5 text-xs font-bold uppercase tracking-wider rounded-xl bg-white/5 text-white/60 border border-white/10 hover:bg-white/10 hover:text-white/80 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("common.cancel")}
</button>
<button
@click=${() => this.handleConfirm()}
?disabled=${this.disabled}
class="flex-1 px-4 py-2.5 text-xs font-bold uppercase tracking-wider rounded-xl ${btnClass} transition-all disabled:opacity-50 disabled:pointer-events-none border-0"
>
${translateText("common.confirm")}
</button>
</div>
${this.buttons === "none"
? ""
: html`<div class="flex gap-3">
${this.buttons === "confirmCancel"
? html`<button
@click=${() => this.handleCancel()}
?disabled=${this.disabled}
class="flex-1 px-4 py-2.5 text-xs font-bold uppercase tracking-wider rounded-xl bg-white/5 text-white/60 border border-white/10 hover:bg-white/10 hover:text-white/80 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("common.cancel")}
</button>`
: ""}
<button
@click=${() => this.handleConfirm()}
?disabled=${this.disabled}
class="flex-1 px-4 py-2.5 text-xs font-bold uppercase tracking-wider rounded-xl ${btnClass} transition-all disabled:opacity-50 disabled:pointer-events-none border-0"
>
${this.confirmText || translateText("common.confirm")}
</button>
</div>`}
</div>
</div>
`;
+8 -4
View File
@@ -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<PurchaseResult>;
/** 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}
>
+16 -7
View File
@@ -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<PurchaseResult>;
@property({ type: Function })
onPurchaseHard?: () => void;
onPurchaseHard?: () => Promise<PurchaseResult>;
@property({ type: Function })
onPurchaseSoft?: () => void;
onPurchaseSoft?: () => Promise<PurchaseResult>;
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();
});
}
};
@@ -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`<confirm-dialog
.heading=${translateText("store.insufficient_currency_title", {
currency: info.currency,
})}
.message=${translateText("store.insufficient_currency_body", {
amount: info.shortfall,
currency: info.currency,
item: info.item,
})}
variant="warning"
.wide=${true}
.showClose=${true}
.buttons=${info.canTopUp ? "confirmOnly" : "none"}
.confirmText=${info.canTopUp
? translateText("store.purchase_currency", { currency: info.currency })
: ""}
@cancel=${() => 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";
}}
></confirm-dialog>`;
}
}
+29 -9
View File
@@ -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<PurchaseResult>;
@property({ type: Function })
onPurchaseHard?: () => void;
onPurchaseHard?: () => Promise<PurchaseResult>;
@property({ type: Function })
onPurchaseSoft?: () => void;
onPurchaseSoft?: () => Promise<PurchaseResult>;
/** 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<PurchaseResult>) {
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 = `<div class="cosmetic-loading-spinner"></div>`;
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}
</div>
</div>
<insufficient-currency-dialog
.info=${this.insufficient}
@close=${() => (this.insufficient = null)}
></insufficient-currency-dialog>
`;
}
}