From 0c83444dc95b7732892526e98e0dcb0d73cbc92f Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 6 Jul 2026 17:23:58 -0700 Subject: [PATCH] feat(client): custom plutonium amount purchase in store (#4515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a **custom plutonium amount** purchase option to the Store, wiring the client to the new `POST /stripe/create-custom-currency-checkout` endpoint (infra [PR #400](https://github.com/openfrontio/infra/pull/400)). Players can buy any integer **20–2000** plutonium, priced inline server-side at **20 plutonium = \$1.00**. A new `` renders as the **last tile in the Store "Packs" tab** — a slider + number input with a live price and a "Buy for \$X.XX" button that redirects to Stripe Checkout. ## Changes | File | Change | |---|---| | `src/client/Api.ts` | New `createCustomCurrencyCheckout(hardAmount)` — POSTs `{ hardAmount, hostname }`, returns the Stripe `url` (mirrors `createCheckoutSession`). | | `src/client/components/CustomCurrencyCard.ts` | New `` component: plutonium slider + number input, live price, clamps to 20–2000 integer client-side, redirects to Stripe on buy. | | `src/client/Store.ts` | Renders the card after the fixed packs in the "Packs" tab; drops the now-unreachable "no packs" empty state. | | `src/client/Main.ts` | Handles `type=custom_currency` in the `#purchase-completed` redirect (success alert + strip hash; balance refetches from `/users/@me` on load). | | `src/client/LangSelector.ts` | Registers `custom-currency-card` for translation-reload re-render. | | `resources/lang/en.json` | Adds `store.buy_for`, `store.custom_amount`, `store.custom_currency_purchase_success`; removes the orphaned `store.no_packs`. | ## Notes - **No catalog product** — unlike currency packs, the custom amount is priced inline server-side, so the card is standalone (not a `resolveCosmetics` item). - **Async credit** — no optimistic balance bump; the balance reflects the Stripe webhook via `/users/@me` on return, matching the currency-pack flow. - Server is authoritative on bounds/rate; client-side clamping is UX-only. ## Testing - Full suite green: **1842 tests / 153 files**; `tsc --noEmit` and eslint clean. - Manually verified in the running app (headless Chromium): card renders in the Packs tab; amount/price stay in sync across slider + number field; clamping confirmed (5000→2000/\$100.00, 5→20/\$1.00, 750→\$37.50 — matches the pricing table). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) --- resources/lang/en.json | 4 +- src/client/Api.ts | 34 ++++++ src/client/LangSelector.ts | 1 + src/client/Main.ts | 7 ++ src/client/Store.ts | 12 +-- src/client/components/CustomCurrencyCard.ts | 108 ++++++++++++++++++++ 6 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 src/client/components/CustomCurrencyCard.ts 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")} + + + + +
+ + +
+ `; + } +}