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:
Evan
2026-05-14 13:47:16 -07:00
committed by GitHub
parent 5e7f1541b9
commit e0f73598d6
11 changed files with 431 additions and 20 deletions
+24 -1
View File
@@ -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",
+21
View File
@@ -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) {
+59
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+46 -8
View File
@@ -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>`
+161
View File
@@ -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>
`;
}
}
+11
View File
@@ -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,
+10 -1
View File
@@ -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 = {
+1
View File
@@ -21,6 +21,7 @@ function makeUserMe(flares: string[] = []): UserMeResponse {
adfree: false,
flares,
achievements: { singleplayerMap: [] },
subscription: null,
},
} as UserMeResponse;
}