mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-07 17:16:00 +00:00
feat(client): custom plutonium amount purchase in store (#4515)
## 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 `<custom-currency-card>` 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 `<custom-currency-card>` 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -222,6 +222,40 @@ export async function createCheckoutSession(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCustomCurrencyCheckout(
|
||||
hardAmount: number,
|
||||
): Promise<string | false> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/subscriptions/@me/cancel`, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
+4
-8
@@ -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`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
${translateText("store.no_packs")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 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`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
@@ -183,6 +178,7 @@ export class StoreModal extends BaseModal {
|
||||
></cosmetic-button>
|
||||
`,
|
||||
)}
|
||||
<custom-currency-card></custom-currency-card>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
<div
|
||||
class="relative flex flex-col items-center justify-between gap-2 p-3 w-48 rounded-xl border border-white/15 backdrop-blur-md"
|
||||
style="background: linear-gradient(to top, rgba(80,80,80,0.55) 0%, rgba(15,15,20,0.85) 100%);"
|
||||
>
|
||||
<div
|
||||
class="text-xs font-bold uppercase tracking-wider text-center text-white/70 w-full"
|
||||
>
|
||||
${translateText("store.custom_amount")}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-2 w-full">
|
||||
<plutonium-icon .size=${64}></plutonium-icon>
|
||||
<span class="text-lg font-black text-green-400"
|
||||
>${this.amount.toLocaleString()}</span
|
||||
>
|
||||
<span class="text-[10px] font-bold text-white/50 uppercase"
|
||||
>${translateText("cosmetics.hard")}</span
|
||||
>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
class="w-full accent-green-500 cursor-pointer"
|
||||
min=${MIN_PLUTONIUM}
|
||||
max=${MAX_PLUTONIUM}
|
||||
step="1"
|
||||
.value=${String(this.amount)}
|
||||
@input=${this.onSlider}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
class="w-24 text-center bg-black/30 border border-white/20 rounded px-2 py-1 text-white text-sm"
|
||||
aria-label=${translateText("store.custom_amount")}
|
||||
min=${MIN_PLUTONIUM}
|
||||
max=${MAX_PLUTONIUM}
|
||||
step="1"
|
||||
.value=${String(this.amount)}
|
||||
@change=${this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full mt-1 px-2 py-1.5 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-base font-bold cursor-pointer transition-all duration-200 hover:bg-green-500 hover:border-green-400 hover:text-white hover:shadow-[0_0_20px_rgba(74,222,128,0.6)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
?disabled=${this.busy}
|
||||
@click=${this.buy}
|
||||
>
|
||||
${translateText("store.buy_for", { price })}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user