Files
OpenFrontIO/src/client/Cosmetics.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

537 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { assetUrl } from "src/core/AssetUrls";
import { UserMeResponse } from "../core/ApiSchemas";
import {
ColorPalette,
Cosmetics,
CosmeticsSchema,
Flag,
Pack,
Pattern,
Product,
Subscription,
} from "../core/CosmeticSchemas";
import {
PlayerCosmeticRefs,
PlayerCosmetics,
PlayerPattern,
} from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
import {
changeSubscriptionTier,
createCheckoutSession,
getApiBase,
getUserMe,
invalidateUserMe,
purchaseWithCurrency,
} from "./Api";
import { translateText } from "./Utils";
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
let __cosmetics: Promise<Cosmetics | null> | null = null;
let __cosmeticsHash: string | null = null;
export type PaymentMethod = "dollar" | "hard" | "soft";
export async function purchaseCosmetic(
resolved: ResolvedCosmetic,
method: PaymentMethod,
): Promise<void> {
if (!resolved.cosmetic) return;
const c = resolved.cosmetic;
const colorPaletteName = resolved.colorPalette?.name;
if (resolved.type === "subscription") {
const sub = c as Subscription;
const userMe = await getUserMe();
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;
}
}
if (method === "dollar") {
if (!c.product) {
alert(translateText("store.checkout_failed"));
return;
}
const url = await createCheckoutSession(
c.product.priceId,
colorPaletteName,
);
if (url === false) {
alert(translateText("store.checkout_failed"));
return;
}
window.location.href = url;
return;
}
// 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"));
return;
}
const balance =
method === "hard"
? (userMe.player.currency?.hard ?? 0)
: (userMe.player.currency?.soft ?? 0);
if (balance < price) {
alert(translateText("store.not_enough_currency"));
if (method === "hard") {
// Send the user to the packs tab so they can top up plutonium.
window.location.hash = "#modal=store&tab=packs";
}
return;
}
const cosmeticType = resolved.type as "pattern" | "skin" | "flag";
const success = await purchaseWithCurrency(
cosmeticType,
c.name,
method,
colorPaletteName,
);
if (!success) {
alert(translateText("store.purchase_failed"));
return;
}
alert(translateText("store.purchase_success", { name: c.name }));
invalidateUserMe();
window.location.reload();
}
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return hash.toString(36);
}
export async function fetchCosmetics(): Promise<Cosmetics | null> {
if (__cosmetics !== null) {
return __cosmetics;
}
__cosmetics = (async () => {
try {
const response = await fetch(`${getApiBase()}/cosmetics.json`);
if (!response.ok) {
console.error(`HTTP error! status: ${response.status}`);
return null;
}
const result = CosmeticsSchema.safeParse(await response.json());
if (!result.success) {
console.error(`Invalid cosmetics: ${result.error.message}`);
return null;
}
const patternKeys = Object.keys(result.data.patterns).sort();
const hashInput = patternKeys
.map((k) => k + (result.data.patterns[k].product ? "sale" : ""))
.join(",");
__cosmeticsHash = simpleHash(hashInput);
return result.data;
} catch (error) {
console.error("Error getting cosmetics:", error);
return null;
}
})();
return __cosmetics;
}
export async function resolveFlagUrl(
flagRef: string,
): Promise<string | undefined> {
if (flagRef.startsWith("flag:")) {
const key = flagRef.slice("flag:".length);
const cosmetics = await fetchCosmetics();
const flagData = cosmetics?.flags?.[key];
return flagData?.url;
}
if (flagRef.startsWith("country:")) {
const code = flagRef.slice("country:".length);
return assetUrl(`flags/${code}.svg`);
}
return undefined;
}
export async function getCosmeticsHash(): Promise<string | null> {
await fetchCosmetics();
return __cosmeticsHash;
}
export function cosmeticRelationship(
opts: {
wildcardFlare: string;
requiredFlare: string;
product: Product | null;
priceSoft?: number;
priceHard?: number;
affiliateCode: string | null;
itemAffiliateCode: string | null;
},
userMeResponse: UserMeResponse | false,
): "owned" | "purchasable" | "blocked" {
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (flares.includes(opts.wildcardFlare)) {
return "owned";
}
if (flares.includes(opts.requiredFlare)) {
return "owned";
}
if (opts.affiliateCode !== opts.itemAffiliateCode) {
return "blocked";
}
// Purchasable if any purchase method is available
if (opts.priceSoft !== undefined || opts.priceHard !== undefined) {
return "purchasable";
}
if (opts.product === null) {
return "blocked";
}
return "purchasable";
}
export function patternRelationship(
pattern: Pattern,
colorPalette: { name: string; isArchived?: boolean } | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
if (colorPalette === null) {
// For backwards compatibility only show non-colored patterns if they are owned.
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (
flares.includes("pattern:*") ||
flares.includes(`pattern:${pattern.name}`)
) {
return "owned";
}
return "blocked";
}
if (colorPalette.isArchived) {
// Check ownership first — if owned, show it even if archived.
const flares =
userMeResponse === false ? [] : (userMeResponse.player.flares ?? []);
if (
flares.includes("pattern:*") ||
flares.includes(`pattern:${pattern.name}:${colorPalette.name}`)
) {
return "owned";
}
return "blocked";
}
return cosmeticRelationship(
{
wildcardFlare: "pattern:*",
requiredFlare: `pattern:${pattern.name}:${colorPalette.name}`,
product: pattern.product,
priceSoft: pattern.priceSoft,
priceHard: pattern.priceHard,
affiliateCode,
itemAffiliateCode: pattern.affiliateCode ?? null,
},
userMeResponse,
);
}
export function flagRelationship(
flag: Flag,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): "owned" | "purchasable" | "blocked" {
return cosmeticRelationship(
{
wildcardFlare: "flag:*",
requiredFlare: `flag:${flag.name}`,
product: flag.product,
priceSoft: flag.priceSoft,
priceHard: flag.priceHard,
affiliateCode,
itemAffiliateCode: flag.affiliateCode ?? null,
},
userMeResponse,
);
}
export type ResolvedCosmetic = {
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" */
key: string;
};
/**
* Resolves all cosmetics into a flat display-ready list with relationship
* status and resolved color palettes. Callers can filter by relationship.
*/
export function resolveCosmetics(
cosmetics: Cosmetics | null,
userMeResponse: UserMeResponse | false,
affiliateCode: string | null,
): ResolvedCosmetic[] {
if (!cosmetics) return [];
const result: ResolvedCosmetic[] = [];
// Default pattern (always owned)
result.push({
type: "pattern",
cosmetic: null,
colorPalette: null,
relationship: "owned",
key: "pattern:default",
});
// Patterns × color palettes
for (const [patternKey, pattern] of Object.entries(cosmetics.patterns)) {
const colorPalettes = [...(pattern.colorPalettes ?? []), null];
for (const cp of colorPalettes) {
const rel = patternRelationship(
pattern,
cp,
userMeResponse,
affiliateCode,
);
const resolvedPalette = cp
? (cosmetics.colorPalettes?.[cp.name] ?? null)
: null;
const key = cp
? `pattern:${patternKey}:${cp.name}`
: `pattern:${patternKey}`;
result.push({
type: "pattern",
cosmetic: pattern,
colorPalette: resolvedPalette,
relationship: rel,
key,
});
}
}
// Flags
for (const [flagKey, flag] of Object.entries(cosmetics.flags)) {
const rel = flagRelationship(flag, userMeResponse, affiliateCode);
result.push({
type: "flag",
cosmetic: flag,
colorPalette: null,
relationship: rel,
key: `flag:${flagKey}`,
});
}
// Packs
for (const [packKey, pack] of Object.entries(cosmetics.currencyPacks ?? {})) {
const rel = pack.product ? "purchasable" : "blocked";
result.push({
type: "pack",
cosmetic: pack,
colorPalette: null,
relationship: rel,
key: `pack:${packKey}`,
});
}
// 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 isCurrent = subKey === currentSubTier || flares.includes(key);
const rel: ResolvedCosmetic["relationship"] = isCurrent
? "owned"
: sub.product
? "purchasable"
: "blocked";
result.push({
type: "subscription",
cosmetic: sub,
colorPalette: null,
relationship: rel,
key,
});
}
return result;
}
export function resolvedToPlayerPattern(
resolved: ResolvedCosmetic,
): PlayerPattern | null {
if (resolved.type !== "pattern") return null;
const c = resolved.cosmetic;
if (c === null) return null;
return {
name: c.name,
patternData: (c as Pattern).pattern,
colorPalette: resolved.colorPalette ?? undefined,
};
}
export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
const userSettings = new UserSettings();
const cosmetics = await fetchCosmetics();
let pattern: PlayerPattern | null =
userSettings.getSelectedPatternName(cosmetics);
if (pattern) {
const userMe = await getUserMe();
if (userMe) {
const flareName =
pattern.colorPalette?.name === undefined
? `pattern:${pattern.name}`
: `pattern:${pattern.name}:${pattern.colorPalette.name}`;
const flares = userMe.player.flares ?? [];
const hasWildcard = flares.includes("pattern:*");
if (!hasWildcard && !flares.includes(flareName)) {
pattern = null;
}
}
if (pattern === null) {
userSettings.setSelectedPatternName(undefined);
}
}
let flag = userSettings.getFlag();
if (flag?.startsWith("flag:")) {
const key = flag.slice("flag:".length);
const flagData = cosmetics?.flags?.[key];
if (!flagData) {
// Only clear if cosmetics loaded successfully but the key is missing
if (cosmetics) {
flag = null;
}
} else {
const userMe = await getUserMe();
if (!userMe) {
flag = null;
} else {
const flares = userMe.player.flares ?? [];
const hasWildcard = flares.includes("flag:*");
if (!hasWildcard && !flares.includes(`flag:${flagData.name}`)) {
flag = null;
}
}
}
}
if (flag === null) {
userSettings.clearFlag();
}
return {
flag: flag ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
};
}
export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
const refs = await getPlayerCosmeticsRefs();
const cosmetics = await fetchCosmetics();
const result: PlayerCosmetics = {};
if (refs.flag) {
result.flag = await resolveFlagUrl(refs.flag);
}
if (refs.patternName && cosmetics) {
const pattern = cosmetics.patterns[refs.patternName];
if (pattern) {
result.pattern = {
name: refs.patternName,
patternData: pattern.pattern,
colorPalette: refs.patternColorPaletteName
? cosmetics.colorPalettes?.[refs.patternColorPaletteName]
: undefined,
};
}
} else {
const devPattern = new UserSettings().getDevOnlyPattern();
if (devPattern) {
result.pattern = {
name: devPattern.name,
patternData: devPattern.patternData,
colorPalette: devPattern.colorPalette,
};
}
}
return result;
}
export function translateCosmetic(prefix: string, name: string): string {
const translation = translateText(`${prefix}.${name}`);
if (translation.startsWith(prefix)) {
return name
.split("_")
.filter((word) => word.length > 0)
.map((word) => word[0].toUpperCase() + word.substring(1))
.join(" ");
}
return translation;
}