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:
Evan
2026-07-10 11:21:52 -07:00
committed by GitHub
parent 6a6c142388
commit bf61f5847b
9 changed files with 535 additions and 8 deletions
+26 -2
View File
@@ -28,6 +28,8 @@ import "./components/CopyButton";
import "./components/CurrencyDisplay";
import "./components/Difficulties";
import "./components/FriendsList";
import "./components/RewardsPanel";
import type { RewardsChangedDetail } from "./components/RewardsPanel";
import "./components/SubscriptionPanel";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
@@ -326,7 +328,7 @@ export class AccountModal extends BaseModal {
</div>
</div>
</div>
${this.renderSubscriptionPanel()}
${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()}
</div>
`;
}
@@ -356,7 +358,7 @@ export class AccountModal extends BaseModal {
</div>
</div>
</div>
${this.renderSubscriptionPanel()}
${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()}
</div>
`;
}
@@ -438,6 +440,28 @@ export class AccountModal extends BaseModal {
`;
}
private renderRewardsPanel(): TemplateResult | "" {
const rewards = this.userMeResponse?.player?.rewards ?? [];
if (rewards.length === 0) return "";
return html`<rewards-panel
.rewards=${rewards}
@rewards-changed=${this.handleRewardsChanged}
></rewards-panel>`;
}
// A claim moved unclaimed rewards into the balances; both were returned by
// the claim endpoint, so update in place instead of re-fetching /users/@me.
private handleRewardsChanged = (
event: CustomEvent<RewardsChangedDetail>,
): void => {
if (!this.userMeResponse) return;
this.userMeResponse.player.rewards = event.detail.rewards;
if (event.detail.currency) {
this.userMeResponse.player.currency = event.detail.currency;
}
this.requestUpdate();
};
private renderSubscriptionPanel(): TemplateResult | "" {
const sub = this.userMeResponse?.player?.subscription;
if (!sub) return "";
+85
View File
@@ -2,6 +2,10 @@ import newsItemsFallback from "resources/news.json";
import { z } from "zod";
import type { NewsItem } from "../core/ApiSchemas";
import {
ClaimAllRewardsResponse,
ClaimAllRewardsResponseSchema,
ClaimRewardResponse,
ClaimRewardResponseSchema,
NewsItemSchema,
PlayerGameModeFilter,
PlayerGameTypeFilter,
@@ -222,6 +226,87 @@ export async function purchaseWithCurrency(
}
}
// POST /rewards/:rewardId/claim — claims a single unclaimed reward and
// credits the balance atomically. "not_found" covers unknown, already-claimed
// and other players' rewards (indistinguishable by design); the usual cause is
// a double-click or a second device claiming first, so callers should re-fetch
// /users/@me and re-render rather than surface an error.
export async function claimReward(
rewardId: string,
): Promise<ClaimRewardResponse | "not_found" | false> {
try {
const response = await fetch(
`${getApiBase()}/rewards/${encodeURIComponent(rewardId)}/claim`,
{
method: "POST",
headers: {
Authorization: await getAuthHeader(),
},
},
);
if (response.status === 401) {
await logOut();
return false;
}
if (response.status === 404) return "not_found";
if (!response.ok) {
console.error(
"claimReward: request failed",
response.status,
response.statusText,
);
return false;
}
const parsed = ClaimRewardResponseSchema.safeParse(await response.json());
if (!parsed.success) {
console.error("claimReward: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch (e) {
console.error("claimReward: request failed", e);
return false;
}
}
// POST /rewards/claim-all — claims all pending rewards in one transaction.
// Succeeds (with an empty `claimed`) even when nothing is pending.
export async function claimAllRewards(): Promise<
ClaimAllRewardsResponse | false
> {
try {
const response = await fetch(`${getApiBase()}/rewards/claim-all`, {
method: "POST",
headers: {
Authorization: await getAuthHeader(),
},
});
if (response.status === 401) {
await logOut();
return false;
}
if (!response.ok) {
console.error(
"claimAllRewards: request failed",
response.status,
response.statusText,
);
return false;
}
const parsed = ClaimAllRewardsResponseSchema.safeParse(
await response.json(),
);
if (!parsed.success) {
console.error("claimAllRewards: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch (e) {
console.error("claimAllRewards: request failed", e);
return false;
}
}
export async function createCheckoutSession(
priceId: string,
colorPaletteName?: string,
+18
View File
@@ -48,6 +48,7 @@ import { modalRouter } from "./ModalRouter";
import { initNavigation } from "./Navigation";
import "./NewsModal";
import "./PatternInput";
import { RewardsModal } from "./RewardsModal";
import "./SinglePlayerModal";
import { StoreModal } from "./Store";
import "./TerritoryPatternsModal";
@@ -266,6 +267,7 @@ class Client {
private storeModal: StoreModal;
private tokenLoginModal: TokenLoginModal;
private matchmakingModal: MatchmakingModal;
private rewardsModal: RewardsModal;
private mostRecentJoinEvent: number;
private turnstileTokenPromise: Promise<{
@@ -505,6 +507,11 @@ class Client {
console.warn("Matchmaking modal element not found");
}
this.rewardsModal = document.querySelector("rewards-modal") as RewardsModal;
if (!this.rewardsModal || !(this.rewardsModal instanceof RewardsModal)) {
console.warn("Rewards modal element not found");
}
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
if (crazyGamesSDK.isOnCrazyGames()) {
void updateCrazyGamesNavButton();
@@ -528,6 +535,17 @@ class Client {
`Your player ID is ${userMeResponse.player.publicId}\n` +
"Sharing this ID will allow others to view your game history and stats.",
);
// Unclaimed-rewards popup — only on a clean homepage load, never over
// a deep link (join URL, #modal=..., #purchase-completed, ...).
const rewards = userMeResponse.player.rewards ?? [];
if (
rewards.length > 0 &&
window.location.pathname === "/" &&
window.location.hash === ""
) {
this.rewardsModal?.openWithRewards(rewards);
}
}
};
+56
View File
@@ -0,0 +1,56 @@
import { html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Reward } from "../core/ApiSchemas";
import { BaseModal } from "./components/BaseModal";
import "./components/RewardsPanel";
import type { RewardsChangedDetail } from "./components/RewardsPanel";
import { modalHeader } from "./components/ui/ModalHeader";
import { translateText } from "./Utils";
// Popup shown at login when the player has unclaimed subscription rewards.
// The list and claim actions are <rewards-panel>, shared with the account
// modal.
@customElement("rewards-modal")
export class RewardsModal extends BaseModal {
@state() private rewards: Reward[] = [];
protected modalConfig() {
return { maxWidth: "620px" };
}
public openWithRewards(rewards: Reward[]): void {
this.rewards = rewards;
this.open();
}
public open(args?: Record<string, unknown>): void {
if (this.rewards.length === 0) return;
super.open(args);
}
protected renderHeaderSlot() {
return modalHeader({
title: translateText("rewards_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
});
}
private handleRewardsChanged = (
event: CustomEvent<RewardsChangedDetail>,
): void => {
this.rewards = event.detail.rewards;
if (this.rewards.length === 0) this.close();
};
protected renderBody(): TemplateResult {
return html`
<div class="p-6">
<rewards-panel
.rewards=${this.rewards}
@rewards-changed=${this.handleRewardsChanged}
></rewards-panel>
</div>
`;
}
}
+1
View File
@@ -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
+170
View File
@@ -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>
`;
}
}
+36 -6
View File
@@ -75,6 +75,39 @@ const SingleplayerMapAchievementSchema = z.object({
difficulty: z.enum(Difficulty),
});
// An unclaimed subscription reward from GET /users/@me. `id` and `amount` are
// stringified bigints — keep them as strings (amount can in principle exceed
// Number.MAX_SAFE_INTEGER). `reason` is open-ended server-side; fall back to
// `note` for unknown values rather than exhausting on an enum.
export const RewardSchema = z.object({
id: z.string(),
currencyType: z.enum(["soft", "hard"]),
amount: z.string(),
reason: z.string(),
note: z.string().nullable(),
});
export type Reward = z.infer<typeof RewardSchema>;
const CurrencyBalancesSchema = z.object({
soft: z.coerce.number(),
hard: z.coerce.number(),
});
// POST /rewards/:rewardId/claim and /rewards/claim-all both return the
// post-claim balances so the UI can update without re-fetching /users/@me.
export const ClaimRewardResponseSchema = z.object({
currency: CurrencyBalancesSchema,
});
export type ClaimRewardResponse = z.infer<typeof ClaimRewardResponseSchema>;
export const ClaimAllRewardsResponseSchema = z.object({
claimed: z.array(z.object({ id: z.string() })),
currency: CurrencyBalancesSchema,
});
export type ClaimAllRewardsResponse = z.infer<
typeof ClaimAllRewardsResponseSchema
>;
export const UserMeResponseSchema = z.object({
user: z.object({
discord: DiscordUserSchema.optional(),
@@ -97,12 +130,9 @@ export const UserMeResponseSchema = z.object({
.optional(),
})
.optional(),
currency: z
.object({
soft: z.coerce.number(),
hard: z.coerce.number(),
})
.optional(),
currency: CurrencyBalancesSchema.optional(),
// Unclaimed rewards — NOT included in `currency` balances until claimed.
rewards: RewardSchema.array().optional(),
clans: z
.array(
z.object({