diff --git a/resources/lang/en.json b/resources/lang/en.json index eeb64dc1c..5ac0698b3 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1305,6 +1305,7 @@ }, "store": { "already_subscribed": "Already subscribed.", + "buy_for": "Buy for {price}", "change_tier_failed": "Couldn't update your subscription. Please try again.", "change_tier_success": "Switched to {tier}.", "checkout_failed": "Failed to create checkout session.", @@ -1313,6 +1314,8 @@ "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!", + "custom_amount": "Custom Amount", + "custom_currency_purchase_success": "Plutonium purchase successful!", "effects": "Effects", "flags": "Flags", "insufficient_currency_body": "You need {amount, number} more {currency} to buy {item}.", @@ -1320,7 +1323,6 @@ "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.", "packs": "Packs", diff --git a/src/client/Api.ts b/src/client/Api.ts index 7c24d5115..c260dc716 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -222,6 +222,40 @@ export async function createCheckoutSession( } } +export async function createCustomCurrencyCheckout( + hardAmount: number, +): Promise { + try { + const response = await fetch( + `${getApiBase()}/stripe/create-custom-currency-checkout`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: await getAuthHeader(), + }, + body: JSON.stringify({ + hardAmount: hardAmount, + hostname: window.location.origin, + }), + }, + ); + if (!response.ok) { + console.error( + "createCustomCurrencyCheckout: request failed", + response.status, + response.statusText, + ); + return false; + } + const json = await response.json(); + return json.url; + } catch (e) { + console.error("createCustomCurrencyCheckout: request failed", e); + return false; + } +} + export async function cancelSubscription(): Promise { try { const response = await fetch(`${getApiBase()}/subscriptions/@me/cancel`, { diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 665a2c06c..2c245583b 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -227,6 +227,7 @@ export class LangSelector extends LitElement { "o-button", "territory-patterns-modal", "store-modal", + "custom-currency-card", "pattern-input", "fluent-slider", "news-modal", diff --git a/src/client/Main.ts b/src/client/Main.ts index c0ed52717..c951be8a9 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -714,6 +714,13 @@ class Client { return; } + if (type === "custom_currency") { + // Plutonium is credited asynchronously by the Stripe webhook; the + // balance refreshes from /users/@me on the next load. + alertAndStrip(translateText("store.custom_currency_purchase_success")); + return; + } + if (type === "subscription_tier") { alert(translateText("store.subscription_purchase_success")); strip(); diff --git a/src/client/Store.ts b/src/client/Store.ts index 0c73b51c7..f1d1a3c42 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -6,6 +6,7 @@ import { Cosmetics } from "../core/CosmeticSchemas"; import { UserSettings } from "../core/game/UserSettings"; import { BaseModal } from "./components/BaseModal"; import "./components/CosmeticButton"; +import "./components/CustomCurrencyCard"; import "./components/EffectsGrid"; import "./components/NotLoggedInWarning"; import { modalHeader } from "./components/ui/ModalHeader"; @@ -163,14 +164,8 @@ export class StoreModal extends BaseModal { this.affiliateCode, ).filter((r) => r.type === "pack" && r.relationship === "purchasable"); - if (items.length === 0) { - return html`
- ${translateText("store.no_packs")} -
`; - } - + // The custom-amount card is always purchasable (priced inline server-side, + // no catalog entry), and follows the fixed packs at the end of the grid. return html`
`, )} +
`; } diff --git a/src/client/components/CustomCurrencyCard.ts b/src/client/components/CustomCurrencyCard.ts new file mode 100644 index 000000000..10412dfab --- /dev/null +++ b/src/client/components/CustomCurrencyCard.ts @@ -0,0 +1,108 @@ +import { html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import { createCustomCurrencyCheckout } from "../Api"; +import { translateText } from "../Utils"; +import "./PlutoniumIcon"; + +// Fixed rate: 20 plutonium = $1.00 (5 cents each). Bounds and rate are +// enforced server-side; these are for UX only. +const MIN_PLUTONIUM = 20; +const MAX_PLUTONIUM = 2000; + +@customElement("custom-currency-card") +export class CustomCurrencyCard extends LitElement { + /** Always a clamped integer in [MIN_PLUTONIUM, MAX_PLUTONIUM]. */ + @state() private amount = 100; + @state() private busy = false; + + createRenderRoot() { + return this; + } + + private clamp(value: number): number { + if (!Number.isFinite(value)) return MIN_PLUTONIUM; + return Math.min(MAX_PLUTONIUM, Math.max(MIN_PLUTONIUM, Math.floor(value))); + } + + private get priceDollars(): string { + return (this.amount / 20).toFixed(2); + } + + private onSlider(e: Event) { + this.amount = this.clamp(Number((e.target as HTMLInputElement).value)); + } + + private onInputChange(e: Event) { + this.amount = this.clamp(Number((e.target as HTMLInputElement).value)); + } + + private async buy() { + if (this.busy) return; + this.busy = true; + try { + const url = await createCustomCurrencyCheckout(this.amount); + if (url === false) { + alert(translateText("store.checkout_failed")); + return; + } + window.location.href = url; + } finally { + this.busy = false; + } + } + + render() { + const price = `$${this.priceDollars}`; + return html` +
+
+ ${translateText("store.custom_amount")} +
+ +
+ + ${this.amount.toLocaleString()} + ${translateText("cosmetics.hard")} + + + + +
+ + +
+ `; + } +}