Skip to content

Commit adb0d07

Browse files
authored
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
1 parent 4dd6c9b commit adb0d07

File tree

11 files changed

+254
-99
lines changed

11 files changed

+254
-99
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
"@opentelemetry/sdk-node": "^0.200.0",
9797
"@opentelemetry/semantic-conventions": "^1.32.0",
9898
"@opentelemetry/winston-transport": "^0.11.0",
99+
"@stripe/stripe-js": "^7.4.0",
99100
"@types/express": "^4.17.21",
100101
"@types/google-protobuf": "^3.15.12",
101102
"@types/hammerjs": "^2.0.45",

resources/lang/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,9 +496,10 @@
496496
},
497497
"territory_patterns": {
498498
"title": "Select Territory Pattern",
499+
"purchase": "Purchase",
499500
"blocked": {
500501
"login": "You must be logged in to access this pattern.",
501-
"role": "This pattern requires the {role} role."
502+
"purchase": "Purchase this pattern to unlock it."
502503
},
503504
"pattern": {
504505
"default": "Default",

src/client/Cosmetics.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { UserMeResponse } from "../core/ApiSchemas";
2+
import { COSMETICS } from "../core/CosmeticSchemas";
3+
import { getApiBase, getAuthHeader } from "./jwt";
4+
import { translateText } from "./Utils";
5+
6+
interface StripeProduct {
7+
id: string;
8+
object: "product";
9+
active: boolean;
10+
created: number;
11+
description: string | null;
12+
images: string[];
13+
livemode: boolean;
14+
metadata: Record<string, string>;
15+
name: string;
16+
shippable: boolean | null;
17+
type: "good" | "service";
18+
updated: number;
19+
url: string | null;
20+
price: string;
21+
price_id: string;
22+
}
23+
24+
export interface Pattern {
25+
name: string;
26+
key: string;
27+
roleGroup?: string;
28+
price?: string;
29+
priceId?: string;
30+
lockedReason?: string;
31+
notShown?: boolean;
32+
}
33+
34+
export async function patterns(
35+
userMe: UserMeResponse | null,
36+
): Promise<Pattern[]> {
37+
const patterns: Pattern[] = Object.entries(COSMETICS.patterns).map(
38+
([key, patternData]) => {
39+
return {
40+
name: patternData.name,
41+
key,
42+
roleGroup: patternData.role_group,
43+
};
44+
},
45+
);
46+
47+
const products = await listAllProducts();
48+
patterns.forEach((pattern) => {
49+
addRestrictions(pattern, userMe, products);
50+
});
51+
return patterns;
52+
}
53+
54+
function addRestrictions(
55+
pattern: Pattern,
56+
userMe: UserMeResponse | null,
57+
products: Map<string, StripeProduct>,
58+
) {
59+
if (userMe === null) {
60+
if (products.has(`pattern:${pattern.name}`)) {
61+
// Purchasable (flare-gated) patterns are shown as disabled
62+
pattern.lockedReason = translateText("territory_patterns.blocked.login");
63+
} else {
64+
// Role-gated patterns are not shown
65+
pattern.notShown = true;
66+
}
67+
return;
68+
}
69+
const flares = userMe.player.flares ?? [];
70+
if (
71+
flares.includes("pattern:*") ||
72+
flares.includes(`pattern:${pattern.name}`)
73+
) {
74+
// Pattern is unlocked by flare
75+
return;
76+
}
77+
78+
const roles = userMe.player.roles ?? [];
79+
if (roles.some((role) => role === pattern.roleGroup)) {
80+
// Pattern is unlocked by role
81+
return;
82+
}
83+
84+
const product = products.get(`pattern:${pattern.name}`);
85+
if (product) {
86+
pattern.price = product.price;
87+
pattern.priceId = product.price_id;
88+
pattern.lockedReason = translateText("territory_patterns.blocked.purchase");
89+
return;
90+
}
91+
92+
// Pattern is locked by role group and not purchasable, don't show it.
93+
pattern.notShown = true;
94+
}
95+
96+
export async function handlePurchase(priceId: string) {
97+
try {
98+
const response = await fetch(
99+
`${getApiBase()}/stripe/create-checkout-session`,
100+
{
101+
method: "POST",
102+
headers: {
103+
"Content-Type": "application/json",
104+
authorization: getAuthHeader(),
105+
},
106+
body: JSON.stringify({
107+
priceId: priceId,
108+
successUrl: `${window.location.href}purchase-success`,
109+
cancelUrl: `${window.location.href}purchase-cancel`,
110+
}),
111+
},
112+
);
113+
114+
if (!response.ok) {
115+
throw new Error(`HTTP error! status: ${response.status}`);
116+
}
117+
118+
const { url } = await response.json();
119+
120+
// Redirect to Stripe checkout
121+
window.location.href = url;
122+
} catch (error) {
123+
console.error("Purchase error:", error);
124+
alert("Something went wrong. Please try again later.");
125+
}
126+
}
127+
128+
// Returns a map of flare -> product
129+
export async function listAllProducts(): Promise<Map<string, StripeProduct>> {
130+
try {
131+
const response = await fetch(`${getApiBase()}/stripe/products`);
132+
if (!response.ok) {
133+
throw new Error(`HTTP error! status: ${response.status}`);
134+
}
135+
const products = (await response.json()) as StripeProduct[];
136+
const productMap = new Map<string, StripeProduct>();
137+
products.forEach((product) => {
138+
productMap.set(product.metadata.flare, product);
139+
});
140+
return productMap;
141+
} catch (error) {
142+
console.error("Failed to fetch products:", error);
143+
return new Map();
144+
}
145+
}

src/client/Main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ class Client {
207207
loginDiscordButton.translationKey = "main.login_discord";
208208
loginDiscordButton.addEventListener("click", discordLogin);
209209
logoutDiscordButton.hidden = true;
210+
territoryModal.onUserMe(null);
210211
} else {
211212
// JWT appears to be valid
212213
loginDiscordButton.disable = true;
@@ -215,12 +216,12 @@ class Client {
215216
logoutDiscordButton.addEventListener("click", () => {
216217
// Log out
217218
logOut();
219+
territoryModal.onUserMe(null);
218220
loginDiscordButton.disable = false;
219221
loginDiscordButton.translationKey = "main.login_discord";
220222
loginDiscordButton.hidden = false;
221223
loginDiscordButton.addEventListener("click", discordLogin);
222224
logoutDiscordButton.hidden = true;
223-
territoryModal.onLogout();
224225
});
225226
// Look up the discord user object.
226227
// TODO: Add caching
@@ -231,6 +232,7 @@ class Client {
231232
loginDiscordButton.translationKey = "main.login_discord";
232233
loginDiscordButton.addEventListener("click", discordLogin);
233234
logoutDiscordButton.hidden = true;
235+
territoryModal.onUserMe(null);
234236
return;
235237
}
236238
console.log(

0 commit comments

Comments
 (0)