mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-12 21:36:05 +00:00
Make patterns puchasable with stripe (#1313)
## 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 <img width="1197" alt="Screenshot 2025-07-01 at 11 45 52 AM" src="https://github.com/user-attachments/assets/b4b4b7ea-f5f4-4c61-9ced-b608f75aa9d7" /> ## 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
This commit is contained in:
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<Pattern[]> {
|
||||
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<string, StripeProduct>,
|
||||
) {
|
||||
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<Map<string, StripeProduct>> {
|
||||
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<string, StripeProduct>();
|
||||
products.forEach((product) => {
|
||||
productMap.set(product.metadata.flare, product);
|
||||
});
|
||||
return productMap;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch products:", error);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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(
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
@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`
|
||||
<div
|
||||
class="fixed z-[10000] px-3 py-2 rounded bg-black text-white text-sm pointer-events-none shadow-md"
|
||||
style="top: ${this.hoverPosition.y + 12}px; left: ${this.hoverPosition
|
||||
.x + 12}px;"
|
||||
>
|
||||
${this.lockedReasons[this.hoveredPattern]}
|
||||
${this.hoveredPattern.lockedReason}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
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`
|
||||
<button
|
||||
class="border p-2 rounded-lg shadow text-black dark:text-white text-left
|
||||
${isSelected
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
|
||||
${isLocked ? "opacity-50 cursor-not-allowed" : ""}"
|
||||
style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);"
|
||||
@click=${() => !isLocked && this.selectPattern(key)}
|
||||
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(key, e)}
|
||||
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
|
||||
@mouseleave=${() => this.handleMouseLeave()}
|
||||
>
|
||||
<div class="text-sm font-bold mb-1">
|
||||
${translateText(`territory_patterns.pattern.${name}`)}
|
||||
</div>
|
||||
<div
|
||||
class="preview-container"
|
||||
style="
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
"
|
||||
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
|
||||
<button
|
||||
class="border p-2 rounded-lg shadow text-black dark:text-white text-left w-full
|
||||
${isSelected
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
|
||||
${pattern.lockedReason ? "opacity-50 cursor-not-allowed" : ""}"
|
||||
@click=${() =>
|
||||
!pattern.lockedReason && this.selectPattern(pattern.key)}
|
||||
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)}
|
||||
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
|
||||
@mouseleave=${() => this.handleMouseLeave()}
|
||||
>
|
||||
${this.renderPatternPreview(key, this.buttonWidth, this.buttonWidth)}
|
||||
</div>
|
||||
</button>
|
||||
<div class="text-sm font-bold mb-1">
|
||||
${translateText(`territory_patterns.pattern.${pattern.name}`)}
|
||||
</div>
|
||||
<div
|
||||
class="preview-container"
|
||||
style="
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
"
|
||||
>
|
||||
${this.renderPatternPreview(
|
||||
pattern.key,
|
||||
this.buttonWidth,
|
||||
this.buttonWidth,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
${pattern.priceId !== undefined && pattern.lockedReason
|
||||
? html`
|
||||
<button
|
||||
class="w-full mt-2 px-3 py-1 bg-green-500 hover:bg-green-600 text-white text-xs font-medium rounded transition-colors"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
handlePurchase(pattern.priceId!);
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
(${pattern.price})
|
||||
</button>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface ServerConfig {
|
||||
cloudflareApiToken(): string;
|
||||
cloudflareConfigPath(): string;
|
||||
cloudflareCredsPath(): string;
|
||||
stripePublishableKey(): string;
|
||||
}
|
||||
|
||||
export interface NukeMagnitude {
|
||||
|
||||
@@ -66,6 +66,9 @@ const numPlayersConfig = {
|
||||
} as const satisfies Record<GameMapType, [number, number, number]>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user