support for unlockable flags (#3479)

## Description:

Add support for purchasable/gated flags.

* Create a new "Store" modal that renders both skins & flags
* move all store related logic out of TerritoryPatternsModal
* use nation:code for existing nation flags & flag:key for gated flags
* check if user has the appropriate flags before purchasing

## 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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
Evan
2026-03-23 17:09:18 -07:00
committed by GitHub
parent 426806299f
commit 39ad547c04
30 changed files with 1144 additions and 1155 deletions
+97
View File
@@ -0,0 +1,97 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import { translateCosmetic } from "../Cosmetics";
import "./PurchaseButton";
export interface FlagItem {
key: string;
name: string;
url: string;
product?: Product | null;
}
@customElement("flag-button")
export class FlagButton extends LitElement {
@property({ type: Boolean })
selected: boolean = false;
@property({ type: Object })
flag!: FlagItem;
@property({ type: Boolean })
requiresPurchase: boolean = false;
@property({ type: Function })
onSelect?: (flagKey: string) => void;
@property({ type: Function })
onPurchase?: () => void;
createRenderRoot() {
return this;
}
private handleClick() {
this.onSelect?.(this.flag.key);
}
render() {
return html`
<div
class="flex flex-col items-center justify-between gap-1 p-2 bg-white/5 backdrop-blur-sm border rounded-lg w-36 h-full transition-all duration-200 ${this
.selected
? "border-green-500 shadow-[0_0_15px_rgba(34,197,94,0.5)]"
: "hover:bg-white/10 hover:border-white/20 hover:shadow-xl border-white/10"}"
>
<button
class="group relative flex flex-col items-center w-full gap-1 rounded-lg cursor-pointer transition-all duration-200
disabled:cursor-not-allowed flex-1"
?disabled=${this.requiresPurchase}
@click=${this.handleClick}
>
<div class="flex flex-col items-center w-full">
<div
class="text-[10px] font-bold text-white uppercase tracking-wider mb-0.5 text-center truncate w-full ${this
.requiresPurchase
? "opacity-50"
: ""}"
title="${translateCosmetic("flags", this.flag.name)}"
>
${translateCosmetic("flags", this.flag.name)}
</div>
<div class="h-[14px] mb-1 w-full"></div>
</div>
<div
class="w-full aspect-square flex items-center justify-center bg-white/5 rounded-lg p-2 border border-white/10 group-hover:border-white/20 transition-colors duration-200 overflow-hidden"
>
<img
src=${this.flag.url}
alt=${this.flag.name}
class="w-full h-full object-contain pointer-events-none"
draggable="false"
loading="lazy"
@error=${(e: Event) => {
const img = e.currentTarget as HTMLImageElement;
const fallback = "/flags/xx.svg";
if (img.src && !img.src.endsWith(fallback)) {
img.src = fallback;
}
}}
/>
</div>
</button>
${this.requiresPurchase && this.flag.product
? html`
<purchase-button
.product=${this.flag.product}
.onPurchase=${() => this.onPurchase?.()}
></purchase-button>
`
: null}
</div>
`;
}
}
+10 -29
View File
@@ -9,7 +9,9 @@ import {
} from "../../core/CosmeticSchemas";
import { PatternDecoder } from "../../core/PatternDecoder";
import { PlayerPattern } from "../../core/Schemas";
import { translateCosmetic } from "../Cosmetics";
import { translateText } from "../Utils";
import "./PurchaseButton";
export const BUTTON_WIDTH = 150;
@@ -36,18 +38,6 @@ export class PatternButton extends LitElement {
return this;
}
private translateCosmetic(prefix: string, patternName: string): string {
const translation = translateText(`${prefix}.${patternName}`);
if (translation.startsWith(prefix)) {
return patternName
.split("_")
.filter((word) => word.length > 0)
.map((word) => word[0].toUpperCase() + word.substring(1))
.join(" ");
}
return translation;
}
private handleClick() {
if (this.pattern === null) {
this.onSelect?.(null);
@@ -60,8 +50,7 @@ export class PatternButton extends LitElement {
} satisfies PlayerPattern);
}
private handlePurchase(e: Event) {
e.stopPropagation();
private handlePurchase() {
if (this.pattern?.product) {
this.onPurchase?.(this.pattern, this.colorPalette ?? null);
}
@@ -91,14 +80,14 @@ export class PatternButton extends LitElement {
: ""}"
title="${isDefaultPattern
? translateText("territory_patterns.pattern.default")
: this.translateCosmetic(
: translateCosmetic(
"territory_patterns.pattern",
this.pattern!.name,
)}"
>
${isDefaultPattern
? translateText("territory_patterns.pattern.default")
: this.translateCosmetic(
: translateCosmetic(
"territory_patterns.pattern",
this.pattern!.name,
)}
@@ -111,7 +100,7 @@ export class PatternButton extends LitElement {
? "opacity-50"
: ""}"
>
${this.translateCosmetic(
${translateCosmetic(
"territory_patterns.color_palette",
this.colorPalette!.name,
)}
@@ -139,18 +128,10 @@ export class PatternButton extends LitElement {
${this.requiresPurchase && this.pattern?.product
? html`
<div class="w-full mt-2">
<button
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
@click=${this.handlePurchase}
>
${translateText("territory_patterns.purchase")}
<span class="ml-1 text-white/60"
>(${this.pattern.product.price})</span
>
</button>
</div>
<purchase-button
.product=${this.pattern.product}
.onPurchase=${() => this.handlePurchase()}
></purchase-button>
`
: null}
</div>
+5
View File
@@ -121,6 +121,11 @@ export class PlayPage extends LitElement {
adaptive-size
class="shrink-0 lg:hidden"
></pattern-input>
<flag-input
id="flag-input-mobile"
show-select-label
class="shrink-0 lg:hidden h-10 w-10"
></flag-input>
</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Product } from "../../core/CosmeticSchemas";
import { translateText } from "../Utils";
@customElement("purchase-button")
export class PurchaseButton extends LitElement {
@property({ type: Object })
product!: Product;
@property({ type: Function })
onPurchase?: () => void;
createRenderRoot() {
return this;
}
private handleClick(e: Event) {
e.stopPropagation();
this.onPurchase?.();
}
render() {
return html`
<div class="no-crazygames w-full mt-2">
<button
class="w-full px-4 py-2 bg-green-500/20 text-green-400 border border-green-500/30 rounded-lg text-xs font-bold uppercase tracking-wider cursor-pointer transition-all duration-200
hover:bg-green-500/30 hover:shadow-[0_0_15px_rgba(74,222,128,0.2)]"
@click=${this.handleClick}
>
${translateText("territory_patterns.purchase")}
<span class="ml-1 text-white/60">(${this.product.price})</span>
</button>
</div>
`;
}
}