Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,24 @@
"account_modal": {
"title": "Account",
"connected_as": "Connected as",
"your_subscription": "Your Subscription",
"manage_subscription": "Manage",
"cancel_subscription": "Cancel",
"cancel_subscription_confirm": "Cancel your subscription? It will stay active until the end of the current billing period.",
"cancel_subscription_success": "Subscription canceled. Access continues until the end of the billing period.",
"cancel_subscription_failed": "Failed to cancel subscription. Please try again or use Manage.",
"subscription_portal_failed": "Failed to open the subscription management portal.",
"sub_status_active": "Active",
"sub_status_trialing": "Trial",
"sub_status_past_due": "Past Due",
"sub_status_unpaid": "Unpaid",
"sub_status_incomplete": "Incomplete",
"sub_status_incomplete_expired": "Expired",
"sub_status_canceled": "Canceled",
"sub_status_paused": "Paused",
"sub_status_canceling": "Canceling",
"sub_status_canceling_on": "Cancels {date}",
"sub_renews_on": "Renews {date}",
"stats_overview": "Stats Overview",
"link_discord": "Link Discord Account",
"log_out": "Log Out",
Expand Down Expand Up @@ -1093,10 +1111,14 @@
"patterns": "Skins",
"flags": "Flags",
"packs": "Packs",
"subscriptions": "Subscriptions",
"no_flags": "No flags available. Check back later for new items.",
"no_skins": "No skins available. Check back later for new items.",
"no_packs": "No packs available. Check back later for new items.",
"no_subscriptions": "No subscriptions available. Check back later for new items.",
"already_subscribed": "Already subscribed.",
"currency_pack_purchase_success": "Currency pack purchase successful!",
"subscription_purchase_success": "Subscription activated!",
"checkout_failed": "Failed to create checkout session.",
"login_required": "You must be logged in to purchase with currency.",
"not_enough_currency": "Not enough currency for this purchase.",
Expand All @@ -1123,7 +1145,8 @@
"legendary": "Legendary",
"adfree": "ad-free for life!",
"hard": "Plutonium",
"soft": "Caps"
"soft": "Caps",
"per_day": "/day"
},
"flag_input": {
"title": "Select Flag",
Expand Down
59 changes: 58 additions & 1 deletion src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import {
UserMeResponse,
} from "../core/ApiSchemas";
import { assetUrl } from "../core/AssetUrls";
import { fetchPlayerById, getUserMe } from "./Api";
import { Cosmetics } from "../core/CosmeticSchemas";
import {
cancelSubscription,
fetchPlayerById,
getUserMe,
invalidateUserMe,
openSubscriptionPortal,
} from "./Api";
import { discordLogin, logOut, sendMagicLink } from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
import "./components/baseComponents/stats/GameList";
Expand All @@ -17,7 +24,9 @@ import { BaseModal } from "./components/BaseModal";
import "./components/CopyButton";
import "./components/CurrencyDisplay";
import "./components/Difficulties";
import "./components/SubscriptionPanel";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
import { translateText } from "./Utils";

@customElement("account-modal")
Expand All @@ -28,6 +37,7 @@ export class AccountModal extends BaseModal {
private userMeResponse: UserMeResponse | null = null;
private statsTree: PlayerStatsTree | null = null;
private recentGames: PlayerGame[] = [];
private cosmetics: Cosmetics | null = null;

constructor() {
super();
Expand Down Expand Up @@ -157,6 +167,8 @@ export class AccountModal extends BaseModal {
</div>
</div>

${this.renderSubscriptionPanel()}

<!-- Middle Row: Stats Section -->
${this.hasAnyStats()
? html`<div
Expand Down Expand Up @@ -192,6 +204,46 @@ export class AccountModal extends BaseModal {
`;
}

private renderSubscriptionPanel(): TemplateResult | "" {
const sub = this.userMeResponse?.player?.subscription;
if (!sub) return "";
const cosmetic = this.cosmetics?.subscriptions?.[sub.tier] ?? null;
return html`<subscription-panel
.sub=${sub}
.cosmetic=${cosmetic}
@subscription-manage=${this.handleManageSubscription}
@subscription-cancel=${this.handleCancelSubscription}
></subscription-panel>`;
}

private handleManageSubscription = async (): Promise<void> => {
const url = await openSubscriptionPortal();
if (url === false) {
alert(translateText("account_modal.subscription_portal_failed"));
return;
}
window.open(url, "_blank", "noopener,noreferrer");
};

private handleCancelSubscription = async (): Promise<void> => {
const confirmed = window.confirm(
translateText("account_modal.cancel_subscription_confirm"),
);
if (!confirmed) return;
const ok = await cancelSubscription();
if (!ok) {
alert(translateText("account_modal.cancel_subscription_failed"));
return;
}
alert(translateText("account_modal.cancel_subscription_success"));
invalidateUserMe();
const userMe = await getUserMe();
if (userMe) {
this.userMeResponse = userMe;
}
this.requestUpdate();
};
Comment on lines +228 to +245
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add error handling for getUserMe() after successful cancellation.

If cancelSubscription() succeeds but the subsequent getUserMe() call fails (line 240), the UI won't update to reflect the cancellation. The user will see the success alert but the subscription panel will continue to display.

🛡️ Proposed fix
  alert(translateText("account_modal.cancel_subscription_success"));
  invalidateUserMe();
- const userMe = await getUserMe();
- if (userMe) {
-   this.userMeResponse = userMe;
- }
- this.requestUpdate();
+ try {
+   const userMe = await getUserMe();
+   if (userMe) {
+     this.userMeResponse = userMe;
+   }
+   this.requestUpdate();
+ } catch (err) {
+   console.warn("Failed to refresh user after cancel:", err);
+   this.requestUpdate();
+ }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/AccountModal.ts` around lines 228 - 245, handleCancelSubscription
shows a success alert but doesn’t handle errors from the subsequent getUserMe()
call, so the UI may not reflect the cancelled subscription; update
handleCancelSubscription to wrap the getUserMe() call in try/catch, call
invalidateUserMe() before/after fetching, set this.userMeResponse only on
successful fetch, and on failure log the error and ensure the UI is updated
(this.requestUpdate()) so the subscription panel reflects the cancelled state
even if user fetch fails; reference functions/methods: handleCancelSubscription,
cancelSubscription, getUserMe, invalidateUserMe, this.userMeResponse,
this.requestUpdate.


private renderCurrency(): TemplateResult {
const currency = this.userMeResponse?.player?.currency;
if (!currency) return html``;
Expand Down Expand Up @@ -377,6 +429,11 @@ export class AccountModal extends BaseModal {
protected onOpen(): void {
this.isLoadingUser = true;

void fetchCosmetics().then((cosmetics) => {
this.cosmetics = cosmetics;
this.requestUpdate();
});
Comment on lines +432 to +435
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add error handling for fetchCosmetics().

If fetchCosmetics() rejects, the promise is unhandled and this.cosmetics will remain null indefinitely. Users with subscriptions would see an incomplete subscription panel.

🛡️ Proposed fix
- void fetchCosmetics().then((cosmetics) => {
-   this.cosmetics = cosmetics;
-   this.requestUpdate();
- });
+ void fetchCosmetics()
+   .then((cosmetics) => {
+     this.cosmetics = cosmetics;
+     this.requestUpdate();
+   })
+   .catch((err) => {
+     console.warn("Failed to fetch cosmetics in AccountModal:", err);
+     this.requestUpdate();
+   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void fetchCosmetics().then((cosmetics) => {
this.cosmetics = cosmetics;
this.requestUpdate();
});
void fetchCosmetics()
.then((cosmetics) => {
this.cosmetics = cosmetics;
this.requestUpdate();
})
.catch((err) => {
console.warn("Failed to fetch cosmetics in AccountModal:", err);
this.requestUpdate();
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/AccountModal.ts` around lines 432 - 435, The promise from
fetchCosmetics() is currently unhandled; wrap the call so rejections are caught
and handled: call fetchCosmetics() and in the .then set this.cosmetics and
requestUpdate(), and add a .catch handler that logs the error (or calls
this.error reporting), sets this.cosmetics to an appropriate fallback (e.g.,
empty array or error state), and calls this.requestUpdate() so the UI renders a
non-blocking subscription panel; reference the existing fetchCosmetics
invocation and the this.cosmetics / this.requestUpdate usage when making the
change.


void getUserMe()
.then((userMe) => {
if (userMe) {
Expand Down
59 changes: 59 additions & 0 deletions src/client/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,65 @@ export async function createCheckoutSession(
}
}

export async function cancelSubscription(): Promise<boolean> {
try {
const response = await fetch(`${getApiBase()}/subscriptions/@me/cancel`, {
method: "POST",
headers: {
Authorization: await getAuthHeader(),
},
});
if (response.status === 401) {
await logOut();
return false;
}
if (!response.ok) {
console.error(
"cancelSubscription: request failed",
response.status,
response.statusText,
);
return false;
}
return true;
} catch (e) {
console.error("cancelSubscription: request failed", e);
return false;
}
}

export async function openSubscriptionPortal(): Promise<string | false> {
try {
const response = await fetch(`${getApiBase()}/subscriptions/@me/portal`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: await getAuthHeader(),
},
body: JSON.stringify({
returnUrl: window.location.origin,
}),
});
if (response.status === 401) {
await logOut();
return false;
}
if (!response.ok) {
console.error(
"openSubscriptionPortal: request failed",
response.status,
response.statusText,
);
return false;
}
const json = await response.json();
return json.url;
} catch (e) {
console.error("openSubscriptionPortal: request failed", e);
return false;
}
}
Comment thread
evanpelle marked this conversation as resolved.

export function getApiBase() {
const domainname = getAudience();

Expand Down
47 changes: 43 additions & 4 deletions src/client/Cosmetics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Pack,
Pattern,
Product,
Subscription,
} from "../core/CosmeticSchemas";
import {
PlayerCosmeticRefs,
Expand Down Expand Up @@ -39,6 +40,15 @@ export async function purchaseCosmetic(
const c = resolved.cosmetic;
const colorPaletteName = resolved.colorPalette?.name;

if (resolved.type === "subscription") {
const userMe = await getUserMe();
const flares = userMe === false ? [] : (userMe.player.flares ?? []);
if (flares.some((f) => f.startsWith("subscription:"))) {
alert(translateText("store.already_subscribed"));
return;
}
}

if (method === "dollar") {
if (!c.product) {
alert(translateText("store.checkout_failed"));
Expand All @@ -56,8 +66,18 @@ export async function purchaseCosmetic(
return;
}

// Currency purchase (hard or soft)
const price = method === "hard" ? (c.priceHard ?? 0) : (c.priceSoft ?? 0);
// Currency purchase (hard or soft) — not valid for subscriptions.
if (resolved.type === "subscription") {
console.error(
"purchaseCosmetic: currency purchase not supported for subscriptions",
);
return;
}
// ResolvedCosmetic isn't a discriminated union, so the guard above doesn't
// narrow cosmetic's type. Subscriptions are excluded by the runtime check.
const priced = c as Pattern | Flag | Pack;
const price =
method === "hard" ? (priced.priceHard ?? 0) : (priced.priceSoft ?? 0);
const userMe = await getUserMe();
if (userMe === false) {
alert(translateText("store.login_required"));
Expand Down Expand Up @@ -254,8 +274,8 @@ export function flagRelationship(
}

export type ResolvedCosmetic = {
type: "pattern" | "flag" | "pack";
cosmetic: Pattern | Flag | Pack | null;
type: "pattern" | "flag" | "pack" | "subscription";
cosmetic: Pattern | Flag | Pack | Subscription | null;
colorPalette: ColorPalette | null;
relationship: "owned" | "purchasable" | "blocked";
/** Unique key for selection/identity, e.g. "pattern:hearts:red" or "flag:cool_flag" */
Expand Down Expand Up @@ -333,6 +353,25 @@ export function resolveCosmetics(
});
}

// Subscriptions
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
for (const [subKey, sub] of Object.entries(cosmetics.subscriptions ?? {})) {
const key = `subscription:${subKey}`;
const rel = flares.includes(key)
? "owned"
: sub.product
? "purchasable"
: "blocked";
result.push({
type: "subscription",
cosmetic: sub,
colorPalette: null,
relationship: rel,
key,
});
}

return result;
}

Expand Down
10 changes: 9 additions & 1 deletion src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
UserSettings,
} from "../core/game/UserSettings";
import "./AccountModal";
import { getUserMe } from "./Api";
import { getUserMe, invalidateUserMe } from "./Api";
import { userAuth } from "./Auth";
import "./ClanModal";
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
Expand Down Expand Up @@ -659,6 +659,14 @@ class Client {
return;
}

if (type === "subscription_tier") {
alert(translateText("store.subscription_purchase_success"));
strip();
invalidateUserMe();
window.location.reload();
return;
}

const cosmeticName = params.get("cosmetic");
if (!cosmeticName) {
alert("Something went wrong. Please contact support.");
Expand Down
Loading
Loading