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
25 changes: 24 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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")
Expand All @@ -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();
Expand Down Expand Up @@ -157,6 +161,8 @@ export class AccountModal extends BaseModal {
</div>
</div>

${this.renderSubscriptionPanel()}

<!-- Middle Row: Stats Section -->
${this.hasAnyStats()
? html`<div
Expand Down Expand Up @@ -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``;
Expand Down Expand Up @@ -377,6 +393,11 @@ export class AccountModal extends BaseModal {
protected onOpen(): void {
this.isLoadingUser = true;

void fetchCosmetics().then((cosmetics) => {
this.cosmetics = cosmetics;
this.requestUpdate();
});
Comment thread
evanpelle marked this conversation as resolved.

void getUserMe()
.then((userMe) => {
if (userMe) {
Expand Down
59 changes: 59 additions & 0 deletions src/client/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Comment thread
evanpelle marked this conversation as resolved.

export function getApiBase() {
const domainname = getAudience();

Expand Down
51 changes: 45 additions & 6 deletions src/client/Cosmetics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Pack,
Pattern,
Product,
Subscription,
} from "../core/CosmeticSchemas";
import {
PlayerCosmeticRefs,
Expand Down Expand Up @@ -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"));
Expand All @@ -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"));
Expand Down Expand Up @@ -228,7 +248,7 @@ export function patternRelationship(
priceSoft: pattern.priceSoft,
priceHard: pattern.priceHard,
affiliateCode,
itemAffiliateCode: pattern.affiliateCode,
itemAffiliateCode: pattern.affiliateCode ?? null,
},
userMeResponse,
);
Expand All @@ -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" */
Expand Down Expand Up @@ -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;
}

Expand Down
10 changes: 9 additions & 1 deletion src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.");
Expand Down
47 changes: 44 additions & 3 deletions src/client/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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") },
];
Expand All @@ -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
Expand All @@ -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}
Expand Down
Loading
Loading