Subscription upgrade/downgrade + tier management (#3927)

## Summary

- Tier upgrade/downgrade in the Store. The Subscriptions tab now shows
all tiers including the user's current one. Other tiers swap "Subscribe"
→ "Switch" when the user already has a sub, and clicking them calls the
new `POST /subscriptions/@me/change-tier` endpoint with a
direction-aware confirm (upgrade charges prorated diff now, downgrade
gives account credit).
- Owned-tier card renders a **Current Plan** badge in place of the
purchase button. Resolution logic in `resolveCosmetics` now reads
`userMeResponse.player.subscription.tier` (with flare fallback) and
marks that tier as `owned`.
- AccountModal's `<subscription-panel>` reworked into a proper
two-column layout:
- **Left**: tier name, `$X.XX/mo` price, description, daily Pu/Caps
amounts.
- **Right**: status badge (Active / Renews date / Cancels date),
`[Manage] [Change Tier]` button row, `[Cancel]` centered underneath.
When `cancelAtPeriodEnd === true`, the row collapses to a single
`[Reactivate]` button (opens the Stripe portal).
- New `<o-button size="xs">` variant (`py-2 px-3 text-xs`) for the
compact panel buttons.
- Store dollar-purchase price label now supports an optional suffix
(`/mo` for subs only) via a `priceSuffix` prop plumbed through
`CosmeticContainer` → `PurchaseButton`.
- `Api.ts` gains `changeSubscriptionTier(tierName)` with the same
401-handling pattern as the existing subscription helpers.


<img width="1114" height="728" alt="Screenshot 2026-05-14 at 7 09 20 PM"
src="https://github.com/user-attachments/assets/688f83d5-4010-4580-9214-6885af8ec98e"
/>

<img width="1038" height="276" alt="Screenshot 2026-05-14 at 7 09 33 PM"
src="https://github.com/user-attachments/assets/458197f5-a0d4-4c32-bc55-31e5679629b5"
/>

<img width="887" height="286" alt="Screenshot 2026-05-14 at 7 09 55 PM"
src="https://github.com/user-attachments/assets/8149ed82-89cc-4bbe-83de-3614f886b331"
/>

## Discord

evan
This commit is contained in:
Evan
2026-05-15 12:01:31 -07:00
committed by GitHub
parent 7dc5d472a7
commit ca565eaa1a
9 changed files with 228 additions and 53 deletions
+24
View File
@@ -13,6 +13,7 @@ import "./CosmeticContainer";
import "./CosmeticInfo";
import { renderPatternPreview } from "./PatternPreview";
import "./PlutoniumIcon";
import { DEFAULT_DOLLAR_LABEL_KEY } from "./PurchaseButton";
@customElement("cosmetic-button")
export class CosmeticButton extends LitElement {
@@ -28,6 +29,10 @@ export class CosmeticButton extends LitElement {
@property({ type: Function })
onPurchase?: (resolved: ResolvedCosmetic, method: PaymentMethod) => void;
/** True if the user already has a subscription (any tier). */
@property({ type: Boolean })
userHasSubscription: boolean = false;
createRenderRoot() {
return this;
}
@@ -151,6 +156,16 @@ export class CosmeticButton extends LitElement {
const isPurchasable = this.resolved.relationship === "purchasable";
const type = this.resolved.type;
const isPattern = type === "pattern";
const isOwnedSubscription =
type === "subscription" && this.resolved.relationship === "owned";
const dollarLabelKey =
type === "subscription"
? this.userHasSubscription
? "store.switch_button"
: "store.subscribe_button"
: DEFAULT_DOLLAR_LABEL_KEY;
const priceSuffix =
type === "subscription" ? translateText("store.price_per_month") : "";
const sizeClass = type === "flag" ? "gap-1 p-1.5 w-36" : "gap-2 p-3 w-48";
const crazygamesClass = isPattern ? "no-crazygames " : "";
@@ -162,6 +177,8 @@ export class CosmeticButton extends LitElement {
.product=${isPurchasable && c?.product ? c.product : null}
.priceHard=${isPurchasable ? (priceHard ?? null) : null}
.priceSoft=${isPurchasable ? (priceSoft ?? null) : null}
.dollarLabelKey=${dollarLabelKey}
.priceSuffix=${priceSuffix}
.onPurchaseDollar=${isPurchasable && c?.product
? () => this.onPurchase?.(this.resolved, "dollar")
: undefined}
@@ -194,6 +211,13 @@ export class CosmeticButton extends LitElement {
${this.renderPreview()}
</div>
</button>
${isOwnedSubscription
? html`<div
class="w-full mt-2 px-4 py-2 bg-amber-500/20 text-amber-300 border border-amber-500/40 rounded-lg text-xs font-bold uppercase tracking-wider text-center"
>
${translateText("store.current_plan")}
</div>`
: nothing}
</cosmetic-container>
`;
}
@@ -2,6 +2,7 @@ import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import "./PurchaseButton";
import { DEFAULT_DOLLAR_LABEL_KEY } from "./PurchaseButton";
type Rarity = "common" | "uncommon" | "rare" | "epic" | "legendary" | string;
@@ -158,6 +159,14 @@ export class CosmeticContainer extends LitElement {
@property({ type: Number })
priceSoft: number | null = null;
/** Override the dollar-button label key. */
@property({ type: String })
dollarLabelKey: string = DEFAULT_DOLLAR_LABEL_KEY;
/** Optional suffix appended to the displayed price, e.g. "/mo". */
@property({ type: String })
priceSuffix: string = "";
@property({ type: Function })
onPurchaseDollar?: () => void;
@@ -448,6 +457,8 @@ export class CosmeticContainer extends LitElement {
.priceHard=${this.priceHard}
.priceSoft=${this.priceSoft}
.rarity=${this.rarity}
.dollarLabelKey=${this.dollarLabelKey}
.priceSuffix=${this.priceSuffix}
.onPurchaseDollar=${this.onPurchaseDollar}
.onPurchaseHard=${this.onPurchaseHard}
.onPurchaseSoft=${this.onPurchaseSoft}
+14 -2
View File
@@ -5,6 +5,8 @@ import { translateText } from "../Utils";
import "./CapIcon";
import "./PlutoniumIcon";
export const DEFAULT_DOLLAR_LABEL_KEY = "territory_patterns.purchase";
const PURCHASE_STYLE_ID = "purchase-button-styles";
if (!document.getElementById(PURCHASE_STYLE_ID)) {
const style = document.createElement("style");
@@ -190,6 +192,14 @@ export class PurchaseButton extends LitElement {
@property({ type: String })
rarity: string = "common";
/** Override the dollar-button label key. */
@property({ type: String })
dollarLabelKey: string = DEFAULT_DOLLAR_LABEL_KEY;
/** Optional suffix appended to the displayed price, e.g. "/mo". Not translated here. */
@property({ type: String })
priceSuffix: string = "";
@property({ type: Function })
onPurchaseDollar?: () => void;
@@ -226,8 +236,10 @@ export class PurchaseButton extends LitElement {
@click=${(e: Event) => this.handleClick(e, this.onPurchaseDollar)}
>
<span class="purchase-sparkle-streak"></span>
${translateText("territory_patterns.purchase")}
<span class="ml-1 text-white/50">(${this.product!.price})</span>
${translateText(this.dollarLabelKey)}
<span class="ml-1 text-white/50"
>(${this.product!.price}${this.priceSuffix})</span
>
</button>
`;
}
+80 -45
View File
@@ -34,6 +34,10 @@ export class SubscriptionPanel extends LitElement {
window.open(url, "_blank", "noopener,noreferrer");
};
private handleChangeTier = (): void => {
window.location.hash = "modal=store&tab=subscriptions";
};
private handleCancel = async (): Promise<void> => {
const confirmed = window.confirm(
translateText("account_modal.cancel_subscription_confirm"),
@@ -103,56 +107,87 @@ export class SubscriptionPanel extends LitElement {
${translateText("account_modal.your_subscription")}
</h3>
<div
class="flex flex-col gap-3 p-4 rounded-lg bg-white/5 border border-white/10"
class="flex flex-wrap items-start justify-between gap-4 p-4 rounded-lg bg-white/5 border border-white/10"
>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<div class="text-base font-bold text-white">
${translateCosmetic("subscriptions", cosmetic?.name ?? sub.tier)}
<div class="flex flex-col gap-3 flex-1 min-w-0">
<div class="flex items-baseline gap-2 flex-wrap">
<div class="text-base font-bold text-white">
${translateCosmetic(
"subscriptions",
cosmetic?.name ?? sub.tier,
)}
</div>
${cosmetic?.product?.price
? html`<div class="text-xs text-white/60">
${translateText("account_modal.sub_price_monthly", {
price: cosmetic.product.price,
})}
</div>`
: ""}
</div>
${this.renderStatus()}
${cosmetic?.description
? html`<div class="text-sm text-white/70">
${cosmetic.description}
</div>`
: ""}
${cosmetic
? html`<div class="flex flex-wrap gap-4 mt-1">
<div class="flex items-center gap-1.5">
<plutonium-icon .size=${20}></plutonium-icon>
<span class="text-sm font-bold text-green-400"
>${cosmetic.dailyHardCurrency.toLocaleString()}</span
>
<span class="text-[10px] text-white/50 uppercase"
>${translateText("cosmetics.per_day")}</span
>
</div>
<div class="flex items-center gap-1.5">
<cap-icon .size=${20}></cap-icon>
<span class="text-sm font-bold text-amber-700"
>${cosmetic.dailySoftCurrency.toLocaleString()}</span
>
<span class="text-[10px] text-white/50 uppercase"
>${translateText("cosmetics.per_day")}</span
>
</div>
</div>`
: ""}
</div>
${cosmetic?.description
? html`<div class="text-sm text-white/70">
${cosmetic.description}
</div>`
: ""}
${cosmetic
? html`<div class="flex flex-wrap gap-4 mt-1">
<div class="flex items-center gap-1.5">
<plutonium-icon .size=${20}></plutonium-icon>
<span class="text-sm font-bold text-green-400"
>${cosmetic.dailyHardCurrency.toLocaleString()}</span
>
<span class="text-[10px] text-white/50 uppercase"
>${translateText("cosmetics.per_day")}</span
>
</div>
<div class="flex items-center gap-1.5">
<cap-icon .size=${20}></cap-icon>
<span class="text-sm font-bold text-amber-700"
>${cosmetic.dailySoftCurrency.toLocaleString()}</span
>
<span class="text-[10px] text-white/50 uppercase"
>${translateText("cosmetics.per_day")}</span
>
</div>
</div>`
: ""}
<div class="flex flex-wrap gap-2 mt-2">
<o-button
variant="primary"
size="sm"
translationKey="account_modal.manage_subscription"
@click=${this.handleManage}
></o-button>
<div class="flex flex-col items-end gap-2">
${this.renderStatus()}
<div class="flex flex-wrap justify-end gap-2">
${sub.cancelAtPeriodEnd
? html`<o-button
variant="secondary"
size="xs"
translationKey="account_modal.reactivate_subscription"
@click=${this.handleManage}
></o-button>`
: html`
<o-button
variant="secondary"
size="xs"
translationKey="account_modal.manage_subscription"
@click=${this.handleManage}
></o-button>
<o-button
variant="secondary"
size="xs"
translationKey="account_modal.change_tier"
@click=${this.handleChangeTier}
></o-button>
`}
</div>
${sub.cancelAtPeriodEnd
? ""
: html`<o-button
variant="danger"
size="sm"
translationKey="account_modal.cancel_subscription"
@click=${this.handleCancel}
></o-button>`}
: html`<div class="flex justify-center w-full">
<o-button
variant="danger"
size="xs"
translationKey="account_modal.cancel_subscription"
@click=${this.handleCancel}
></o-button>
</div>`}
</div>
</div>
</div>
@@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators.js";
import { translateText } from "../../Utils";
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
type ButtonSize = "sm" | "md" | "lg";
type ButtonSize = "xs" | "sm" | "md" | "lg";
type ButtonWidth = "auto" | "block" | "blockDesktop" | "fill";
type IconPosition = "left" | "right" | "only";
@@ -45,6 +45,8 @@ export class OButton extends LitElement {
private sizeClasses(): string {
if (this.iconPosition === "only") {
switch (this.size) {
case "xs":
return "w-6 h-6 text-xs";
case "sm":
return "w-8 h-8 text-sm";
case "md":
@@ -54,6 +56,8 @@ export class OButton extends LitElement {
}
}
switch (this.size) {
case "xs":
return "py-1 px-2 text-xs";
case "sm":
return "py-1.5 px-3 text-sm";
case "md":