mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 01:33:29 +00:00
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:
@@ -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 |
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user