mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-29 12:32:13 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user