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` -
+
+ +
+ `; + } + + 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`