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
co-authored by Claude Fable 5
parent 6a6c142388
commit bf61f5847b
9 changed files with 535 additions and 8 deletions
+134
View File
@@ -1,4 +1,6 @@
import {
ClaimAllRewardsResponseSchema,
ClaimRewardResponseSchema,
GoogleUser,
GoogleUserSchema,
hasActiveSubscription,
@@ -8,7 +10,9 @@ import {
PlayerProfileSchema,
PublicPlayerGameSchema,
PublicPlayerGamesResponseSchema,
RewardSchema,
UserMeResponse,
UserMeResponseSchema,
} from "../src/core/ApiSchemas";
describe("GoogleUserSchema", () => {
@@ -203,6 +207,136 @@ describe("PublicPlayerGamesResponseSchema", () => {
});
});
describe("RewardSchema", () => {
const validReward = {
id: "42",
currencyType: "hard",
amount: "500",
reason: "subscription_signup_bonus",
note: "Subscription signup bonus (Gold)",
};
it("accepts a fully-populated reward", () => {
expect(RewardSchema.safeParse(validReward).success).toBe(true);
});
it("keeps id and amount as strings (bigints can exceed MAX_SAFE_INTEGER)", () => {
const result = RewardSchema.safeParse({
...validReward,
amount: "9007199254740993",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe("42");
expect(result.data.amount).toBe("9007199254740993");
}
});
it("accepts note: null", () => {
expect(RewardSchema.safeParse({ ...validReward, note: null }).success).toBe(
true,
);
});
it("accepts an unknown reason (open-ended by design)", () => {
expect(
RewardSchema.safeParse({ ...validReward, reason: "future_source" })
.success,
).toBe(true);
});
it("rejects an unknown currencyType", () => {
expect(
RewardSchema.safeParse({ ...validReward, currencyType: "gems" }).success,
).toBe(false);
});
});
describe("UserMeResponseSchema rewards", () => {
const basePlayer = {
publicId: "p1",
adfree: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
};
it("accepts a player with unclaimed rewards", () => {
const result = UserMeResponseSchema.safeParse({
user: {},
player: {
...basePlayer,
rewards: [
{
id: "42",
currencyType: "soft",
amount: "150",
reason: "subscription_daily",
note: "Daily Gold subscription reward (5 days)",
},
],
},
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.player.rewards).toHaveLength(1);
}
});
it("accepts a response without rewards (older API versions)", () => {
expect(
UserMeResponseSchema.safeParse({ user: {}, player: basePlayer }).success,
).toBe(true);
});
});
describe("claim response schemas", () => {
it("coerces claim balances from bigint strings to numbers", () => {
const result = ClaimRewardResponseSchema.safeParse({
id: "42",
currencyType: "hard",
amount: "500",
claimedAt: "2026-07-09T18:03:11.000Z",
currency: { soft: "1200", hard: "850" },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.currency).toEqual({ soft: 1200, hard: 850 });
}
});
it("accepts a claim-all with nothing pending", () => {
const result = ClaimAllRewardsResponseSchema.safeParse({
claimed: [],
currency: { soft: "0", hard: "0" },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.claimed).toEqual([]);
}
});
it("accepts a claim-all with claimed rewards", () => {
const result = ClaimAllRewardsResponseSchema.safeParse({
claimed: [
{
id: "42",
currencyType: "hard",
amount: "500",
reason: "subscription_signup_bonus",
note: null,
claimedAt: "2026-07-09T18:03:11.000Z",
},
],
currency: { soft: "1200", hard: "850" },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.claimed).toEqual([{ id: "42" }]);
}
});
});
describe("hasActiveSubscription", () => {
function userMeWith(
subscription: UserMeResponse["player"]["subscription"],