fix(store): specific rate-limit message on tier change + use shared dialogs for subscription flows (#4622)

## Summary

- The change-tier API allows one tier change per minute per player
(`playerRateLimit` on `/subscriptions/@me/change-tier`). The client
previously lumped the 429 in with every other failure and showed
"Couldn't update your subscription. Please try again." — retrying
immediately just fails again. A 429 now gets its own message: *"You just
changed tiers. Please wait a minute before changing again."*
- Replaced all native `alert()` / `window.confirm()` calls in the
subscription flows with the shared `showInGameAlert` /
`showInGameConfirm` helpers (styled `<confirm-dialog>`, consistent with
the rest of the store, and safe on CrazyGames):
- Tier change: already-subscribed notice, upgrade/downgrade confirmation
(warning variant with "Change Tier" heading),
rate-limited/failure/success notices
  - Dollar checkout failures (first-time subscribe path)
- Subscription panel: cancel confirmation (danger variant), cancel
failure/success, portal-open failure

The currency-purchase alerts in Cosmetics.ts (`login_required`,
`purchase_failed`, `purchase_success`) are untouched — subscriptions
can't reach that path.

## Test plan

- [x] Typecheck, ESLint, Prettier
- [ ] Manual: change tier twice within a minute against the real API and
confirm the rate-limit dialog appears

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-16 12:35:33 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 46162c0e27
commit b379502516
4 changed files with 37 additions and 13 deletions
+5 -1
View File
@@ -410,7 +410,7 @@ export async function cancelSubscription(): Promise<boolean> {
export async function changeSubscriptionTier(
tierName: string,
): Promise<boolean> {
): Promise<boolean | "rate_limited"> {
try {
const response = await fetch(
`${getApiBase()}/subscriptions/@me/change-tier`,
@@ -427,6 +427,10 @@ export async function changeSubscriptionTier(
await logOut();
return false;
}
// The API allows one tier change per minute per player.
if (response.status === 429) {
return "rate_limited";
}
if (!response.ok) {
console.error(
"changeSubscriptionTier: request failed",
+19 -8
View File
@@ -29,6 +29,7 @@ import {
invalidateUserMe,
purchaseWithCurrency,
} from "./Api";
import { showInGameAlert, showInGameConfirm } from "./InGameModal";
import { translateText } from "./Utils";
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
@@ -92,7 +93,7 @@ export async function purchaseCosmetic(
if (currentSub) {
if (currentSub.tier === sub.name) {
alert(translateText("store.already_subscribed"));
await showInGameAlert(translateText("store.already_subscribed"));
return;
}
@@ -108,17 +109,27 @@ export async function purchaseCosmetic(
const confirmKey = isUpgrade
? "store.confirm_upgrade"
: "store.confirm_downgrade";
const confirmed = window.confirm(
const confirmed = await showInGameConfirm(
translateText(confirmKey, { tier: targetName }),
{
heading: translateText("account_modal.change_tier"),
variant: "warning",
},
);
if (!confirmed) return;
const ok = await changeSubscriptionTier(sub.name);
if (!ok) {
alert(translateText("store.change_tier_failed"));
const result = await changeSubscriptionTier(sub.name);
if (result === "rate_limited") {
await showInGameAlert(translateText("store.change_tier_rate_limited"));
return;
}
alert(translateText("store.change_tier_success", { tier: targetName }));
if (!result) {
await showInGameAlert(translateText("store.change_tier_failed"));
return;
}
await showInGameAlert(
translateText("store.change_tier_success", { tier: targetName }),
);
window.location.reload();
return;
}
@@ -126,7 +137,7 @@ export async function purchaseCosmetic(
if (method === "dollar") {
if (!c.product) {
alert(translateText("store.checkout_failed"));
await showInGameAlert(translateText("store.checkout_failed"));
return;
}
const url = await createCheckoutSession(
@@ -134,7 +145,7 @@ export async function purchaseCosmetic(
colorPaletteName,
);
if (url === false) {
alert(translateText("store.checkout_failed"));
await showInGameAlert(translateText("store.checkout_failed"));
return;
}
window.location.href = url;
+12 -4
View File
@@ -8,6 +8,7 @@ import {
openSubscriptionPortal,
} from "../Api";
import { translateCosmetic } from "../Cosmetics";
import { showInGameAlert, showInGameConfirm } from "../InGameModal";
import { translateText } from "../Utils";
import "./baseComponents/Button";
import "./CapIcon";
@@ -28,7 +29,9 @@ export class SubscriptionPanel extends LitElement {
private handleManage = async (): Promise<void> => {
const url = await openSubscriptionPortal();
if (url === false) {
alert(translateText("account_modal.subscription_portal_failed"));
await showInGameAlert(
translateText("account_modal.subscription_portal_failed"),
);
return;
}
window.open(url, "_blank", "noopener,noreferrer");
@@ -39,16 +42,21 @@ export class SubscriptionPanel extends LitElement {
};
private handleCancel = async (): Promise<void> => {
const confirmed = window.confirm(
const confirmed = await showInGameConfirm(
translateText("account_modal.cancel_subscription_confirm"),
{ heading: translateText("account_modal.cancel_subscription") },
);
if (!confirmed) return;
const ok = await cancelSubscription();
if (!ok) {
alert(translateText("account_modal.cancel_subscription_failed"));
await showInGameAlert(
translateText("account_modal.cancel_subscription_failed"),
);
return;
}
alert(translateText("account_modal.cancel_subscription_success"));
await showInGameAlert(
translateText("account_modal.cancel_subscription_success"),
);
invalidateUserMe();
window.location.reload();
};