From b379502516dcff78f991c360f5f06e8dc1b5c410 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 16 Jul 2026 12:35:33 -0700 Subject: [PATCH] fix(store): specific rate-limit message on tier change + use shared dialogs for subscription flows (#4622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ``, 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 --- resources/lang/en.json | 1 + src/client/Api.ts | 6 ++++- src/client/Cosmetics.ts | 27 +++++++++++++++------- src/client/components/SubscriptionPanel.ts | 16 +++++++++---- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 62acedfb9..cf943f929 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1354,6 +1354,7 @@ "store": { "already_subscribed": "Already subscribed.", "change_tier_failed": "Couldn't update your subscription. Please try again.", + "change_tier_rate_limited": "You just changed tiers. Please wait a minute before changing again.", "change_tier_success": "Switched to {tier}.", "checkout_failed": "Failed to create checkout session.", "confirm_downgrade": "Downgrade to {tier}? You'll get account credit for the unused portion of your current plan.", diff --git a/src/client/Api.ts b/src/client/Api.ts index 86c84964e..c352a1862 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -410,7 +410,7 @@ export async function cancelSubscription(): Promise { export async function changeSubscriptionTier( tierName: string, -): Promise { +): Promise { 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", diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 7445cd084..40fcb90e3 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -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; diff --git a/src/client/components/SubscriptionPanel.ts b/src/client/components/SubscriptionPanel.ts index 04a4e63e5..903857141 100644 --- a/src/client/components/SubscriptionPanel.ts +++ b/src/client/components/SubscriptionPanel.ts @@ -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 => { 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 => { - 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(); };