mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
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:
@@ -347,6 +347,8 @@
|
||||
"connected_as": "Connected as",
|
||||
"your_subscription": "Your Subscription",
|
||||
"manage_subscription": "Manage",
|
||||
"change_tier": "Change Tier",
|
||||
"reactivate_subscription": "Reactivate",
|
||||
"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.",
|
||||
@@ -363,6 +365,7 @@
|
||||
"sub_status_canceling": "Canceling",
|
||||
"sub_status_canceling_on": "Cancels {date}",
|
||||
"sub_renews_on": "Renews {date}",
|
||||
"sub_price_monthly": "{price}/mo",
|
||||
"stats_overview": "Stats Overview",
|
||||
"link_discord": "Link Discord Account",
|
||||
"log_out": "Log Out",
|
||||
@@ -1118,6 +1121,14 @@
|
||||
"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.",
|
||||
"current_plan": "Current Plan",
|
||||
"subscribe_button": "Subscribe",
|
||||
"switch_button": "Switch",
|
||||
"price_per_month": "/mo",
|
||||
"confirm_upgrade": "Upgrade to {tier}? You'll be charged the prorated difference now.",
|
||||
"confirm_downgrade": "Downgrade to {tier}? You'll get account credit for the unused portion of your current plan.",
|
||||
"change_tier_success": "Switched to {tier}.",
|
||||
"change_tier_failed": "Couldn't update your subscription. Please try again.",
|
||||
"currency_pack_purchase_success": "Currency pack purchase successful!",
|
||||
"subscription_purchase_success": "Subscription activated!",
|
||||
"checkout_failed": "Failed to create checkout session.",
|
||||
|
||||
@@ -196,6 +196,40 @@ export async function cancelSubscription(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function changeSubscriptionTier(
|
||||
tierName: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${getApiBase()}/subscriptions/@me/change-tier`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: await getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ tierName }),
|
||||
},
|
||||
);
|
||||
if (response.status === 401) {
|
||||
await logOut();
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"changeSubscriptionTier: request failed",
|
||||
response.status,
|
||||
response.statusText,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("changeSubscriptionTier: request failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openSubscriptionPortal(): Promise<string | false> {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/subscriptions/@me/portal`, {
|
||||
|
||||
+41
-4
@@ -17,6 +17,7 @@ import {
|
||||
} from "../core/Schemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import {
|
||||
changeSubscriptionTier,
|
||||
createCheckoutSession,
|
||||
getApiBase,
|
||||
getUserMe,
|
||||
@@ -41,10 +42,41 @@ export async function purchaseCosmetic(
|
||||
const colorPaletteName = resolved.colorPalette?.name;
|
||||
|
||||
if (resolved.type === "subscription") {
|
||||
const sub = c as Subscription;
|
||||
const userMe = await getUserMe();
|
||||
const flares = userMe === false ? [] : (userMe.player.flares ?? []);
|
||||
if (flares.some((f) => f.startsWith("subscription:"))) {
|
||||
alert(translateText("store.already_subscribed"));
|
||||
const currentSub =
|
||||
userMe === false ? null : (userMe.player.subscription ?? null);
|
||||
|
||||
if (currentSub) {
|
||||
if (currentSub.tier === sub.name) {
|
||||
alert(translateText("store.already_subscribed"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Direction-aware confirm based on priceMonthly. We don't have the
|
||||
// server's sortOrder client-side — priceMonthly is a good proxy.
|
||||
const currentCosmetic =
|
||||
(await fetchCosmetics())?.subscriptions?.[currentSub.tier] ?? null;
|
||||
const isUpgrade =
|
||||
currentCosmetic !== null
|
||||
? sub.priceMonthly > currentCosmetic.priceMonthly
|
||||
: true;
|
||||
const targetName = translateCosmetic("subscriptions", sub.name);
|
||||
const confirmKey = isUpgrade
|
||||
? "store.confirm_upgrade"
|
||||
: "store.confirm_downgrade";
|
||||
const confirmed = window.confirm(
|
||||
translateText(confirmKey, { tier: targetName }),
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
const ok = await changeSubscriptionTier(sub.name);
|
||||
if (!ok) {
|
||||
alert(translateText("store.change_tier_failed"));
|
||||
return;
|
||||
}
|
||||
alert(translateText("store.change_tier_success", { tier: targetName }));
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -360,9 +392,14 @@ export function resolveCosmetics(
|
||||
// Subscriptions
|
||||
const flares =
|
||||
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
|
||||
const currentSubTier =
|
||||
userMeResponse === false
|
||||
? null
|
||||
: (userMeResponse.player.subscription?.tier ?? null);
|
||||
for (const [subKey, sub] of Object.entries(cosmetics.subscriptions ?? {})) {
|
||||
const key = `subscription:${subKey}`;
|
||||
const rel = flares.includes(key)
|
||||
const isCurrent = subKey === currentSubTier || flares.includes(key);
|
||||
const rel: ResolvedCosmetic["relationship"] = isCurrent
|
||||
? "owned"
|
||||
: sub.product
|
||||
? "purchasable"
|
||||
|
||||
+8
-1
@@ -175,7 +175,9 @@ export class StoreModal extends BaseModal {
|
||||
this.userMeResponse,
|
||||
this.affiliateCode,
|
||||
).filter(
|
||||
(r) => r.type === "subscription" && r.relationship === "purchasable",
|
||||
(r) =>
|
||||
r.type === "subscription" &&
|
||||
(r.relationship === "purchasable" || r.relationship === "owned"),
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
@@ -186,6 +188,10 @@ export class StoreModal extends BaseModal {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const userHasSubscription =
|
||||
this.userMeResponse !== false &&
|
||||
this.userMeResponse.player.subscription !== null;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
@@ -195,6 +201,7 @@ export class StoreModal extends BaseModal {
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.onPurchase=${purchaseCosmetic}
|
||||
.userHasSubscription=${userHasSubscription}
|
||||
></cosmetic-button>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user