mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-25 08:08:35 +00:00
feat(rewards): claimable subscription rewards UI (#4566)
## Summary Subscription perks (signup bonus + daily soft/hard currency) now land as **unclaimed rewards** the player must explicitly claim, instead of crediting balances directly (API side: openfrontio/infra#409). This PR adds the client UI: - **`RewardsPanel`** — shared Lit element listing pending rewards (currency icon, `+amount`, server `note` with translated fallbacks for known reasons), with per-reward **Claim** and **Claim All**. Both claim endpoints return post-claim balances, so the wallet updates without re-fetching `/users/@me`. - **Account modal** — renders the panel above the subscription panel (rewards survive subscription lapse, so it doesn't depend on an active sub). - **`RewardsModal`** — popup at login when rewards are pending. Reuses `RewardsPanel`, auto-closes when the last reward is claimed. Only opens on a clean homepage load (`pathname === "/"`, empty hash) so it never pops over join links, `#modal=...` deep links, or the Stripe `#purchase-completed` return — that flow reloads clean, so the signup bonus popup appears right after purchase. - **Schemas/API** — `RewardSchema` + `rewards[]` on `/users/@me` (optional for older API versions), `claimReward` / `claimAllRewards` in `Api.ts`. Reward `id`/`amount` stay opaque bigint strings; a 404 on single claim means "already claimed elsewhere" (double-click / second device) and re-syncs instead of erroring. ## Screenshots Login popup (also embedded in the account modal): - Panel: currency icon + amount + note per row, Claim / Claim All buttons - Amounts formatted via `BigInt` (can exceed `Number.MAX_SAFE_INTEGER`) ## Test plan - [x] `npm test` — 161 tests pass, including new coverage for `RewardSchema`, `/users/@me` with/without `rewards`, and both claim response shapes - [x] `tsc --noEmit`, ESLint, Prettier clean - [x] Verified in the running app (Playwright): panel renders in the account-modal style; single claim against a stubbed endpoint credits balances and removes the row; Claim All empties the list and auto-closes the popup; body scroll restored 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ export class PlayPage extends LitElement {
|
||||
class="flex flex-col gap-2 w-full px-0 lg:px-4 min-h-0"
|
||||
>
|
||||
<token-login class="absolute"></token-login>
|
||||
<rewards-modal class="absolute"></rewards-modal>
|
||||
|
||||
<!-- Mobile: Fixed top bar -->
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { Reward } from "../../core/ApiSchemas";
|
||||
import {
|
||||
claimAllRewards,
|
||||
claimReward,
|
||||
getUserMe,
|
||||
invalidateUserMe,
|
||||
} from "../Api";
|
||||
import { translateText } from "../Utils";
|
||||
import "./baseComponents/Button";
|
||||
import "./CapIcon";
|
||||
import "./PlutoniumIcon";
|
||||
|
||||
// The new state of the rewards list after a claim. `currency` is the fresh
|
||||
// post-claim balances, or null when they couldn't be determined (the parent
|
||||
// should leave its wallet display unchanged).
|
||||
export interface RewardsChangedDetail {
|
||||
currency: { soft: number; hard: number } | null;
|
||||
rewards: Reward[];
|
||||
}
|
||||
|
||||
@customElement("rewards-panel")
|
||||
export class RewardsPanel extends LitElement {
|
||||
@property({ type: Array })
|
||||
rewards: Reward[] = [];
|
||||
|
||||
@state() private claiming = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private emitChanged(detail: RewardsChangedDetail): void {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<RewardsChangedDetail>("rewards-changed", {
|
||||
detail,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async handleClaim(reward: Reward): Promise<void> {
|
||||
if (this.claiming) return;
|
||||
this.claiming = true;
|
||||
try {
|
||||
const result = await claimReward(reward.id);
|
||||
if (result === false) {
|
||||
alert(translateText("account_modal.claim_failed"));
|
||||
return;
|
||||
}
|
||||
invalidateUserMe();
|
||||
if (result === "not_found") {
|
||||
// Already claimed elsewhere (double-click or second device) — the
|
||||
// currency was still credited exactly once. Re-sync from the server.
|
||||
const userMe = await getUserMe();
|
||||
this.emitChanged({
|
||||
currency: userMe === false ? null : (userMe.player.currency ?? null),
|
||||
rewards:
|
||||
userMe === false
|
||||
? this.rewards.filter((r) => r.id !== reward.id)
|
||||
: (userMe.player.rewards ?? []),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.emitChanged({
|
||||
currency: result.currency,
|
||||
rewards: this.rewards.filter((r) => r.id !== reward.id),
|
||||
});
|
||||
} finally {
|
||||
this.claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleClaimAll(): Promise<void> {
|
||||
if (this.claiming) return;
|
||||
this.claiming = true;
|
||||
try {
|
||||
const result = await claimAllRewards();
|
||||
if (result === false) {
|
||||
alert(translateText("account_modal.claim_failed"));
|
||||
return;
|
||||
}
|
||||
invalidateUserMe();
|
||||
this.emitChanged({ currency: result.currency, rewards: [] });
|
||||
} finally {
|
||||
this.claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Amounts are stringified bigints that can exceed Number.MAX_SAFE_INTEGER.
|
||||
private formatAmount(amount: string): string {
|
||||
try {
|
||||
return BigInt(amount).toLocaleString();
|
||||
} catch {
|
||||
return amount;
|
||||
}
|
||||
}
|
||||
|
||||
private rewardLabel(reward: Reward): string {
|
||||
if (reward.note) return reward.note;
|
||||
if (reward.reason === "subscription_signup_bonus") {
|
||||
return translateText("account_modal.reward_signup_bonus");
|
||||
}
|
||||
if (reward.reason === "subscription_daily") {
|
||||
return translateText("account_modal.reward_daily");
|
||||
}
|
||||
return reward.reason;
|
||||
}
|
||||
|
||||
private renderReward(reward: Reward): TemplateResult {
|
||||
const isHard = reward.currencyType === "hard";
|
||||
return html`
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 p-3 rounded-lg bg-white/5 border border-white/10"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
${isHard
|
||||
? html`<plutonium-icon .size=${20}></plutonium-icon>`
|
||||
: html`<cap-icon .size=${20}></cap-icon>`}
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span
|
||||
class="text-sm font-bold ${isHard
|
||||
? "text-green-400"
|
||||
: "text-amber-700"}"
|
||||
>+${this.formatAmount(reward.amount)}</span
|
||||
>
|
||||
<span class="text-xs text-white/60 truncate"
|
||||
>${this.rewardLabel(reward)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<o-button
|
||||
variant="primary"
|
||||
size="xs"
|
||||
translationKey="account_modal.claim"
|
||||
.disable=${this.claiming}
|
||||
@click=${() => this.handleClaim(reward)}
|
||||
></o-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.rewards.length === 0) return html``;
|
||||
return html`
|
||||
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<h3 class="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span>🎁</span>
|
||||
${translateText("account_modal.unclaimed_rewards")}
|
||||
</h3>
|
||||
${this.rewards.length > 1
|
||||
? html`<o-button
|
||||
variant="primary"
|
||||
size="xs"
|
||||
translationKey="account_modal.claim_all"
|
||||
.disable=${this.claiming}
|
||||
@click=${this.handleClaimAll}
|
||||
></o-button>`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
${this.rewards.map((r) => this.renderReward(r))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user