mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-21 04:36:07 +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:
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user