diff --git a/resources/lang/en.json b/resources/lang/en.json index af45bd6bf..bb81cda9a 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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.", @@ -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", @@ -1118,6 +1121,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.", diff --git a/src/client/Api.ts b/src/client/Api.ts index a7aa2cfeb..8fc5f0143 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -196,6 +196,40 @@ export async function cancelSubscription(): Promise { } } +export async function changeSubscriptionTier( + tierName: string, +): Promise { + 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 { try { const response = await fetch(`${getApiBase()}/subscriptions/@me/portal`, { diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 5cc5f040d..a72fa9445 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -17,6 +17,7 @@ import { } from "../core/Schemas"; import { UserSettings } from "../core/game/UserSettings"; import { + changeSubscriptionTier, createCheckoutSession, getApiBase, getUserMe, @@ -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"; + 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; } } @@ -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 ? "owned" : sub.product ? "purchasable" diff --git a/src/client/Store.ts b/src/client/Store.ts index 9f3babf49..97e403d72 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -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) { @@ -186,6 +188,10 @@ export class StoreModal extends BaseModal { `; } + const userHasSubscription = + this.userMeResponse !== false && + this.userMeResponse.player.subscription !== null; + return html`
`, )} diff --git a/src/client/components/CosmeticButton.ts b/src/client/components/CosmeticButton.ts index 9f24b4102..0374fa2a7 100644 --- a/src/client/components/CosmeticButton.ts +++ b/src/client/components/CosmeticButton.ts @@ -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 { @@ -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; } @@ -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 " : ""; @@ -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} @@ -194,6 +211,13 @@ export class CosmeticButton extends LitElement { ${this.renderPreview()}
+ ${isOwnedSubscription + ? html`
+ ${translateText("store.current_plan")} +
` + : nothing} `; } diff --git a/src/client/components/CosmeticContainer.ts b/src/client/components/CosmeticContainer.ts index c95cdcd24..d63c90b2d 100644 --- a/src/client/components/CosmeticContainer.ts +++ b/src/client/components/CosmeticContainer.ts @@ -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; @@ -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; @@ -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} diff --git a/src/client/components/PurchaseButton.ts b/src/client/components/PurchaseButton.ts index f0f2b77e0..8210b8750 100644 --- a/src/client/components/PurchaseButton.ts +++ b/src/client/components/PurchaseButton.ts @@ -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"); @@ -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 = ""; + @property({ type: Function }) onPurchaseDollar?: () => void; @@ -226,8 +236,10 @@ export class PurchaseButton extends LitElement { @click=${(e: Event) => this.handleClick(e, this.onPurchaseDollar)} > - ${translateText("territory_patterns.purchase")} - (${this.product!.price}) + ${translateText(this.dollarLabelKey)} + (${this.product!.price}${this.priceSuffix}) `; } diff --git a/src/client/components/SubscriptionPanel.ts b/src/client/components/SubscriptionPanel.ts index f722b69ed..04a4e63e5 100644 --- a/src/client/components/SubscriptionPanel.ts +++ b/src/client/components/SubscriptionPanel.ts @@ -34,6 +34,10 @@ export class SubscriptionPanel extends LitElement { window.open(url, "_blank", "noopener,noreferrer"); }; + private handleChangeTier = (): void => { + window.location.hash = "modal=store&tab=subscriptions"; + }; + private handleCancel = async (): Promise => { const confirmed = window.confirm( translateText("account_modal.cancel_subscription_confirm"), @@ -103,56 +107,87 @@ export class SubscriptionPanel extends LitElement { ${translateText("account_modal.your_subscription")}
-
-
- ${translateCosmetic("subscriptions", cosmetic?.name ?? sub.tier)} +
+
+
+ ${translateCosmetic( + "subscriptions", + cosmetic?.name ?? sub.tier, + )} +
+ ${cosmetic?.product?.price + ? html`
+ ${translateText("account_modal.sub_price_monthly", { + price: cosmetic.product.price, + })} +
` + : ""}
- ${this.renderStatus()} + ${cosmetic?.description + ? html`
+ ${cosmetic.description} +
` + : ""} + ${cosmetic + ? html`
+
+ + ${cosmetic.dailyHardCurrency.toLocaleString()} + ${translateText("cosmetics.per_day")} +
+
+ + ${cosmetic.dailySoftCurrency.toLocaleString()} + ${translateText("cosmetics.per_day")} +
+
` + : ""}
- ${cosmetic?.description - ? html`
- ${cosmetic.description} -
` - : ""} - ${cosmetic - ? html`
-
- - ${cosmetic.dailyHardCurrency.toLocaleString()} - ${translateText("cosmetics.per_day")} -
-
- - ${cosmetic.dailySoftCurrency.toLocaleString()} - ${translateText("cosmetics.per_day")} -
-
` - : ""} -
- +
+ ${this.renderStatus()} +
+ ${sub.cancelAtPeriodEnd + ? html`` + : html` + + + `} +
${sub.cancelAtPeriodEnd ? "" - : html``} + : html`
+ +
`}
diff --git a/src/client/components/baseComponents/Button.ts b/src/client/components/baseComponents/Button.ts index 6d9f7bb8e..4e8d0f731 100644 --- a/src/client/components/baseComponents/Button.ts +++ b/src/client/components/baseComponents/Button.ts @@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators.js"; import { translateText } from "../../Utils"; type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; -type ButtonSize = "sm" | "md" | "lg"; +type ButtonSize = "xs" | "sm" | "md" | "lg"; type ButtonWidth = "auto" | "block" | "blockDesktop" | "fill"; type IconPosition = "left" | "right" | "only"; @@ -45,6 +45,8 @@ export class OButton extends LitElement { private sizeClasses(): string { if (this.iconPosition === "only") { switch (this.size) { + case "xs": + return "w-6 h-6 text-xs"; case "sm": return "w-8 h-8 text-sm"; case "md": @@ -54,6 +56,8 @@ export class OButton extends LitElement { } } switch (this.size) { + case "xs": + return "py-1 px-2 text-xs"; case "sm": return "py-1.5 px-3 text-sm"; case "md":