mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-23 08:10:28 +00:00
df84ee023e
## Description: Refactors tab handling out of the individual modal components and into the base o-modal component. Tabs are now declared by passing tabs, activeTab, and onTabChange props, and a new named header slot pins consumer-supplied content above the tabs. This standardizes the modal tab look. <img width="1089" height="290" alt="Screenshot 2026-05-06 at 12 17 33 PM" src="https://github.com/user-attachments/assets/08d5a039-0aef-4aa7-b972-1e43b8723685" /> ## 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
221 lines
5.8 KiB
TypeScript
221 lines
5.8 KiB
TypeScript
import type { TemplateResult } from "lit";
|
|
import { html } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
import { UserMeResponse } from "../core/ApiSchemas";
|
|
import { Cosmetics } from "../core/CosmeticSchemas";
|
|
import { UserSettings } from "../core/game/UserSettings";
|
|
import { BaseModal } from "./components/BaseModal";
|
|
import "./components/CosmeticButton";
|
|
import "./components/NotLoggedInWarning";
|
|
import { modalHeader } from "./components/ui/ModalHeader";
|
|
import {
|
|
fetchCosmetics,
|
|
purchaseCosmetic,
|
|
resolveCosmetics,
|
|
} from "./Cosmetics";
|
|
import { translateText } from "./Utils";
|
|
|
|
@customElement("store-modal")
|
|
export class StoreModal extends BaseModal {
|
|
@state() private activeTab: "patterns" | "flags" | "packs" = "patterns";
|
|
|
|
private cosmetics: Cosmetics | null = null;
|
|
private isActive = false;
|
|
private affiliateCode: string | null = null;
|
|
private userMeResponse: UserMeResponse | false = false;
|
|
|
|
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();
|
|
}
|
|
|
|
private renderHeader(): TemplateResult {
|
|
return modalHeader({
|
|
title: translateText("store.title"),
|
|
onBack: () => this.close(),
|
|
ariaLabel: translateText("common.back"),
|
|
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
|
|
});
|
|
}
|
|
|
|
private renderPatternGrid(): TemplateResult {
|
|
const items = resolveCosmetics(
|
|
this.cosmetics,
|
|
this.userMeResponse,
|
|
this.affiliateCode,
|
|
).filter(
|
|
(r) =>
|
|
r.type === "pattern" &&
|
|
r.relationship !== "blocked" &&
|
|
r.relationship !== "owned",
|
|
);
|
|
|
|
if (items.length === 0) {
|
|
return html`<div
|
|
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
|
>
|
|
${translateText("store.no_skins")}
|
|
</div>`;
|
|
}
|
|
|
|
return html`
|
|
<div
|
|
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
|
>
|
|
${items.map(
|
|
(r) => html`
|
|
<cosmetic-button
|
|
.resolved=${r}
|
|
.onPurchase=${purchaseCosmetic}
|
|
></cosmetic-button>
|
|
`,
|
|
)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderFlagGrid(): TemplateResult {
|
|
const items = resolveCosmetics(
|
|
this.cosmetics,
|
|
this.userMeResponse,
|
|
this.affiliateCode,
|
|
).filter(
|
|
(r) =>
|
|
r.type === "flag" &&
|
|
r.relationship !== "blocked" &&
|
|
r.relationship !== "owned",
|
|
);
|
|
|
|
if (items.length === 0) {
|
|
return html`<div
|
|
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
|
>
|
|
${translateText("store.no_flags")}
|
|
</div>`;
|
|
}
|
|
|
|
const selectedFlag = new UserSettings().getFlag() ?? "";
|
|
return html`
|
|
<div
|
|
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
|
>
|
|
${items.map(
|
|
(r) => html`
|
|
<cosmetic-button
|
|
.resolved=${r}
|
|
.selected=${selectedFlag === r.key}
|
|
.onPurchase=${purchaseCosmetic}
|
|
></cosmetic-button>
|
|
`,
|
|
)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderPackGrid(): TemplateResult {
|
|
const items = resolveCosmetics(
|
|
this.cosmetics,
|
|
this.userMeResponse,
|
|
this.affiliateCode,
|
|
).filter((r) => r.type === "pack" && r.relationship === "purchasable");
|
|
|
|
if (items.length === 0) {
|
|
return html`<div
|
|
class="text-white/40 text-sm font-bold uppercase tracking-wider text-center py-8"
|
|
>
|
|
${translateText("store.no_packs")}
|
|
</div>`;
|
|
}
|
|
|
|
return html`
|
|
<div
|
|
class="flex flex-wrap gap-4 p-8 justify-center items-stretch content-start"
|
|
>
|
|
${items.map(
|
|
(r) => html`
|
|
<cosmetic-button
|
|
.resolved=${r}
|
|
.onPurchase=${purchaseCosmetic}
|
|
></cosmetic-button>
|
|
`,
|
|
)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
render() {
|
|
if (!this.isActive && !this.inline) return html``;
|
|
|
|
const tabs = [
|
|
{ key: "packs", label: translateText("store.packs") },
|
|
{ key: "patterns", label: translateText("store.patterns") },
|
|
{ key: "flags", label: translateText("store.flags") },
|
|
];
|
|
|
|
const grid =
|
|
this.activeTab === "patterns"
|
|
? this.renderPatternGrid()
|
|
: this.activeTab === "flags"
|
|
? this.renderFlagGrid()
|
|
: this.renderPackGrid();
|
|
|
|
return html`
|
|
<o-modal
|
|
id="storeModal"
|
|
title="${translateText("store.title")}"
|
|
?inline=${this.inline}
|
|
?hideHeader=${true}
|
|
?hideCloseButton=${true}
|
|
.tabs=${tabs}
|
|
.activeTab=${this.activeTab}
|
|
.onTabChange=${(key: string) =>
|
|
(this.activeTab = key as "patterns" | "flags" | "packs")}
|
|
>
|
|
<div slot="header">${this.renderHeader()}</div>
|
|
${grid}
|
|
</o-modal>
|
|
`;
|
|
}
|
|
|
|
public async open(options?: string | { affiliateCode?: string }) {
|
|
if (this.isModalOpen) return;
|
|
this.isActive = true;
|
|
if (typeof options === "string") {
|
|
this.affiliateCode = options;
|
|
} else if (
|
|
options !== null &&
|
|
typeof options === "object" &&
|
|
!Array.isArray(options)
|
|
) {
|
|
this.affiliateCode = options.affiliateCode ?? null;
|
|
} else {
|
|
this.affiliateCode = null;
|
|
}
|
|
|
|
this.cosmetics ??= await fetchCosmetics();
|
|
await this.refresh();
|
|
super.open();
|
|
}
|
|
|
|
public close() {
|
|
this.isActive = false;
|
|
this.affiliateCode = null;
|
|
super.close();
|
|
}
|
|
|
|
public async refresh() {
|
|
this.requestUpdate();
|
|
}
|
|
}
|