diff --git a/resources/lang/en.json b/resources/lang/en.json index 56caabec5..ee8c3b935 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -5,6 +5,9 @@ "cancel_subscription_failed": "Failed to cancel subscription. Please try again or use Manage.", "cancel_subscription_success": "Subscription canceled. Access continues until the end of the billing period.", "change_tier": "Change Tier", + "claim": "Claim", + "claim_all": "Claim All", + "claim_failed": "Failed to claim reward. Please try again.", "clear_session": "Clear Session", "connected_as": "Connected as", "email_placeholder": "Enter your email address", @@ -40,6 +43,8 @@ "public_player_id": "Public Player ID:", "reactivate_subscription": "Reactivate", "recovery_email_sent": "Recovery email sent to {email}", + "reward_daily": "Daily subscription reward", + "reward_signup_bonus": "Subscription signup bonus", "sign_in_desc": "Sign in to save your stats and progress", "stats_overview": "Stats Overview", "sub_price_monthly": "{price}/mo", @@ -61,6 +66,7 @@ "tab_settings": "Settings", "tab_stats": "Stats", "title": "Account", + "unclaimed_rewards": "Unclaimed Rewards", "your_subscription": "Your Subscription" }, "build_menu": { @@ -1274,6 +1280,9 @@ "game_speed": "Game speed", "replay_speed": "Replay speed" }, + "rewards_modal": { + "title": "Rewards" + }, "select_lang": { "title": "Select Language" }, diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index b6bae67ac..562005445 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -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 { - ${this.renderSubscriptionPanel()} + ${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()} `; } @@ -356,7 +358,7 @@ export class AccountModal extends BaseModal { - ${this.renderSubscriptionPanel()} + ${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()} `; } @@ -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``; + } + + // 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, + ): 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 ""; diff --git a/src/client/Api.ts b/src/client/Api.ts index 4e8e2ea9a..e0372c672 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -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 { + 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, diff --git a/src/client/Main.ts b/src/client/Main.ts index 5a9d4ead7..8a3a60d9d 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -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); + } } }; diff --git a/src/client/RewardsModal.ts b/src/client/RewardsModal.ts new file mode 100644 index 000000000..96f77c994 --- /dev/null +++ b/src/client/RewardsModal.ts @@ -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 , 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): 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, + ): void => { + this.rewards = event.detail.rewards; + if (this.rewards.length === 0) this.close(); + }; + + protected renderBody(): TemplateResult { + return html` +
+ +
+ `; + } +} diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts index 7da39f2e9..fff2a0b62 100644 --- a/src/client/components/PlayPage.ts +++ b/src/client/components/PlayPage.ts @@ -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" > +
("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"],