diff --git a/index.html b/index.html index 1edcbad0d..8d363cc4a 100644 --- a/index.html +++ b/index.html @@ -272,6 +272,11 @@ inline class="hidden w-full h-full page-content relative z-50" > + - - 📊 - ${translateText("account_modal.stats_overview")} - + + + 📊 + ${translateText("account_modal.stats_overview")} + + ${publicId + ? html` + + ` + : ""} + diff --git a/src/client/Api.ts b/src/client/Api.ts index b685cb9f3..f899ef72a 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -66,6 +66,44 @@ export async function fetchPlayerById( } } +// GET /public/player/:publicId — public player profile (stats tree). No auth, +// so logged-out visitors can view shared profiles. +export async function fetchPublicPlayerProfile( + publicId: string, +): Promise { + try { + const url = `${getApiBase()}/public/player/${encodeURIComponent(publicId)}`; + + const res = await fetch(url, { + headers: { Accept: "application/json" }, + }); + + if (res.status !== 200) { + console.warn( + "fetchPublicPlayerProfile: unexpected status", + res.status, + res.statusText, + ); + return false; + } + + const json = await res.json(); + const parsed = PlayerProfileSchema.safeParse(json); + if (!parsed.success) { + console.warn( + "fetchPublicPlayerProfile: Zod validation failed", + parsed.error, + ); + return false; + } + + return parsed.data; + } catch (err) { + console.warn("fetchPublicPlayerProfile: request failed", err); + return false; + } +} + // GET /public/player/:publicId/games — keyset-paginated personal game history. // Public (no auth). `filter` (mode bucket) and `type` (game-type split) are // orthogonal; `cursor` is the opaque token from the previous response's diff --git a/src/client/ClanModal.ts b/src/client/ClanModal.ts index 3c479cc58..51b6353fa 100644 --- a/src/client/ClanModal.ts +++ b/src/client/ClanModal.ts @@ -444,6 +444,8 @@ export class ClanModal extends BaseModal { pendingRequestCount: e.detail.pendingRequestCount, }; }} + @view-profile=${(e: CustomEvent<{ publicId: string }>) => + this.openPlayerProfile(e.detail.publicId)} @navigate-manage=${() => (this.view = "manage")} @navigate-requests=${() => (this.view = "requests")} @clan-joined=${(e: CustomEvent<{ tag: string }>) => { @@ -532,6 +534,30 @@ export class ClanModal extends BaseModal { } } + private openPlayerProfile(publicId: string): void { + const profileModal = document.querySelector< + HTMLElement & { openFromClan(publicId: string): void } + >("player-profile-modal"); + if (!profileModal) return; + + // Same handoff as openGameStats: keep clan state so the profile modal's + // back button can land on the members tab without a refetch. + this.preserveStateForGameStats = true; + try { + profileModal.openFromClan(publicId); + } finally { + this.preserveStateForGameStats = false; + } + } + + public returnToMembers(): void { + const tag = this.selectedClanTag; + if (!tag) return; + + this.returningFromGameStats = true; + this.open({ clan: tag, tab: "members" }); + } + public returnToGameHistory(): void { const tag = this.selectedClanTag; if (!tag) return; diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 4d0832691..c38557d87 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -234,6 +234,7 @@ export class LangSelector extends LitElement { "news-button", "account-modal", "game-stats-modal", + "player-profile-modal", "game-info-view", "ranking-controls", "leaderboard-modal", diff --git a/src/client/Main.ts b/src/client/Main.ts index 22fd8f2d4..3c3c542aa 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -49,6 +49,7 @@ import { MatchmakingModal } from "./Matchmaking"; import { modalRouter } from "./ModalRouter"; import { initNavigation } from "./Navigation"; import "./NewsModal"; +import "./PlayerProfileModal"; import { RewardsModal } from "./RewardsModal"; import "./SinglePlayerModal"; import { StoreModal } from "./Store"; @@ -306,6 +307,10 @@ class Client { tag: "game-stats-modal", pageId: "page-stats", }); + modalRouter.register("profile", { + tag: "player-profile-modal", + pageId: "page-profile", + }); modalRouter.register("help", { tag: "help-modal", pageId: "page-help" }); modalRouter.register("news", { tag: "news-modal", pageId: "page-news" }); modalRouter.register("language", { diff --git a/src/client/PlayerProfileModal.ts b/src/client/PlayerProfileModal.ts new file mode 100644 index 000000000..65ea1c252 --- /dev/null +++ b/src/client/PlayerProfileModal.ts @@ -0,0 +1,121 @@ +import { html } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import type { PlayerStatsTree } from "../core/ApiSchemas"; +import { fetchPublicPlayerProfile } from "./Api"; +import "./components/baseComponents/stats/PlayerStatsTree"; +import { BaseModal } from "./components/BaseModal"; +import "./components/CopyButton"; +import { modalHeader } from "./components/ui/ModalHeader"; +import { translateText } from "./Utils"; + +/** Build a shareable profile URL for a publicId. */ +export function playerProfileUrl(publicId: string): string { + return `${window.location.origin}${window.location.pathname}#modal=profile&publicID=${encodeURIComponent(publicId)}`; +} + +@customElement("player-profile-modal") +export class PlayerProfileModal extends BaseModal { + protected routerName = "profile"; + + @state() private publicId: string | null = null; + @state() private statsTree: PlayerStatsTree | null = null; + @state() private loading = false; + private openedFrom: "clan" | null = null; + + protected modalConfig() { + return { maxWidth: "960px" }; + } + + protected renderHeaderSlot() { + return modalHeader({ + title: translateText("player_profile.title"), + onBack: () => this.back(), + ariaLabel: translateText("common.back"), + rightContent: this.publicId + ? html` + + ` + : undefined, + }); + } + + protected renderBody() { + return html` + + ${this.renderProfile()} + + `; + } + + private renderProfile() { + if (this.loading) { + return this.renderLoadingSpinner(translateText("player_profile.loading")); + } + if (!this.publicId || !this.statsTree) { + return html` + + 📊 + + ${translateText("player_profile.not_found")} + + + `; + } + return html` + + + + `; + } + + protected onOpen(args?: Record): void { + const publicId = + typeof args?.publicID === "string" && args.publicID.length > 0 + ? args.publicID + : null; + this.publicId = publicId; + this.statsTree = null; + this.loading = publicId !== null; + if (publicId !== null) { + void this.loadProfile(publicId); + } + } + + private async loadProfile(publicId: string): Promise { + const profile = await fetchPublicPlayerProfile(publicId); + // A late response must not clobber state after close or re-open. + if (this.publicId !== publicId) return; + this.loading = false; + this.statsTree = profile === false ? null : profile.stats; + } + + protected onClose(): void { + this.publicId = null; + this.statsTree = null; + this.loading = false; + this.openedFrom = null; + } + + public openFromClan(publicId: string): void { + this.openedFrom = "clan"; + this.open({ publicID: publicId }); + } + + private back(): void { + const openedFrom = this.openedFrom; + this.close(); + if (openedFrom === "clan") { + document + .querySelector("clan-modal") + ?.returnToMembers(); + } + } +} diff --git a/src/client/components/clan/ClanDetailView.ts b/src/client/components/clan/ClanDetailView.ts index a51c6147f..4179be9d1 100644 --- a/src/client/components/clan/ClanDetailView.ts +++ b/src/client/components/clan/ClanDetailView.ts @@ -548,7 +548,17 @@ export class ClanDetailView extends LitElement { ), )} - ${filtered.map((m) => renderMemberRow(m, this.myPublicId))} + ${filtered.map((m) => + renderMemberRow(m, this.myPublicId, (publicId) => + this.dispatchEvent( + new CustomEvent("view-profile", { + detail: { publicId }, + bubbles: true, + composed: true, + }), + ), + ), + )} ${renderMemberPagination( this.memberPage, diff --git a/src/client/components/clan/ClanShared.ts b/src/client/components/clan/ClanShared.ts index cb79faef1..5e36a1ba5 100644 --- a/src/client/components/clan/ClanShared.ts +++ b/src/client/components/clan/ClanShared.ts @@ -378,6 +378,7 @@ export function renderMemberStats( export function renderMemberRow( member: ClanMember, myPublicId: string | null, + onViewProfile?: (publicId: string) => void, ): TemplateResult { const isMe = member.publicId === myPublicId; return html` @@ -407,12 +408,24 @@ export function renderMemberRow( .showCopyIcon=${false} > - ${translateText("clan_modal.joined_date", { - date: formatClanDate(member.joinedAt), - })} + + ${translateText("clan_modal.joined_date", { + date: formatClanDate(member.joinedAt), + })} + ${onViewProfile + ? html` onViewProfile(member.publicId)} + > + 📊 + ` + : ""} + diff --git a/tests/client/PlayerProfileModal.test.ts b/tests/client/PlayerProfileModal.test.ts new file mode 100644 index 000000000..fd4e9d1f1 --- /dev/null +++ b/tests/client/PlayerProfileModal.test.ts @@ -0,0 +1,189 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const copyToClipboardMock = vi.hoisted(() => + vi.fn(async (_text: string, onSuccess?: () => void) => onSuccess?.()), +); + +const fetchPublicPlayerProfileMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../src/client/Utils", () => ({ + translateText: vi.fn((key: string) => key), + copyToClipboard: copyToClipboardMock, +})); + +vi.mock("../../src/client/Api", () => ({ + fetchPublicPlayerProfile: fetchPublicPlayerProfileMock, +})); + +vi.mock( + "../../src/client/components/baseComponents/stats/PlayerStatsTree", + () => { + class FakePlayerStatsTreeView extends HTMLElement {} + if (!customElements.get("player-stats-tree-view")) { + customElements.define("player-stats-tree-view", FakePlayerStatsTreeView); + } + return { PlayerStatsTreeView: FakePlayerStatsTreeView }; + }, +); + +import type { CopyButton } from "../../src/client/components/CopyButton"; +import { modalRouter } from "../../src/client/ModalRouter"; +import { initNavigation } from "../../src/client/Navigation"; +import { + PlayerProfileModal, + playerProfileUrl, +} from "../../src/client/PlayerProfileModal"; + +type ModalShell = HTMLElement & { updateComplete: Promise }; + +const statsTree = { Public: { "Free For All": {} } }; + +describe("public player profile route", () => { + let modal: PlayerProfileModal; + let playPage: HTMLElement; + + beforeAll(() => { + playPage = document.createElement("div"); + playPage.id = "page-play"; + document.body.appendChild(playPage); + initNavigation(); + }); + + afterAll(() => { + playPage.remove(); + }); + + beforeEach(async () => { + copyToClipboardMock.mockClear(); + fetchPublicPlayerProfileMock.mockReset(); + vi.stubGlobal("localStorage", { getItem: vi.fn(() => null) }); + history.replaceState(null, "", "/"); + modalRouter.register("profile", { + tag: "player-profile-modal", + pageId: "page-profile", + }); + if (!customElements.get("player-profile-modal")) { + customElements.define("player-profile-modal", PlayerProfileModal); + } + + modal = document.createElement( + "player-profile-modal", + ) as PlayerProfileModal; + modal.id = "page-profile"; + modal.setAttribute("inline", ""); + modal.className = "hidden page-content"; + document.body.appendChild(modal); + await modal.updateComplete; + }); + + afterEach(() => { + window.showPage?.("page-play"); + modal.remove(); + history.replaceState(null, "", "/"); + vi.unstubAllGlobals(); + }); + + it("opens a shared publicID and renders the fetched stats tree", async () => { + fetchPublicPlayerProfileMock.mockResolvedValue({ + createdAt: "2026-01-01T00:00:00.000Z", + stats: statsTree, + }); + history.replaceState(null, "", "/#modal=profile&publicID=shared-player"); + + expect(modalRouter.routeFromHash()).toBe(true); + + await vi.waitFor(async () => { + await modal.updateComplete; + const shell = modal.querySelector("o-modal") as ModalShell | null; + await shell?.updateComplete; + const treeView = modal.querySelector("player-stats-tree-view") as + | (HTMLElement & { statsTree?: unknown }) + | null; + expect(modal.isOpen()).toBe(true); + expect(treeView?.statsTree).toEqual(statsTree); + }); + + expect(fetchPublicPlayerProfileMock).toHaveBeenCalledWith("shared-player"); + + const copyButton = modal.querySelector("copy-button")!; + await copyButton.updateComplete; + expect(copyButton.copyText).toBe(playerProfileUrl("shared-player")); + expect(copyButton.displayText).toBe("shared-player"); + expect(copyButton.compact).toBe(true); + + expect(window.location.hash).toBe("#modal=profile&publicID=shared-player"); + + const backButton = modal.querySelector( + '[slot="header"] button', + ) as HTMLButtonElement; + backButton.click(); + + expect(modal.isOpen()).toBe(false); + expect(window.location.hash).toBe(""); + }); + + it("shows not-found when the profile fetch fails", async () => { + fetchPublicPlayerProfileMock.mockResolvedValue(false); + history.replaceState(null, "", "/#modal=profile&publicID=missing"); + + expect(modalRouter.routeFromHash()).toBe(true); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.isOpen()).toBe(true); + expect(modal.querySelector("player-stats-tree-view")).toBeNull(); + expect(modal.textContent).toContain("player_profile.not_found"); + }); + expect(fetchPublicPlayerProfileMock).toHaveBeenCalledWith("missing"); + }); + + it("shows not-found without fetching when publicID is missing", async () => { + history.replaceState(null, "", "/#modal=profile"); + + expect(modalRouter.routeFromHash()).toBe(true); + + await vi.waitFor(async () => { + await modal.updateComplete; + expect(modal.isOpen()).toBe(true); + expect(modal.textContent).toContain("player_profile.not_found"); + }); + expect(fetchPublicPlayerProfileMock).not.toHaveBeenCalled(); + }); + + it("returns to the clan members tab when opened from a clan", async () => { + fetchPublicPlayerProfileMock.mockResolvedValue({ + createdAt: "2026-01-01T00:00:00.000Z", + stats: statsTree, + }); + const returnToMembers = vi.fn(); + const fakeClanModal = document.createElement( + "clan-modal", + ) as HTMLElement & { + returnToMembers: () => void; + }; + fakeClanModal.returnToMembers = returnToMembers; + document.body.appendChild(fakeClanModal); + + modal.openFromClan("clan-member"); + await modal.updateComplete; + expect(modal.isOpen()).toBe(true); + + const backButton = modal.querySelector( + '[slot="header"] button', + ) as HTMLButtonElement; + backButton.click(); + + expect(modal.isOpen()).toBe(false); + expect(returnToMembers).toHaveBeenCalled(); + fakeClanModal.remove(); + }); +});
+ ${translateText("player_profile.not_found")} +