mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-12 08:43:50 +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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 "";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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