Files
OpenFrontIO/src/client/components/SubscriptionPanel.ts
T
Evan ca565eaa1a 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
2026-05-15 12:01:31 -07:00

197 lines
6.7 KiB
TypeScript

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<void> => {
const url = await openSubscriptionPortal();
if (url === false) {
alert(translateText("account_modal.subscription_portal_failed"));
return;
}
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"),
);
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`<div
class="text-xs font-bold text-amber-400 uppercase tracking-wider"
>
${periodEnd
? translateText("account_modal.sub_status_canceling_on", {
date: periodEnd,
})
: translateText("account_modal.sub_status_canceling")}
</div>`;
}
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`<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="font-bold ${colorClass} uppercase tracking-wider"
>${statusLabel}</span
>
${periodEnd
? html`<span class="text-white/50"
>${translateText("account_modal.sub_renews_on", {
date: periodEnd,
})}</span
>`
: ""}
</div>`;
}
render() {
const { sub, cosmetic } = this;
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="text-amber-400">⭐</span>
${translateText("account_modal.your_subscription")}
</h3>
<div
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-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>
${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>
<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`<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>
`;
}
}