From adb0d070740c3a5fd177e74d6b962bf7c6408db3 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 2 Jul 2025 20:03:58 -0700 Subject: [PATCH] Make patterns puchasable with stripe (#1313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Patterns now show how much each skin costs and can be purchased * Refactored logic out of TerritoryPatternsModal and into Cosmetics.ts * Role gated cosmetics are not shown if you don't have the role. This is to prevent people trying to get roles just for the cosmetics. * Added purchasable cosmetics. * On purchase the backend adds the flare to the player account Screenshot 2025-07-01 at 11 45 52 AM ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- package-lock.json | 10 ++ package.json | 1 + resources/lang/en.json | 3 +- src/client/Cosmetics.ts | 145 ++++++++++++++++++++ src/client/Main.ts | 4 +- src/client/TerritoryPatternsModal.ts | 167 ++++++++++-------------- src/client/jwt.ts | 8 +- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 8 ++ tests/util/TestServerConfig.ts | 3 + webpack.config.js | 3 + 11 files changed, 254 insertions(+), 99 deletions(-) create mode 100644 src/client/Cosmetics.ts diff --git a/package-lock.json b/package-lock.json index 319f78472..57c67fd60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@opentelemetry/sdk-node": "^0.200.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@opentelemetry/winston-transport": "^0.11.0", + "@stripe/stripe-js": "^7.4.0", "@types/express": "^4.17.21", "@types/google-protobuf": "^3.15.12", "@types/hammerjs": "^2.0.45", @@ -8830,6 +8831,15 @@ "node": ">=18.0.0" } }, + "node_modules/@stripe/stripe-js": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.4.0.tgz", + "integrity": "sha512-lQHQPfXPTBeh0XFjq6PqSBAyR7umwcJbvJhXV77uGCUDD6ymXJU/f2164ydLMLCCceNuPlbV9b+1smx98efwWQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", diff --git a/package.json b/package.json index 049023cb2..03ef20e3a 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@opentelemetry/sdk-node": "^0.200.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@opentelemetry/winston-transport": "^0.11.0", + "@stripe/stripe-js": "^7.4.0", "@types/express": "^4.17.21", "@types/google-protobuf": "^3.15.12", "@types/hammerjs": "^2.0.45", diff --git a/resources/lang/en.json b/resources/lang/en.json index f91d5e294..9b21f9782 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -496,9 +496,10 @@ }, "territory_patterns": { "title": "Select Territory Pattern", + "purchase": "Purchase", "blocked": { "login": "You must be logged in to access this pattern.", - "role": "This pattern requires the {role} role." + "purchase": "Purchase this pattern to unlock it." }, "pattern": { "default": "Default", diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts new file mode 100644 index 000000000..059e3daef --- /dev/null +++ b/src/client/Cosmetics.ts @@ -0,0 +1,145 @@ +import { UserMeResponse } from "../core/ApiSchemas"; +import { COSMETICS } from "../core/CosmeticSchemas"; +import { getApiBase, getAuthHeader } from "./jwt"; +import { translateText } from "./Utils"; + +interface StripeProduct { + id: string; + object: "product"; + active: boolean; + created: number; + description: string | null; + images: string[]; + livemode: boolean; + metadata: Record; + name: string; + shippable: boolean | null; + type: "good" | "service"; + updated: number; + url: string | null; + price: string; + price_id: string; +} + +export interface Pattern { + name: string; + key: string; + roleGroup?: string; + price?: string; + priceId?: string; + lockedReason?: string; + notShown?: boolean; +} + +export async function patterns( + userMe: UserMeResponse | null, +): Promise { + const patterns: Pattern[] = Object.entries(COSMETICS.patterns).map( + ([key, patternData]) => { + return { + name: patternData.name, + key, + roleGroup: patternData.role_group, + }; + }, + ); + + const products = await listAllProducts(); + patterns.forEach((pattern) => { + addRestrictions(pattern, userMe, products); + }); + return patterns; +} + +function addRestrictions( + pattern: Pattern, + userMe: UserMeResponse | null, + products: Map, +) { + if (userMe === null) { + if (products.has(`pattern:${pattern.name}`)) { + // Purchasable (flare-gated) patterns are shown as disabled + pattern.lockedReason = translateText("territory_patterns.blocked.login"); + } else { + // Role-gated patterns are not shown + pattern.notShown = true; + } + return; + } + const flares = userMe.player.flares ?? []; + if ( + flares.includes("pattern:*") || + flares.includes(`pattern:${pattern.name}`) + ) { + // Pattern is unlocked by flare + return; + } + + const roles = userMe.player.roles ?? []; + if (roles.some((role) => role === pattern.roleGroup)) { + // Pattern is unlocked by role + return; + } + + const product = products.get(`pattern:${pattern.name}`); + if (product) { + pattern.price = product.price; + pattern.priceId = product.price_id; + pattern.lockedReason = translateText("territory_patterns.blocked.purchase"); + return; + } + + // Pattern is locked by role group and not purchasable, don't show it. + pattern.notShown = true; +} + +export async function handlePurchase(priceId: string) { + try { + const response = await fetch( + `${getApiBase()}/stripe/create-checkout-session`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: getAuthHeader(), + }, + body: JSON.stringify({ + priceId: priceId, + successUrl: `${window.location.href}purchase-success`, + cancelUrl: `${window.location.href}purchase-cancel`, + }), + }, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const { url } = await response.json(); + + // Redirect to Stripe checkout + window.location.href = url; + } catch (error) { + console.error("Purchase error:", error); + alert("Something went wrong. Please try again later."); + } +} + +// Returns a map of flare -> product +export async function listAllProducts(): Promise> { + try { + const response = await fetch(`${getApiBase()}/stripe/products`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const products = (await response.json()) as StripeProduct[]; + const productMap = new Map(); + products.forEach((product) => { + productMap.set(product.metadata.flare, product); + }); + return productMap; + } catch (error) { + console.error("Failed to fetch products:", error); + return new Map(); + } +} diff --git a/src/client/Main.ts b/src/client/Main.ts index 7cc946362..d78d431f3 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -207,6 +207,7 @@ class Client { loginDiscordButton.translationKey = "main.login_discord"; loginDiscordButton.addEventListener("click", discordLogin); logoutDiscordButton.hidden = true; + territoryModal.onUserMe(null); } else { // JWT appears to be valid loginDiscordButton.disable = true; @@ -215,12 +216,12 @@ class Client { logoutDiscordButton.addEventListener("click", () => { // Log out logOut(); + territoryModal.onUserMe(null); loginDiscordButton.disable = false; loginDiscordButton.translationKey = "main.login_discord"; loginDiscordButton.hidden = false; loginDiscordButton.addEventListener("click", discordLogin); logoutDiscordButton.hidden = true; - territoryModal.onLogout(); }); // Look up the discord user object. // TODO: Add caching @@ -231,6 +232,7 @@ class Client { loginDiscordButton.translationKey = "main.login_discord"; loginDiscordButton.addEventListener("click", discordLogin); logoutDiscordButton.hidden = true; + territoryModal.onUserMe(null); return; } console.log( diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index bb8d29b1c..b7b3938a8 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -3,11 +3,11 @@ import type { TemplateResult } from "lit"; import { html, LitElement, render } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { UserMeResponse } from "../core/ApiSchemas"; -import { COSMETICS } from "../core/CosmeticSchemas"; import { UserSettings } from "../core/game/UserSettings"; import { PatternDecoder } from "../core/PatternDecoder"; import "./components/Difficulties"; import "./components/Maps"; +import { handlePurchase, Pattern, patterns } from "./Cosmetics"; import { translateText } from "./Utils"; @customElement("territory-patterns-modal") @@ -24,19 +24,21 @@ export class TerritoryPatternsModal extends LitElement { @state() private lockedPatterns: string[] = []; @state() private lockedReasons: Record = {}; - @state() private hoveredPattern: string | null = null; + @state() private hoveredPattern: Pattern | null = null; @state() private hoverPosition = { x: 0, y: 0 }; @state() private keySequence: string[] = []; @state() private showChocoPattern = false; + private patterns: Pattern[] = []; + private me: UserMeResponse | null = null; + public resizeObserver: ResizeObserver; private userSettings: UserSettings = new UserSettings(); constructor() { super(); - this.checkPatternPermission(undefined, undefined); } connectedCallback() { @@ -60,52 +62,9 @@ export class TerritoryPatternsModal extends LitElement { this.resizeObserver.disconnect(); } - onLogout() { - this.checkPatternPermission(undefined, undefined); - } - - onUserMe(userMeResponse: UserMeResponse) { - const { player } = userMeResponse; - const { roles, flares } = player; - this.checkPatternPermission(roles, flares); - } - - private checkPatternPermission( - roles: string[] | undefined, - flares: string[] | undefined, - ) { - this.lockedPatterns = []; - this.lockedReasons = {}; - for (const key in COSMETICS.patterns) { - const patternData = COSMETICS.patterns[key]; - const roleGroup: string[] | string | undefined = patternData.role_group; - if ( - flares !== undefined && - (flares.includes("pattern:*") || flares.includes(`pattern:${key}`)) - ) { - continue; - } - - if (!roleGroup || (Array.isArray(roleGroup) && roleGroup.length === 0)) { - if (roles === undefined || roles.length === 0) { - const reason = translateText("territory_patterns.blocked.login"); - this.setLockedPatterns([key], reason); - } - continue; - } - - const groupList = Array.isArray(roleGroup) ? roleGroup : [roleGroup]; - const isAllowed = - roles !== undefined && - groupList.some((required) => roles.includes(required)); - - if (!isAllowed) { - const reason = translateText("territory_patterns.blocked.role", { - role: groupList.join(", "), - }); - this.setLockedPatterns([key], reason); - } - } + async onUserMe(userMeResponse: UserMeResponse | null) { + this.patterns = await patterns(userMeResponse); + this.me = userMeResponse; this.requestUpdate(); } @@ -141,65 +100,85 @@ export class TerritoryPatternsModal extends LitElement { } private renderTooltip(): TemplateResult | null { - if (this.hoveredPattern && this.lockedReasons[this.hoveredPattern]) { + if (this.hoveredPattern && this.hoveredPattern.lockedReason) { return html`
- ${this.lockedReasons[this.hoveredPattern]} + ${this.hoveredPattern.lockedReason}
`; } return null; } - private renderPatternButton(key: string): TemplateResult { - const isLocked = this.isPatternLocked(key); - const isSelected = this.selectedPattern === key; - const name = COSMETICS.patterns[key]?.name ?? "custom"; + private renderPatternButton(pattern: Pattern): TemplateResult { + const isSelected = this.selectedPattern === pattern.key; + return html` - +
+ ${translateText(`territory_patterns.pattern.${pattern.name}`)} +
+
+ ${this.renderPatternPreview( + pattern.key, + this.buttonWidth, + this.buttonWidth, + )} +
+ + ${pattern.priceId !== undefined && pattern.lockedReason + ? html` + + ` + : null} + `; } private renderPatternGrid(): TemplateResult { const buttons: TemplateResult[] = []; - for (const key in COSMETICS.patterns) { - const value = COSMETICS.patterns[key]; - if (!this.showChocoPattern && value.name === "choco") continue; - const result = this.renderPatternButton(key); + for (const pattern of this.patterns) { + if (!this.showChocoPattern && pattern.name === "choco") continue; + if (pattern.notShown === true) continue; + + const result = this.renderPatternButton(pattern); buttons.push(result); } @@ -339,13 +318,9 @@ export class TerritoryPatternsModal extends LitElement { }; } - private isPatternLocked(patternKey: string): boolean { - return this.lockedPatterns.includes(patternKey); - } - - private handleMouseEnter(patternKey: string, event: MouseEvent) { - if (this.isPatternLocked(patternKey)) { - this.hoveredPattern = patternKey; + private handleMouseEnter(pattern: Pattern, event: MouseEvent) { + if (pattern.lockedReason) { + this.hoveredPattern = pattern; this.hoverPosition = { x: event.clientX, y: event.clientY }; } } diff --git a/src/client/jwt.ts b/src/client/jwt.ts index 684a737dc..da5af1818 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -14,7 +14,7 @@ function getAudience() { return domainname; } -function getApiBase() { +export function getApiBase() { const domainname = getAudience(); return domainname === "localhost" ? (localStorage.getItem("apiHost") ?? "http://localhost:8787") @@ -47,6 +47,12 @@ export function discordLogin() { window.location.href = `${getApiBase()}/login/discord?redirect_uri=${window.location.href}`; } +export function getAuthHeader(): string { + const token = getToken(); + if (!token) return ""; + return `Bearer ${token}`; +} + export async function logOut(allSessions: boolean = false) { const token = localStorage.getItem("token"); if (token === null) return; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index cdf6c3167..461d99c55 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -62,6 +62,7 @@ export interface ServerConfig { cloudflareApiToken(): string; cloudflareConfigPath(): string; cloudflareCredsPath(): string; + stripePublishableKey(): string; } export interface NukeMagnitude { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index dc7588858..510b87399 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -66,6 +66,9 @@ const numPlayersConfig = { } as const satisfies Record; export abstract class DefaultServerConfig implements ServerConfig { + stripePublishableKey(): string { + return process.env.STRIPE_PUBLISHABLE_KEY ?? ""; + } domain(): string { return process.env.DOMAIN ?? ""; } @@ -199,6 +202,11 @@ export class DefaultConfig implements Config { private _userSettings: UserSettings | null, private _isReplay: boolean, ) {} + + stripePublishableKey(): string { + return process.env.STRIPE_PUBLISHABLE_KEY ?? ""; + } + isReplay(): boolean { return this._isReplay; } diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index be5155d5a..4858ddafb 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { + stripePublishableKey(): string { + throw new Error("Method not implemented."); + } cloudflareConfigPath(): string { throw new Error("Method not implemented."); } diff --git a/webpack.config.js b/webpack.config.js index 487894a6c..0820e8cd0 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -126,6 +126,9 @@ export default async (env, argv) => { ), "process.env.GAME_ENV": JSON.stringify(isProduction ? "prod" : "dev"), "process.env.GIT_COMMIT": JSON.stringify(gitCommit), + "process.env.STRIPE_PUBLISHABLE_KEY": JSON.stringify( + process.env.STRIPE_PUBLISHABLE_KEY, + ), }), new CopyPlugin({ patterns: [