mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-19 08:14:59 +00:00
Remove role based perms, fetch cosmetics.json from api (#1640)
## Description: * Fetch cosmetics.json from api * Remove all role based perms, we are only using flares now * Created Priviledge refresher which periodically polls /cosmetics.json endpoint. ## 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 have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
+62
-126
@@ -1,149 +1,85 @@
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { COSMETICS } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics, CosmeticsSchema, Pattern } 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;
|
||||
roles: 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,
|
||||
roles: patternData.role_group
|
||||
? (COSMETICS.role_groups[patternData.role_group] ?? [])
|
||||
: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
const cosmetics = await getCosmetics();
|
||||
|
||||
const products = await listAllProducts();
|
||||
patterns.forEach((pattern) => {
|
||||
addRestrictions(pattern, userMe, products);
|
||||
});
|
||||
if (cosmetics === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patterns: Pattern[] = [];
|
||||
const playerFlares = new Set(userMe?.player.flares);
|
||||
|
||||
for (const name in cosmetics.patterns) {
|
||||
const patternData = cosmetics.patterns[name];
|
||||
const hasAccess = playerFlares.has(`pattern:${name}`);
|
||||
if (hasAccess) {
|
||||
// Remove product info because player already has access.
|
||||
patternData.product = null;
|
||||
patterns.push(patternData);
|
||||
} else if (patternData.product !== null) {
|
||||
// Player doesn't have access, but product is available for purchase.
|
||||
patterns.push(patternData);
|
||||
}
|
||||
// If player doesn't have access and product is null, don't show it.
|
||||
}
|
||||
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 myRoles = userMe.player.roles ?? [];
|
||||
if (
|
||||
pattern.roles.some((authorizedRole) => myRoles.includes(authorizedRole))
|
||||
) {
|
||||
// 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`,
|
||||
}),
|
||||
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) {
|
||||
console.error(
|
||||
`Error purchasing pattern:${response.status} ${response.statusText}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
if (response.status === 401) {
|
||||
alert("You are not logged in. Please log in to purchase a pattern.");
|
||||
} else {
|
||||
alert("Something went wrong. Please try again later.");
|
||||
}
|
||||
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// Returns a map of flare -> product
|
||||
export async function listAllProducts(): Promise<Map<string, StripeProduct>> {
|
||||
async function getCosmetics(): Promise<Cosmetics | undefined> {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/stripe/products`);
|
||||
const response = await fetch(`${getApiBase()}/cosmetics.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
console.error(`HTTP error! status: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
const products = (await response.json()) as StripeProduct[];
|
||||
const productMap = new Map<string, StripeProduct>();
|
||||
products.forEach((product) => {
|
||||
productMap.set(product.metadata.flare, product);
|
||||
});
|
||||
return productMap;
|
||||
const result = CosmeticsSchema.safeParse(await response.json());
|
||||
if (!result.success) {
|
||||
console.error(`Invalid cosmetics: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch products:", error);
|
||||
return new Map();
|
||||
console.error("Error getting cosmetics:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ 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 { Pattern } 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 { handlePurchase, patterns } from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("territory-patterns-modal")
|
||||
@@ -107,14 +108,14 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderTooltip(): TemplateResult | null {
|
||||
if (this.hoveredPattern && this.hoveredPattern.lockedReason) {
|
||||
if (this.hoveredPattern && this.hoveredPattern.product !== undefined) {
|
||||
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.hoveredPattern.lockedReason}
|
||||
${translateText("territory_patterns.blocked.purchase")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -122,7 +123,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderPatternButton(pattern: Pattern): TemplateResult {
|
||||
const isSelected = this.selectedPattern === pattern.key;
|
||||
const isSelected = this.selectedPattern === pattern.pattern;
|
||||
|
||||
return html`
|
||||
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
|
||||
@@ -131,9 +132,9 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
${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" : ""}"
|
||||
${pattern.product !== null ? "opacity-50 cursor-not-allowed" : ""}"
|
||||
@click=${() =>
|
||||
!pattern.lockedReason && this.selectPattern(pattern.key)}
|
||||
pattern.product === null && this.selectPattern(pattern.pattern)}
|
||||
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)}
|
||||
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
|
||||
@mouseleave=${() => this.handleMouseLeave()}
|
||||
@@ -155,23 +156,23 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
"
|
||||
>
|
||||
${this.renderPatternPreview(
|
||||
pattern.key,
|
||||
pattern.pattern,
|
||||
this.buttonWidth,
|
||||
this.buttonWidth,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
${pattern.priceId !== undefined && pattern.lockedReason
|
||||
${pattern.product !== null
|
||||
? 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!);
|
||||
handlePurchase(pattern.product!.priceId);
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
(${pattern.price})
|
||||
(${pattern.product!.price})
|
||||
</button>
|
||||
`
|
||||
: null}
|
||||
@@ -183,7 +184,6 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
const buttons: TemplateResult[] = [];
|
||||
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);
|
||||
@@ -243,6 +243,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
this.modalEl?.open();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
this.isActive = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public close() {
|
||||
@@ -332,7 +333,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private handleMouseEnter(pattern: Pattern, event: MouseEvent) {
|
||||
if (pattern.lockedReason) {
|
||||
if (pattern.product !== null) {
|
||||
this.hoveredPattern = pattern;
|
||||
this.hoverPosition = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user