mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:20:46 +00:00
Add subscriptions: store tab, account panel, manage/cancel (#3918)
## Summary
- Add a **Subscriptions** tab to the Store. Each tier renders as a
`<cosmetic-button>` with description, daily Pu/Caps amounts, and a
Stripe checkout button driven by the existing `createCheckoutSession`
flow.
- Show the player's active subscription in the **Account modal** via a
new `<subscription-panel>` Lit component (status badge, period-end /
cancel-at-period-end, daily currency breakdown).
- **Manage** button opens the Stripe billing portal in a new tab (`POST
/subscriptions/@me/portal`).
- **Cancel** button (hidden once `cancelAtPeriodEnd === true`) calls
`POST /subscriptions/@me/cancel` after a `confirm()` prompt, then
invalidates the userMe cache and refetches.
- Block re-purchase: clicking Subscribe when the user already has a
`subscription:*` flare alerts "Already subscribed" before opening
checkout (upgrade/downgrade flows are out of scope for now).
- Schema additions:
- `CosmeticsSchema.subscriptions: Record<string, SubscriptionSchema>`
(optional) in `src/core/CosmeticSchemas.ts`.
- `UserMeResponse.player.subscription: { tier, status, currentPeriodEnd,
cancelAtPeriodEnd } | null` in `src/core/ApiSchemas.ts`.
- Translations: new `store.*` and `account_modal.sub_*` keys in
`resources/lang/en.json` (English only — Crowdin handles the rest).
-
<img width="942" height="313" alt="Screenshot 2026-05-14 at 1 13 05 PM"
src="https://github.com/user-attachments/assets/3d28df13-9e03-49f0-bee8-a25f9ad0c420"
/>
<img width="545" height="439" alt="Screenshot 2026-05-14 at 1 13 32 PM"
src="https://github.com/user-attachments/assets/b413b275-d6f2-40dc-9230-d68cd11fb07a"
/>
## Discord
evanpelle
This commit is contained in:
+24
-1
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.renderSubscriptionPanel()}
|
||||
|
||||
<!-- Middle Row: Stats Section -->
|
||||
${this.hasAnyStats()
|
||||
? html`<div
|
||||
@@ -192,6 +198,16 @@ export class AccountModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSubscriptionPanel(): TemplateResult | "" {
|
||||
const sub = this.userMeResponse?.player?.subscription;
|
||||
if (!sub) return "";
|
||||
const cosmetic = this.cosmetics?.subscriptions?.[sub.tier] ?? null;
|
||||
return html`<subscription-panel
|
||||
.sub=${sub}
|
||||
.cosmetic=${cosmetic}
|
||||
></subscription-panel>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -169,6 +169,65 @@ export async function createCheckoutSession(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelSubscription(): Promise<boolean> {
|
||||
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<string | false> {
|
||||
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();
|
||||
|
||||
|
||||
+45
-6
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -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.");
|
||||
|
||||
+44
-3
@@ -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`<div
|
||||
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
||||
>
|
||||
${translateText("store.no_subscriptions")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
||||
>
|
||||
${items.map(
|
||||
(r) => html`
|
||||
<cosmetic-button
|
||||
.resolved=${r}
|
||||
.onPurchase=${purchaseCosmetic}
|
||||
></cosmetic-button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<o-modal
|
||||
@@ -180,7 +217,11 @@ export class StoreModal extends BaseModal {
|
||||
.tabs=${tabs}
|
||||
.activeTab=${this.activeTab}
|
||||
.onTabChange=${(key: string) =>
|
||||
(this.activeTab = key as "patterns" | "flags" | "packs")}
|
||||
(this.activeTab = key as
|
||||
| "patterns"
|
||||
| "flags"
|
||||
| "packs"
|
||||
| "subscriptions")}
|
||||
>
|
||||
<div slot="header">${this.renderHeader()}</div>
|
||||
${grid}
|
||||
|
||||
@@ -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 {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (this.resolved.type === "subscription") {
|
||||
const sub = this.resolved.cosmetic as Subscription;
|
||||
return html`<div
|
||||
class="flex flex-col items-center justify-between h-full w-full text-center gap-2 p-1"
|
||||
>
|
||||
<span class="text-xs text-white/70 line-clamp-3 px-1"
|
||||
>${sub.description}</span
|
||||
>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<plutonium-icon .size=${24}></plutonium-icon>
|
||||
<span class="text-sm font-bold text-green-400"
|
||||
>${sub.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=${24}></cap-icon>
|
||||
<span class="text-sm font-bold text-amber-700"
|
||||
>${sub.dailySoftCurrency.toLocaleString()}</span
|
||||
>
|
||||
<span class="text-[10px] text-white/50 uppercase"
|
||||
>${translateText("cosmetics.per_day")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const c = this.resolved.cosmetic as Flag;
|
||||
return html`<img
|
||||
src=${c.url}
|
||||
@@ -110,6 +144,10 @@ export class CosmeticButton extends LitElement {
|
||||
|
||||
render() {
|
||||
const c = this.resolved.cosmetic;
|
||||
const priced = c as Pattern | Flag | Pack | null;
|
||||
const priceHard = priced?.priceHard;
|
||||
const priceSoft = priced?.priceSoft;
|
||||
const artist = priced?.artist;
|
||||
const isPurchasable = this.resolved.relationship === "purchasable";
|
||||
const type = this.resolved.type;
|
||||
const isPattern = type === "pattern";
|
||||
@@ -122,15 +160,15 @@ export class CosmeticButton extends LitElement {
|
||||
.rarity=${c?.rarity ?? "common"}
|
||||
.selected=${this.selected}
|
||||
.product=${isPurchasable && c?.product ? c.product : null}
|
||||
.priceHard=${isPurchasable ? (c?.priceHard ?? null) : null}
|
||||
.priceSoft=${isPurchasable ? (c?.priceSoft ?? null) : null}
|
||||
.priceHard=${isPurchasable ? (priceHard ?? null) : null}
|
||||
.priceSoft=${isPurchasable ? (priceSoft ?? null) : null}
|
||||
.onPurchaseDollar=${isPurchasable && c?.product
|
||||
? () => 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`<cosmetic-info
|
||||
.artist=${c.artist}
|
||||
.rarity=${c.rarity}
|
||||
.artist=${artist}
|
||||
.rarity=${c!.rarity}
|
||||
.colorPalette=${this.resolved.colorPalette?.name}
|
||||
.showAdFree=${isPurchasable}
|
||||
></cosmetic-info>`
|
||||
|
||||
@@ -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<void> => {
|
||||
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<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-col gap-3 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>
|
||||
${this.renderStatus()}
|
||||
</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>
|
||||
${sub.cancelAtPeriodEnd
|
||||
? ""
|
||||
: html`<o-button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
translationKey="account_modal.cancel_subscription"
|
||||
@click=${this.handleCancel}
|
||||
></o-button>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof UserMeResponseSchema>;
|
||||
export type UserSubscription = NonNullable<
|
||||
NonNullable<UserMeResponse["player"]["subscription"]>
|
||||
>;
|
||||
|
||||
export const PlayerStatsLeafSchema = z.object({
|
||||
wins: BigIntStringSchema,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type Cosmetics = z.infer<typeof CosmeticsSchema>;
|
||||
export type Pattern = z.infer<typeof PatternSchema>;
|
||||
export type Flag = z.infer<typeof FlagSchema>;
|
||||
export type Pack = z.infer<typeof PackSchema>;
|
||||
export type Subscription = z.infer<typeof SubscriptionSchema>;
|
||||
export type PatternName = z.infer<typeof CosmeticNameSchema>;
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
export type ColorPalette = z.infer<typeof ColorPaletteSchema>;
|
||||
@@ -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 = {
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse {
|
||||
adfree: false,
|
||||
flares,
|
||||
achievements: { singleplayerMap: [] },
|
||||
subscription: null,
|
||||
},
|
||||
} as UserMeResponse;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user