("rewards-changed", {
+ detail,
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ private async handleClaim(reward: Reward): Promise
{
+ 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 {
+ 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`
+
+
+ ${isHard
+ ? html`
`
+ : html`
`}
+
+ +${this.formatAmount(reward.amount)}
+ ${this.rewardLabel(reward)}
+
+
+
this.handleClaim(reward)}
+ >
+
+ `;
+ }
+
+ render() {
+ if (this.rewards.length === 0) return html``;
+ return html`
+
+
+
+ 🎁
+ ${translateText("account_modal.unclaimed_rewards")}
+
+ ${this.rewards.length > 1
+ ? html``
+ : ""}
+
+
+ ${this.rewards.map((r) => this.renderReward(r))}
+
+
+ `;
+ }
+}
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index 75e90f8a5..0eeb2ee6f 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -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;
+
+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;
+
+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({
diff --git a/tests/ApiSchemas.test.ts b/tests/ApiSchemas.test.ts
index 40286904c..4a15fefb6 100644
--- a/tests/ApiSchemas.test.ts
+++ b/tests/ApiSchemas.test.ts
@@ -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"],