diff --git a/resources/images/ShoppingCart.svg b/resources/images/ShoppingCart.svg new file mode 100644 index 000000000..f4fef5c44 --- /dev/null +++ b/resources/images/ShoppingCart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index fff1e698d..4b04a7f52 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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!", diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 821d8e25e..08e125721 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -29,24 +29,36 @@ export async function handlePurchase( window.location.href = url; } -export async function fetchCosmetics(): Promise { - 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 | null = null; + + return (): Promise => { + 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, diff --git a/src/client/Main.ts b/src/client/Main.ts index 978d35691..bc9fa8b3c 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -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", diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index db624f349..f4c72fa14 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -156,13 +156,13 @@ export class NewsButton extends LitElement { render() { return html` - + diff --git a/src/client/ShopModal.ts b/src/client/ShopModal.ts new file mode 100644 index 000000000..072f6d959 --- /dev/null +++ b/src/client/ShopModal.ts @@ -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) => { + 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` + + (this.activeTab = "patterns")} + > + ${translateText("shop.skins")} + + (this.activeTab = "colors")} + > + ${translateText("shop.colors")} + + + `; + } + + 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` + {}} + .onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) => + handlePurchase(p, colorPalette)} + > + `); + } + } + + if (buttons.length === 0) { + return html` + + + ${translateText("shop.no_items_available")} + + + `; + } + + return html` + + + ${buttons} + + + `; + } + + 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` + + ${translateText("shop.no_colors_available")} + + `; + } + + render() { + if (!this.isActive) return html``; + return html` + + ${this.renderTabNavigation()} + ${this.activeTab === "patterns" + ? this.renderPatternGrid() + : this.renderColorSwatchGrid()} + + `; + } + + 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` + + + + + + ${translateText("shop.badge")} + + + + + `; + } + + private open() { + this.shopModal?.open(); + } + + public close() { + this.shopModal?.close(); + } +} diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 40a12d1fc..9af5a53c6 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -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` - - ${hasLinkedAccount(this.userMeResponse) - ? this.renderMySkinsButton() - : this.renderNotLoggedInWarning()} - + ${!hasLinkedAccount(this.userMeResponse) + ? html` + ${this.renderNotLoggedInWarning()} + ` + : html``} { - this.showOnlyOwned = !this.showOnlyOwned; - }} - > - ${translateText("territory_patterns.show_only_owned")} - `; - } - private renderNotLoggedInWarning(): TemplateResult { return html` @@ -151,12 +151,8 @@ export function renderPatternPreview( function renderBlankPreview(width: number, height: number): TemplateResult { return html` - - + + - + + diff --git a/src/client/styles/layout/container.css b/src/client/styles/layout/container.css index 951245368..efe87777b 100644 --- a/src/client/styles/layout/container.css +++ b/src/client/styles/layout/container.css @@ -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%; }