mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 13:42:03 +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:
+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({
|
||||
|
||||
Reference in New Issue
Block a user