Add shop button and modal (#2814)

## Description:

For better visibility, add a seperate button that shows skins that can
be purchased.
In the territory skins modal, only show owned skins.

<img width="851" height="834" alt="Screenshot 2026-01-07 at 9 16 46 AM"
src="https://github.com/user-attachments/assets/4ca67280-1c81-47e9-9932-3013dc5c490d"
/>


## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] 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-01-07 11:34:49 -08:00
committed by GitHub
parent e0ecad9cb8
commit bd26230b5c
10 changed files with 302 additions and 56 deletions
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m1119.8 402.28-75 300c-12.516 50.156-57.422 85.219-109.12 85.219h-527.34l17.156 75.609c1.9688-0.09375 3.7969-0.60938 5.7188-0.60938 48.844 0 90.094 31.453 105.61 75h276.32c15.516-43.547 56.766-75 105.61-75 62.016 0 112.5 50.484 112.5 112.5s-50.484 112.5-112.5 112.5c-48.844 0-90.094-31.453-105.61-75h-276.32c-15.516 43.547-56.766 75-105.61 75-62.016 0-112.5-50.484-112.5-112.5 0-32.719 14.25-61.875 36.562-82.453l-160.26-705.05h-82.547c-20.719 0-37.5-16.781-37.5-37.5s16.781-37.5 37.5-37.5h82.594c35.203 0 65.297 24 73.078 58.406l20.812 91.594h721.69c34.875 0 67.219 15.75 88.641 43.266 21.469 27.469 28.922 62.672 20.484 96.516z"/>
</svg>

After

Width:  |  Height:  |  Size: 799 B

+8
View File
@@ -30,6 +30,7 @@
"join_lobby": "Join Lobby",
"single_player": "Single Player",
"instructions": "Instructions",
"shop": "Shop",
"wiki": "Wiki",
"privacy_policy": "Privacy Policy",
"terms_of_service": "Terms of Service",
@@ -730,6 +731,13 @@
"default": "Default"
}
},
"shop": {
"badge": "shop",
"skins": "Skins",
"colors": "Colors",
"no_items_available": "All skins owned! Check back later for new items.",
"no_colors_available": "No colors available for purchase at this time."
},
"flag_input": {
"title": "Select Flag",
"button_title": "Pick a flag!",
+29 -17
View File
@@ -29,24 +29,36 @@ export async function handlePurchase(
window.location.href = url;
}
export async function fetchCosmetics(): Promise<Cosmetics | null> {
try {
const response = await fetch(`${getApiBase()}/cosmetics.json`);
if (!response.ok) {
console.error(`HTTP error! status: ${response.status}`);
return null;
export const fetchCosmetics = (() => {
let cachePromise: Promise<Cosmetics | null> | null = null;
return (): Promise<Cosmetics | null> => {
if (cachePromise !== null) {
return cachePromise;
}
const result = CosmeticsSchema.safeParse(await response.json());
if (!result.success) {
console.error(`Invalid cosmetics: ${result.error.message}`);
return null;
}
return result.data;
} catch (error) {
console.error("Error getting cosmetics:", error);
return null;
}
}
cachePromise = (async () => {
try {
const response = await fetch(`${getApiBase()}/cosmetics.json`);
if (!response.ok) {
console.error(`HTTP error! status: ${response.status}`);
return null;
}
const result = CosmeticsSchema.safeParse(await response.json());
if (!result.success) {
console.error(`Invalid cosmetics: ${result.error.message}`);
return null;
}
return result.data;
} catch (error) {
console.error("Error getting cosmetics:", error);
return null;
}
})();
return cachePromise;
};
})();
export function patternRelationship(
pattern: Pattern,
+2
View File
@@ -31,6 +31,7 @@ import { MatchmakingModal } from "./Matchmaking";
import "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import "./ShopModal";
import { SinglePlayerModal } from "./SinglePlayerModal";
import "./StatsModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
@@ -532,6 +533,7 @@ class Client {
"help-modal",
"user-setting",
"territory-patterns-modal",
"shop-modal",
"language-modal",
"news-modal",
"flag-input-modal",
+3 -3
View File
@@ -156,13 +156,13 @@ export class NewsButton extends LitElement {
render() {
return html`
<div class="flex relative">
<div class="flex relative w-full">
<button
class="border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)]"
class="w-full aspect-square border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)] items-center justify-center"
@click=${this.openNewsModel}
>
<img
class="size-[48px] dark:invert"
class="w-full h-full object-contain dark:invert"
src="${megaphone}"
alt=${translateText("news.title")}
/>
+232
View File
@@ -0,0 +1,232 @@
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { UserMeResponse } from "../core/ApiSchemas";
import { ColorPalette, Cosmetics, Pattern } from "../core/CosmeticSchemas";
import "./components/Difficulties";
import "./components/PatternButton";
import {
fetchCosmetics,
handlePurchase,
patternRelationship,
} from "./Cosmetics";
import { translateText } from "./Utils";
@customElement("shop-modal")
class ShopModalInner extends LitElement {
@query("o-modal") private modalEl!: HTMLElement & {
open: () => void;
close: () => void;
};
@state() private activeTab: "patterns" | "colors" = "patterns";
private cosmetics: Cosmetics | null = null;
private userMeResponse: UserMeResponse | false = false;
private isActive = false;
constructor() {
super();
}
connectedCallback() {
super.connectedCallback();
document.addEventListener(
"userMeResponse",
(event: CustomEvent<UserMeResponse | false>) => {
this.onUserMe(event.detail);
},
);
}
async onUserMe(userMeResponse: UserMeResponse | false) {
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
this.refresh();
}
createRenderRoot() {
return this;
}
private renderTabNavigation(): TemplateResult {
return html`
<div class="flex border-b border-gray-600 mb-4 justify-center">
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 ${this
.activeTab === "patterns"
? "text-blue-400 border-b-2 border-blue-400 bg-blue-400/10"
: "text-gray-400 hover:text-white"}"
@click=${() => (this.activeTab = "patterns")}
>
${translateText("shop.skins")}
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 ${this
.activeTab === "colors"
? "text-blue-400 border-b-2 border-blue-400 bg-blue-400/10"
: "text-gray-400 hover:text-white"}"
@click=${() => (this.activeTab = "colors")}
>
${translateText("shop.colors")}
</button>
</div>
`;
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
for (const pattern of Object.values(this.cosmetics?.patterns ?? {})) {
const colorPalettes = [...(pattern.colorPalettes ?? []), null];
for (const colorPalette of colorPalettes) {
const rel = patternRelationship(
pattern,
colorPalette,
this.userMeResponse,
null, // No affiliate code filtering in shop
);
// Only show purchasable items (not owned or blocked)
if (rel !== "purchasable") {
continue;
}
buttons.push(html`
<pattern-button
.pattern=${pattern}
.colorPalette=${this.cosmetics?.colorPalettes?.[
colorPalette?.name ?? ""
] ?? null}
.requiresPurchase=${true}
.onSelect=${() => {}}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
handlePurchase(p, colorPalette)}
></pattern-button>
`);
}
}
if (buttons.length === 0) {
return html`
<div class="flex flex-col gap-2">
<div class="text-center text-gray-400 py-8">
${translateText("shop.no_items_available")}
</div>
</div>
`;
}
return html`
<div class="flex flex-col gap-2">
<div
class="flex flex-wrap gap-4 p-2"
style="justify-content: center; align-items: flex-start;"
>
${buttons}
</div>
</div>
`;
}
private renderColorSwatchGrid(): TemplateResult {
// For now, show message that colors aren't available in shop
// You could expand this if there are purchasable colors in the future
return html`
<div class="text-center text-gray-400 py-8">
${translateText("shop.no_colors_available")}
</div>
`;
}
render() {
if (!this.isActive) return html``;
return html`
<o-modal
id="shopModal"
title="${this.activeTab === "patterns"
? translateText("shop.skins")
: translateText("shop.colors")}"
>
${this.renderTabNavigation()}
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.renderColorSwatchGrid()}
</o-modal>
`;
}
public async open() {
this.isActive = true;
await this.refresh();
}
public close() {
this.isActive = false;
this.modalEl?.close();
}
public async refresh() {
this.requestUpdate();
// Wait for the DOM to be updated and the o-modal element to be available
await this.updateComplete;
// Now modalEl should be available
if (this.modalEl) {
this.modalEl.open();
} else {
console.warn("modalEl is still null after updateComplete");
}
this.requestUpdate();
}
}
@customElement("shop-button")
export class ShopButton extends LitElement {
@query("shop-modal") private shopModal!: ShopModalInner;
createRenderRoot() {
return this;
}
render() {
return html`
<div class="relative w-full group">
<button
id="shop-button"
class="w-full border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)] justify-center items-center transition-all duration-300 hover:shadow-lg hover:shadow-blue-500/50"
title="Shop"
@click=${this.open}
>
<img
src="/images/ShoppingCart.svg"
alt="Shop"
class="w-full h-full object-contain transition-all duration-300"
style="filter: invert(47%) sepia(96%) saturate(1733%) hue-rotate(193deg) brightness(98%) contrast(101%);"
/>
</button>
<span
id="shop-badge"
class="absolute -top-0.5 -right-2 bg-green-500 text-white text-[9px] font-bold px-2 py-0.5 rounded-full uppercase whitespace-nowrap -rotate-[330deg] shadow-md transition-all duration-300 group-hover:scale-110 group-hover:shadow-[0_0_20px_rgba(34,197,94,0.8),0_0_40px_rgba(34,197,94,0.4)] pointer-events-none overflow-hidden"
style="animation: glint 3s ease-in-out infinite"
>
<span class="relative z-10">${translateText("shop.badge")}</span>
<span
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent"
style="
animation: glint-slide 3s ease-in-out infinite;
transform: translateX(-100%);
"
></span>
</span>
</div>
<shop-modal></shop-modal>
`;
}
private open() {
this.shopModal?.open();
}
public close() {
this.shopModal?.close();
}
}
+8 -25
View File
@@ -29,7 +29,6 @@ export class TerritoryPatternsModal extends LitElement {
@state() private selectedColor: string | null = null;
@state() private activeTab: "patterns" | "colors" = "patterns";
@state() private showOnlyOwned: boolean = false;
private cosmetics: Cosmetics | null = null;
@@ -112,10 +111,8 @@ export class TerritoryPatternsModal extends LitElement {
this.userMeResponse,
this.affiliateCode,
);
if (rel === "blocked") {
continue;
}
if (this.showOnlyOwned && rel !== "owned") {
// Only show owned patterns (skip blocked and purchasable)
if (rel !== "owned") {
continue;
}
buttons.push(html`
@@ -124,7 +121,7 @@ export class TerritoryPatternsModal extends LitElement {
.colorPalette=${this.cosmetics?.colorPalettes?.[
colorPalette?.name ?? ""
] ?? null}
.requiresPurchase=${rel === "purchasable"}
.requiresPurchase=${false}
.onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)}
.onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) =>
handlePurchase(p, colorPalette)}
@@ -135,11 +132,11 @@ export class TerritoryPatternsModal extends LitElement {
return html`
<div class="flex flex-col gap-2">
<div class="flex justify-center">
${hasLinkedAccount(this.userMeResponse)
? this.renderMySkinsButton()
: this.renderNotLoggedInWarning()}
</div>
${!hasLinkedAccount(this.userMeResponse)
? html`<div class="flex justify-center">
${this.renderNotLoggedInWarning()}
</div>`
: html``}
<div
class="flex flex-wrap gap-4 p-2"
style="justify-content: center; align-items: flex-start;"
@@ -158,20 +155,6 @@ export class TerritoryPatternsModal extends LitElement {
`;
}
private renderMySkinsButton(): TemplateResult {
return html`<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
.showOnlyOwned
? "bg-blue-500 text-white hover:bg-blue-600"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
@click=${() => {
this.showOnlyOwned = !this.showOnlyOwned;
}}
>
${translateText("territory_patterns.show_only_owned")}
</button>`;
}
private renderNotLoggedInWarning(): TemplateResult {
return html`<label
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg bg-red-500 text-white"
+2 -6
View File
@@ -75,7 +75,7 @@ export class PatternButton extends LitElement {
<button
class="bg-white/90 border-2 border-black/10 rounded-lg cursor-pointer transition-all duration-200 w-full
hover:bg-white hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/20
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none"
disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none"
?disabled=${this.requiresPurchase}
@click=${this.handleClick}
>
@@ -151,12 +151,8 @@ export function renderPatternPreview(
function renderBlankPreview(width: number, height: number): TemplateResult {
return html`
<div
class="w-full h-full flex items-center justify-center"
style="
display: flex;
align-items: center;
justify-content: center;
height: ${height}px;
width: ${width}px;
background-color: #ffffff;
border-radius: 4px;
box-sizing: border-box;
+7 -4
View File
@@ -226,16 +226,19 @@
<token-login class="w-[20%] md:w-[15%]"></token-login>
<div class="container__row">
<flag-input class="w-[20%] md:w-[20%]"></flag-input>
<territory-patterns-modal class="w-[20%] md:w-[15%]">
<flag-input class="w-[15%] md:w-[15%]"></flag-input>
<territory-patterns-modal class="w-[15%] md:w-[15%]">
<button
id="territory-patterns-input-preview-button"
class="w-full border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)] justify-center"
class="w-full aspect-square border p-[4px] rounded-lg flex cursor-pointer border-black/30 dark:border-gray-300/60 bg-white/70 dark:bg-[rgba(55,65,81,0.7)] justify-center items-center"
title="Pick a pattern!"
></button>
</territory-patterns-modal>
<username-input class="relative w-full"></username-input>
<news-button class="w-[20%] component-hideable"></news-button>
<shop-button class="w-[15%] md:w-[15%]"></shop-button>
<news-button
class="w-[15%] md:w-[15%] component-hideable"
></news-button>
</div>
<div></div>
<div>
+7 -1
View File
@@ -10,10 +10,16 @@
.container__row {
display: flex;
gap: 1rem;
gap: 0.5rem;
align-items: center;
}
@media (min-width: 768px) {
.container__row {
gap: 1rem;
}
}
.container__row--equal > * {
flex: 1 1 100%;
}