import { html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { getUserMe, invalidateUserMe } from "./Api"; import { type ClanInfo, type ClanMember } from "./ClanApi"; import { BaseModal } from "./components/BaseModal"; import "./components/clan/ClanBansView"; import "./components/clan/ClanBrowseView"; import type { BrowseState } from "./components/clan/ClanBrowseView"; import "./components/clan/ClanCard"; import "./components/clan/ClanDetailView"; import "./components/clan/ClanGameHistoryView"; import type { ClanGameHistoryCache } from "./components/clan/ClanGameHistoryView"; import "./components/clan/ClanManageView"; import "./components/clan/ClanMyRequestsView"; import "./components/clan/ClanRequestsView"; import type { ClanRole } from "./components/clan/ClanShared"; import "./components/clan/ClanTransferView"; import "./components/ConfirmDialog"; import "./components/CopyButton"; import { modalHeader } from "./components/ui/ModalHeader"; import { modalRouter } from "./ModalRouter"; import { translateText } from "./Utils"; type View = | "list" | "detail" | "manage" | "transfer" | "requests" | "bans" | "my-requests"; // List tabs share BaseModal's `activeTab` slot with detail tabs ("overview" / // "members" / "game-history"); which set is live depends on `view`. const LIST_TABS = ["my-clans", "browse"] as const; type ListTab = (typeof LIST_TABS)[number]; function isListTab(key: string): key is ListTab { return (LIST_TABS as readonly string[]).includes(key); } @customElement("clan-modal") export class ClanModal extends BaseModal { protected routerName = "clan"; @state() private view: View = "list"; @state() private loading = false; @state() private myClans: ClanInfo[] = []; @state() private myPendingRequests: { tag: string; name: string; createdAt: string; }[] = []; @state() private selectedClanTag = ""; @state() private selectedClan: ClanInfo | null = null; @state() private myRole: ClanRole | null = null; private myPublicId: string | null = null; @state() private myClanRoles = new Map(); // Lifted browse state — survives tab switches private browseCache: BrowseState | null = null; // Lifted detail cache — survives sub-view navigation private detailCache: { tag: string; members: ClanMember[]; membersTotal: number; pendingRequestCount: number; } | null = null; // Single-clan cache: switching clans within one modal session drops // it (see `openDetail`), so a user who clan-hops loses their filter // selection and accumulated scroll on the previous clan. Keyed-by-tag // would persist across hops if that becomes desired. private gameHistoryCache: ClanGameHistoryCache | null = null; private previousListTab: ListTab = "my-clans"; private get onListView(): boolean { return this.view === "list" && !this.selectedClanTag; } private get onDetailView(): boolean { return this.view === "detail" && !!this.selectedClanTag; } protected modalConfig() { return { tabs: this.onListView ? [ { key: "my-clans", label: translateText("clan_modal.my_clans") }, { key: "browse", label: translateText("clan_modal.browse") }, ] : this.onDetailView ? [ { key: "overview", label: translateText("clan_modal.tab_overview"), }, { key: "members", label: translateText("clan_modal.tab_members"), }, { key: "game-history", label: translateText("clan_modal.tab_game_history"), }, ] : [], }; } protected renderHeaderSlot() { return this.onListView ? modalHeader({ title: translateText("clan_modal.title"), onBack: () => this.close(), ariaLabel: translateText("common.back"), }) : this.renderSubViewHeader(); } protected renderBody() { return html`
${this.renderInner()}
`; } protected onTabEnter(tab: string): void { if (isListTab(tab)) { this.view = "list"; this.selectedClan = null; this.selectedClanTag = ""; this.detailCache = null; this.gameHistoryCache = null; if (tab === "my-clans") { this.loadMyClans(); } return; } // Detail tabs: BaseModal already updated activeTab; renderInner reads it. // No additional side effects required here. } private tagPill(tag: string) { return html`[${tag}]`; } private renderSubViewHeader() { const clan = this.selectedClan; const ariaLabel = translateText("common.back"); if (this.view === "my-requests") { return modalHeader({ title: translateText("clan_modal.pending_applications"), onBack: () => (this.view = "list"), ariaLabel, }); } if (this.view === "manage") { return modalHeader({ title: translateText("clan_modal.manage_clan"), onBack: () => (this.view = "detail"), ariaLabel, rightContent: clan ? this.tagPill(clan.tag) : undefined, }); } if (this.view === "transfer") { return modalHeader({ title: translateText("clan_modal.transfer_leadership"), onBack: () => (this.view = "manage"), ariaLabel, }); } if (this.view === "requests") { return modalHeader({ title: translateText("clan_modal.join_requests"), onBack: () => (this.view = "detail"), ariaLabel, }); } if (this.view === "bans") { return modalHeader({ title: translateText("clan_modal.banned_players"), onBack: () => (this.view = "manage"), ariaLabel, }); } // Default: detail return modalHeader({ title: clan?.name ?? translateText("clan_modal.title"), onBack: () => { this.view = "list"; this.selectedClan = null; this.selectedClanTag = ""; this.myRole = null; this.detailCache = null; modalRouter.syncArgs("clan", { clan: null, tag: null }); this.gameHistoryCache = null; this.setActiveTab(this.previousListTab); }, ariaLabel, rightContent: clan ? this.tagPill(clan.tag) : undefined, }); } protected onOpen(args?: Record): void { const targetTag = typeof args?.clan === "string" ? args.clan.trim() : typeof args?.tag === "string" ? args.tag.trim() : ""; if (targetTag) { this.openDetail(targetTag.toUpperCase()); } this.loadMyClans({ allowGuest: Boolean(targetTag) }); } protected onClose(): void { this.activeTab = "my-clans"; this.previousListTab = "my-clans"; this.view = "list"; this.selectedClan = null; this.selectedClanTag = ""; this.myRole = null; this.browseCache = null; this.detailCache = null; this.gameHistoryCache = null; } private async loadMyClans(opts: { allowGuest?: boolean } = {}) { this.loading = true; try { const me = await getUserMe(); if (!this.isModalOpen) return; if (!me || Object.keys(me.user).length === 0) { if (opts.allowGuest) { this.myPublicId = null; this.myPendingRequests = []; this.myClanRoles = new Map(); this.myClans = []; return; } window.dispatchEvent( new CustomEvent("show-message", { detail: { message: translateText("clan_modal.sign_in_for_clans"), color: "red", duration: 3000, }, }), ); this.close(); window.showPage?.("page-account"); return; } this.myPublicId = me.player.publicId; this.myPendingRequests = me.player.clanRequests ?? []; const roles = new Map(); const clans: ClanInfo[] = []; for (const c of me.player.clans ?? []) { roles.set(c.tag, c.role); clans.push({ tag: c.tag, name: c.name, description: "", isOpen: false, memberCount: c.memberCount, }); } this.myClanRoles = roles; this.myClans = clans; } finally { this.loading = false; } } private renderInner() { if (this.loading) { return this.renderLoadingSpinner(); } if (this.view === "my-requests") { return html` (this.view = "list")} @request-withdrawn=${(e: CustomEvent<{ tag: string }>) => { this.myPendingRequests = this.myPendingRequests.filter( (r) => r.tag !== e.detail.tag, ); if (this.myPendingRequests.length === 0) this.view = "list"; }} >`; } if (this.selectedClanTag) { if (this.view === "manage") { return html` (this.view = "detail")} @navigate-bans=${() => (this.view = "bans")} @navigate-transfer=${() => (this.view = "transfer")} @clan-updated=${(e: CustomEvent>) => { if (this.selectedClan) { this.selectedClan = { ...this.selectedClan, ...e.detail }; } this.detailCache = null; invalidateUserMe(); }} @clan-disbanded=${(e: CustomEvent<{ tag: string }>) => { const roles = new Map(this.myClanRoles); roles.delete(e.detail.tag); this.myClanRoles = roles; this.myClans = this.myClans.filter((c) => c.tag !== e.detail.tag); this.selectedClan = null; this.selectedClanTag = ""; this.myRole = null; this.detailCache = null; this.view = "list"; this.setActiveTab(this.previousListTab); }} >`; } if (this.view === "transfer") { return html` (this.view = "manage")} @leadership-transferred=${() => { this.loadMyClans().then(() => this.openDetail(this.selectedClanTag), ); }} >`; } if (this.view === "requests") { return html` (this.view = "detail")} @request-approved=${() => { if (this.selectedClan) { this.selectedClan = { ...this.selectedClan, memberCount: (this.selectedClan.memberCount ?? 0) + 1, }; } this.detailCache = null; }} >`; } if (this.view === "bans") { return html` (this.view = "manage")} >`; } // Default: detail view — dispatched by the active detail tab if (this.activeTab === "game-history") { return html`) => { this.gameHistoryCache = e.detail; }} @close-clan-modal=${() => this.close()} >`; } return html` { this.view = "list"; this.selectedClan = null; this.selectedClanTag = ""; this.myRole = null; this.detailCache = null; this.gameHistoryCache = null; this.setActiveTab(this.previousListTab); }} @detail-loaded=${( e: CustomEvent<{ clan: ClanInfo; myRole: ClanRole | null; members: ClanMember[]; membersTotal: number; pendingRequestCount: number; }>, ) => { this.selectedClan = e.detail.clan; this.myRole = e.detail.myRole; this.detailCache = { tag: e.detail.clan.tag, members: e.detail.members, membersTotal: e.detail.membersTotal, pendingRequestCount: e.detail.pendingRequestCount, }; }} @members-loaded=${( e: CustomEvent<{ members: ClanMember[]; membersTotal: number; pendingRequestCount: number; }>, ) => { if ( !this.detailCache || this.detailCache.tag !== this.selectedClanTag ) return; this.detailCache = { ...this.detailCache, members: e.detail.members, membersTotal: e.detail.membersTotal, pendingRequestCount: e.detail.pendingRequestCount, }; }} @navigate-manage=${() => (this.view = "manage")} @navigate-requests=${() => (this.view = "requests")} @clan-joined=${(e: CustomEvent<{ tag: string }>) => { this.myClanRoles = new Map([ ...this.myClanRoles, [e.detail.tag, "member" as ClanRole], ]); this.detailCache = null; this.openDetail(e.detail.tag); }} @clan-left=${(e: CustomEvent<{ tag: string }>) => { const roles = new Map(this.myClanRoles); roles.delete(e.detail.tag); this.myClanRoles = roles; this.selectedClan = null; this.selectedClanTag = ""; this.myRole = null; this.detailCache = null; this.view = "list"; this.setActiveTab(this.previousListTab); }} @request-sent=${(e: CustomEvent<{ tag: string; name: string }>) => { this.myPendingRequests = [ ...this.myPendingRequests, { tag: e.detail.tag, name: e.detail.name, createdAt: new Date().toISOString(), }, ]; }} >`; } // List view (my clans / browse) — header + tabs are rendered by o-modal return html` ${this.activeTab === "my-clans" ? this.renderMyClans() : html`) => { this.browseCache = e.detail; }} @clan-select=${(e: CustomEvent<{ tag: string }>) => this.openDetail(e.detail.tag)} >`} `; } private openDetail(tag: string) { if (this.selectedClanTag !== tag) { // History cache is per-clan (see `gameHistoryCache` declaration), // so it must be cleared on tag change. `detailCache` is left // alone — its `tag` field is checked at render time and the // detail view falls back to a fresh fetch when it doesn't match, // so an explicit null here would be redundant. this.gameHistoryCache = null; } // Remember which list tab the user was on so the back button can // return them to it (browse vs my-clans). if (isListTab(this.activeTab)) { this.previousListTab = this.activeTab; } this.selectedClanTag = tag; this.view = "detail"; modalRouter.syncArgs("clan", { clan: tag, tag: null }); // modalConfig() returns detail tabs; setActiveTab anchors activeTab to // "overview" and syncs the URL router (routerName = "clan"). this.setActiveTab("overview"); } private renderMyClans() { const hasClans = this.myClans.length > 0; const hasRequests = this.myPendingRequests.length > 0; if (!hasClans && !hasRequests) { return html`

${translateText("clan_modal.no_clans")}

`; } return html`
${hasRequests ? this.renderPendingRequestsButton() : ""} ${this.myClans.map( (clan) => html` ) => this.openDetail(e.detail.tag)} > `, )}
`; } private renderPendingRequestsButton() { const count = this.myPendingRequests.length; return html` `; } }