Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1256,15 +1256,17 @@
"confirm_upgrade": "Upgrade to {tier}? You'll be charged the prorated difference now.",
"currency_pack_purchase_success": "Currency pack purchase successful!",
"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_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",
Expand Down
39 changes: 31 additions & 8 deletions src/client/Cosmetics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import {
Skin,
Subscription,
} from "../core/CosmeticSchemas";
import { UserSettings } from "../core/game/UserSettings";
import {
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
import {
changeSubscriptionTier,
createCheckoutSession,
Expand Down Expand Up @@ -61,10 +61,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;
Expand Down Expand Up @@ -148,12 +163,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";
Expand Down
63 changes: 44 additions & 19 deletions src/client/components/ConfirmDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = "";

Expand Down Expand Up @@ -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
Expand All @@ -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>
`;
Expand Down
12 changes: 8 additions & 4 deletions src/client/components/CosmeticButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { PlayerPattern } from "../../core/Schemas";
import {
PaymentMethod,
PurchaseResult,
ResolvedCosmetic,
translateCosmetic,
} from "../Cosmetics";
Expand All @@ -32,7 +33,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 })
Expand Down Expand Up @@ -290,13 +294,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}
>
Expand Down
23 changes: 16 additions & 7 deletions src/client/components/CosmeticContainer.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private static _backdrop: HTMLDivElement | null = null;
private static _ensureBackdrop(): HTMLDivElement {
Expand Down Expand Up @@ -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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
};

Expand Down
50 changes: 50 additions & 0 deletions src/client/components/InsufficientCurrencyDialog.ts
Original file line number Diff line number Diff line change
@@ -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>`;
}
}
Loading
Loading