mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 18:53:25 +00:00
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:
@@ -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
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user