Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down Expand Up @@ -1117,6 +1120,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.",
Expand Down
34 changes: 34 additions & 0 deletions src/client/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`, {
Expand Down
45 changes: 41 additions & 4 deletions src/client/Cosmetics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "../core/Schemas";
import { UserSettings } from "../core/game/UserSettings";
import {
changeSubscriptionTier,
createCheckoutSession,
getApiBase,
getUserMe,
Expand All @@ -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";
Comment on lines +58 to +67
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid defaulting unknown tier comparisons to “upgrade” messaging.

On Line 60–67, when the current tier is missing from cosmetics.subscriptions, isUpgrade defaults to true, so downgrade paths can show upgrade billing text. Use a neutral fallback confirm message when direction is unknown.

💡 Suggested fix
-      const isUpgrade =
-        currentCosmetic !== undefined && currentCosmetic !== null
-          ? sub.priceMonthly > currentCosmetic.priceMonthly
-          : true;
+      const isUpgrade =
+        currentCosmetic !== undefined && currentCosmetic !== null
+          ? sub.priceMonthly > currentCosmetic.priceMonthly
+          : null;
       const targetName = translateCosmetic("subscriptions", sub.name);
-      const confirmKey = isUpgrade
-        ? "store.confirm_upgrade"
-        : "store.confirm_downgrade";
+      const confirmKey =
+        isUpgrade === null
+          ? "store.confirm_change_tier"
+          : isUpgrade
+            ? "store.confirm_upgrade"
+            : "store.confirm_downgrade";

Add a neutral translation key:

  "store": {
+   "confirm_change_tier": "Switch to {tier}?",
    "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.",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const currentCosmetic =
(await fetchCosmetics())?.subscriptions?.[currentSub.tier] ?? null;
const isUpgrade =
currentCosmetic !== undefined && currentCosmetic !== null
? sub.priceMonthly > currentCosmetic.priceMonthly
: true;
const targetName = translateCosmetic("subscriptions", sub.name);
const confirmKey = isUpgrade
? "store.confirm_upgrade"
: "store.confirm_downgrade";
const currentCosmetic =
(await fetchCosmetics())?.subscriptions?.[currentSub.tier] ?? null;
const isUpgrade =
currentCosmetic !== undefined && currentCosmetic !== null
? sub.priceMonthly > currentCosmetic.priceMonthly
: null;
const targetName = translateCosmetic("subscriptions", sub.name);
const confirmKey =
isUpgrade === null
? "store.confirm_change_tier"
: isUpgrade
? "store.confirm_upgrade"
: "store.confirm_downgrade";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/Cosmetics.ts` around lines 58 - 67, The code currently treats a
missing currentCosmetic as an upgrade (isUpgrade true) which can display
incorrect billing text; change the logic so isUpgrade is only computed when
currentCosmetic exists (use currentCosmetic !== null && currentCosmetic !==
undefined), otherwise set a neutral state (e.g., isUpgrade = null or a separate
flag like directionUnknown) and choose a neutral confirmKey (add a new
translation key like "store.confirm_change" or similar) instead of
"store.confirm_upgrade" or "store.confirm_downgrade"; update the confirmKey
assignment to select upgrade/downgrade only when isUpgrade is boolean and fall
back to the neutral key when currentCosmetic is missing, referencing
fetchCosmetics(), currentCosmetic, isUpgrade, and confirmKey in your changes.

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;
}
}
Expand Down Expand Up @@ -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
Comment on lines +395 to +402
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use flare ownership only as a fallback.

subKey === currentSubTier || flares.includes(key) can mark two plans as "owned" if the flare list lags behind a tier change. When player.subscription.tier exists, it should be the only source of truth.

Proposed change
-    const isCurrent = subKey === currentSubTier || flares.includes(key);
+    const isCurrent =
+      currentSubTier !== null ? subKey === currentSubTier : flares.includes(key);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
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 =
currentSubTier !== null ? subKey === currentSubTier : flares.includes(key);
const rel: ResolvedCosmetic["relationship"] = isCurrent
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/Cosmetics.ts` around lines 395 - 402, The ownership logic can mark
both the actual subscription and a flare as owned when flares lag; update the
isCurrent calculation in the loop over cosmetics.subscriptions so that
subscription ownership uses player.subscription.tier as the sole source of truth
(currentSubTier) when it is non-null, and only fall back to checking
flares.includes(key) if currentSubTier is null/undefined; adjust the evaluation
that sets isCurrent (referencing currentSubTier, flares, subKey, and key) so
flares are considered only when userMeResponse.player.subscription?.tier is not
present.

? "owned"
: sub.product
? "purchasable"
Expand Down
9 changes: 8 additions & 1 deletion src/client/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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"
Expand All @@ -195,6 +201,7 @@ export class StoreModal extends BaseModal {
<cosmetic-button
.resolved=${r}
.onPurchase=${purchaseCosmetic}
.userHasSubscription=${userHasSubscription}
></cosmetic-button>
`,
)}
Expand Down
24 changes: 24 additions & 0 deletions src/client/components/CosmeticButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 " : "";

Expand All @@ -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}
Expand Down Expand Up @@ -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>
`;
}
Expand Down
11 changes: 11 additions & 0 deletions src/client/components/CosmeticContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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}
Expand Down
16 changes: 14 additions & 2 deletions src/client/components/PurchaseButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 = "";
Comment thread
evanpelle marked this conversation as resolved.

@property({ type: Function })
onPurchaseDollar?: () => void;

Expand Down Expand Up @@ -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>
`;
}
Expand Down
Loading
Loading