diff --git a/resources/lang/en.json b/resources/lang/en.json
index 3735db8822..f6aa4f0187 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -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",
@@ -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.",
@@ -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",
diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts
index 08d92586ec..ab57c70b89 100644
--- a/src/client/AccountModal.ts
+++ b/src/client/AccountModal.ts
@@ -7,6 +7,7 @@ import {
UserMeResponse,
} from "../core/ApiSchemas";
import { assetUrl } from "../core/AssetUrls";
+import { Cosmetics } from "../core/CosmeticSchemas";
import { fetchPlayerById, getUserMe } from "./Api";
import { discordLogin, logOut, sendMagicLink } from "./Auth";
import "./components/baseComponents/stats/DiscordUserHeader";
@@ -17,7 +18,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")
@@ -28,6 +31,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();
@@ -157,6 +161,8 @@ export class AccountModal extends BaseModal {
+ ${this.renderSubscriptionPanel()}
+
${this.hasAnyStats()
? html`
`;
+ }
+
private renderCurrency(): TemplateResult {
const currency = this.userMeResponse?.player?.currency;
if (!currency) return html``;
@@ -377,6 +393,11 @@ export class AccountModal extends BaseModal {
protected onOpen(): void {
this.isLoadingUser = true;
+ void fetchCosmetics().then((cosmetics) => {
+ this.cosmetics = cosmetics;
+ this.requestUpdate();
+ });
+
void getUserMe()
.then((userMe) => {
if (userMe) {
diff --git a/src/client/Api.ts b/src/client/Api.ts
index 9b26f9ad41..a7aa2cfebb 100644
--- a/src/client/Api.ts
+++ b/src/client/Api.ts
@@ -169,6 +169,65 @@ export async function createCheckoutSession(
}
}
+export async function cancelSubscription(): Promise
{
+ 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 {
+ 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;
+ }
+}
+
export function getApiBase() {
const domainname = getAudience();
diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts
index cc11a08c1b..42aff508be 100644
--- a/src/client/Cosmetics.ts
+++ b/src/client/Cosmetics.ts
@@ -8,6 +8,7 @@ import {
Pack,
Pattern,
Product,
+ Subscription,
} from "../core/CosmeticSchemas";
import {
PlayerCosmeticRefs,
@@ -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"));
@@ -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"));
@@ -228,7 +248,7 @@ export function patternRelationship(
priceSoft: pattern.priceSoft,
priceHard: pattern.priceHard,
affiliateCode,
- itemAffiliateCode: pattern.affiliateCode,
+ itemAffiliateCode: pattern.affiliateCode ?? null,
},
userMeResponse,
);
@@ -247,15 +267,15 @@ export function flagRelationship(
priceSoft: flag.priceSoft,
priceHard: flag.priceHard,
affiliateCode,
- itemAffiliateCode: flag.affiliateCode,
+ itemAffiliateCode: flag.affiliateCode ?? null,
},
userMeResponse,
);
}
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" */
@@ -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;
}
diff --git a/src/client/Main.ts b/src/client/Main.ts
index 2c53eaf835..6c7b713720 100644
--- a/src/client/Main.ts
+++ b/src/client/Main.ts
@@ -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";
@@ -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.");
diff --git a/src/client/Store.ts b/src/client/Store.ts
index 460c15a473..08f15daabb 100644
--- a/src/client/Store.ts
+++ b/src/client/Store.ts
@@ -17,7 +17,8 @@ import { translateText } from "./Utils";
@customElement("store-modal")
export class StoreModal extends BaseModal {
- @state() private activeTab: "patterns" | "flags" | "packs" = "patterns";
+ @state() private activeTab: "patterns" | "flags" | "packs" | "subscriptions" =
+ "patterns";
private cosmetics: Cosmetics | null = null;
private isActive = false;
@@ -154,11 +155,45 @@ export class StoreModal extends BaseModal {
`;
}
+ private renderSubscriptionGrid(): TemplateResult {
+ const items = resolveCosmetics(
+ this.cosmetics,
+ this.userMeResponse,
+ this.affiliateCode,
+ ).filter(
+ (r) => r.type === "subscription" && r.relationship === "purchasable",
+ );
+
+ if (items.length === 0) {
+ return html`
+ ${translateText("store.no_subscriptions")}
+
`;
+ }
+
+ return html`
+
+ ${items.map(
+ (r) => html`
+
+ `,
+ )}
+
+ `;
+ }
+
render() {
if (!this.isActive && !this.inline) return html``;
const tabs = [
{ key: "packs", label: translateText("store.packs") },
+ { key: "subscriptions", label: translateText("store.subscriptions") },
{ key: "patterns", label: translateText("store.patterns") },
{ key: "flags", label: translateText("store.flags") },
];
@@ -168,7 +203,9 @@ export class StoreModal extends BaseModal {
? this.renderPatternGrid()
: this.activeTab === "flags"
? this.renderFlagGrid()
- : this.renderPackGrid();
+ : this.activeTab === "subscriptions"
+ ? this.renderSubscriptionGrid()
+ : this.renderPackGrid();
return html`
- (this.activeTab = key as "patterns" | "flags" | "packs")}
+ (this.activeTab = key as
+ | "patterns"
+ | "flags"
+ | "packs"
+ | "subscriptions")}
>
${this.renderHeader()}
${grid}
diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts
index 8799aaef8c..9f24b41026 100644
--- a/src/client/components/CosmeticButton.ts
+++ b/src/client/components/CosmeticButton.ts
@@ -1,6 +1,6 @@
import { html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
-import { Flag, Pack, Pattern } from "../../core/CosmeticSchemas";
+import { Flag, Pack, Pattern, Subscription } from "../../core/CosmeticSchemas";
import { PlayerPattern } from "../../core/Schemas";
import {
PaymentMethod,
@@ -47,6 +47,9 @@ export class CosmeticButton extends LitElement {
if (this.resolved.type === "pack") {
return (c as Pack).displayName;
}
+ if (this.resolved.type === "subscription") {
+ return translateCosmetic("subscriptions", c.name);
+ }
return translateCosmetic("flags", c.name);
}
@@ -91,6 +94,37 @@ export class CosmeticButton extends LitElement {
`;
}
+ if (this.resolved.type === "subscription") {
+ const sub = this.resolved.cosmetic as Subscription;
+ return html`
+
${sub.description}
+
+
+
+
${sub.dailyHardCurrency.toLocaleString()}
+
${translateText("cosmetics.per_day")}
+
+
+
+ ${sub.dailySoftCurrency.toLocaleString()}
+ ${translateText("cosmetics.per_day")}
+
+
+
`;
+ }
+
const c = this.resolved.cosmetic as Flag;
return html`
this.onPurchase?.(this.resolved, "dollar")
: undefined}
- .onPurchaseHard=${isPurchasable && c?.priceHard !== undefined
+ .onPurchaseHard=${isPurchasable && priceHard !== undefined
? () => this.onPurchase?.(this.resolved, "hard")
: undefined}
- .onPurchaseSoft=${isPurchasable && c?.priceSoft !== undefined
+ .onPurchaseSoft=${isPurchasable && priceSoft !== undefined
? () => this.onPurchase?.(this.resolved, "soft")
: undefined}
.name=${this.displayName}
@@ -141,10 +179,10 @@ export class CosmeticButton extends LitElement {
: "gap-1"} rounded-lg cursor-pointer transition-all duration-200 flex-1"
@click=${() => this.handleClick()}
>
- ${(c?.product ?? c?.priceHard ?? c?.priceSoft)
+ ${(c?.product ?? priceHard ?? priceSoft)
? html``
diff --git a/src/client/components/SubscriptionPanel.ts b/src/client/components/SubscriptionPanel.ts
new file mode 100644
index 0000000000..f722b69eda
--- /dev/null
+++ b/src/client/components/SubscriptionPanel.ts
@@ -0,0 +1,161 @@
+import { html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { UserSubscription } from "../../core/ApiSchemas";
+import { Subscription } from "../../core/CosmeticSchemas";
+import {
+ cancelSubscription,
+ invalidateUserMe,
+ openSubscriptionPortal,
+} from "../Api";
+import { translateCosmetic } from "../Cosmetics";
+import { translateText } from "../Utils";
+import "./baseComponents/Button";
+import "./CapIcon";
+import "./PlutoniumIcon";
+
+@customElement("subscription-panel")
+export class SubscriptionPanel extends LitElement {
+ @property({ type: Object })
+ sub!: UserSubscription;
+
+ @property({ type: Object })
+ cosmetic: Subscription | null = null;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ private handleManage = async (): Promise => {
+ const url = await openSubscriptionPortal();
+ if (url === false) {
+ alert(translateText("account_modal.subscription_portal_failed"));
+ return;
+ }
+ window.open(url, "_blank", "noopener,noreferrer");
+ };
+
+ private handleCancel = async (): Promise => {
+ 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();
+ window.location.reload();
+ };
+
+ private renderStatus(): TemplateResult {
+ const periodEnd = this.sub.currentPeriodEnd
+ ? this.sub.currentPeriodEnd.toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })
+ : null;
+
+ if (this.sub.cancelAtPeriodEnd) {
+ return html`
+ ${periodEnd
+ ? translateText("account_modal.sub_status_canceling_on", {
+ date: periodEnd,
+ })
+ : translateText("account_modal.sub_status_canceling")}
+
`;
+ }
+
+ const isActive =
+ this.sub.status === "active" || this.sub.status === "trialing";
+ const colorClass = isActive ? "text-green-400" : "text-white/60";
+ const translatedStatus = translateText(
+ `account_modal.sub_status_${this.sub.status}`,
+ );
+ const statusLabel = translatedStatus.startsWith("account_modal.sub_status_")
+ ? this.sub.status
+ : translatedStatus;
+
+ return html`
+ ${statusLabel}
+ ${periodEnd
+ ? html`${translateText("account_modal.sub_renews_on", {
+ date: periodEnd,
+ })}`
+ : ""}
+
`;
+ }
+
+ render() {
+ const { sub, cosmetic } = this;
+ return html`
+
+
+ ⭐
+ ${translateText("account_modal.your_subscription")}
+
+
+
+
+ ${translateCosmetic("subscriptions", cosmetic?.name ?? sub.tier)}
+
+ ${this.renderStatus()}
+
+ ${cosmetic?.description
+ ? html`
+ ${cosmetic.description}
+
`
+ : ""}
+ ${cosmetic
+ ? html`
+
+
+
${cosmetic.dailyHardCurrency.toLocaleString()}
+
${translateText("cosmetics.per_day")}
+
+
+
+ ${cosmetic.dailySoftCurrency.toLocaleString()}
+ ${translateText("cosmetics.per_day")}
+
+
`
+ : ""}
+
+
+ ${sub.cancelAtPeriodEnd
+ ? ""
+ : html``}
+
+
+
+ `;
+ }
+}
diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts
index f7ae878f68..44a19e2f54 100644
--- a/src/core/ApiSchemas.ts
+++ b/src/core/ApiSchemas.ts
@@ -117,9 +117,20 @@ export const UserMeResponseSchema = z.object({
}),
)
.optional(),
+ subscription: z
+ .object({
+ tier: z.string(),
+ status: z.string(),
+ currentPeriodEnd: z.coerce.date().nullable(),
+ cancelAtPeriodEnd: z.boolean(),
+ })
+ .nullable(),
}),
});
export type UserMeResponse = z.infer;
+export type UserSubscription = NonNullable<
+ NonNullable
+>;
export const PlayerStatsLeafSchema = z.object({
wins: BigIntStringSchema,
diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts
index ae6196294f..3e570b89b5 100644
--- a/src/core/CosmeticSchemas.ts
+++ b/src/core/CosmeticSchemas.ts
@@ -7,6 +7,7 @@ export type Cosmetics = z.infer;
export type Pattern = z.infer;
export type Flag = z.infer;
export type Pack = z.infer;
+export type Subscription = z.infer;
export type PatternName = z.infer;
export type Product = z.infer;
export type ColorPalette = z.infer;
@@ -54,7 +55,7 @@ export const ColorPaletteSchema = z.object({
const CosmeticSchema = z.object({
name: CosmeticNameSchema,
- affiliateCode: z.string().nullable(),
+ affiliateCode: z.string().nullable().optional(),
product: ProductSchema.nullable(),
priceSoft: z.number().optional(),
priceHard: z.number().optional(),
@@ -85,12 +86,20 @@ export const PackSchema = CosmeticSchema.extend({
amount: z.number().int().positive(),
});
+export const SubscriptionSchema = CosmeticSchema.extend({
+ description: z.string(),
+ priceMonthly: z.number(),
+ dailySoftCurrency: z.number(),
+ dailyHardCurrency: z.number(),
+});
+
// Schema for resources/cosmetics/cosmetics.json
export const CosmeticsSchema = z.object({
colorPalettes: z.record(z.string(), ColorPaletteSchema).optional(),
patterns: z.record(z.string(), PatternSchema),
flags: z.record(z.string(), FlagSchema),
currencyPacks: z.record(z.string(), PackSchema).optional(),
+ subscriptions: z.record(z.string(), SubscriptionSchema).optional(),
});
export const DefaultPattern = {
diff --git a/tests/ResolveCosmetics.test.ts b/tests/ResolveCosmetics.test.ts
index 24d988afbc..3b8fc9e2a9 100644
--- a/tests/ResolveCosmetics.test.ts
+++ b/tests/ResolveCosmetics.test.ts
@@ -21,6 +21,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse {
adfree: false,
flares,
achievements: { singleplayerMap: [] },
+ subscription: null,
},
} as UserMeResponse;
}