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; }