feat(client): shareable player profile modal (#modal=profile&publicID=x) (#4639)

## Description:

Add player profile (stats only for now):
<img width="952" height="241" alt="image"
src="https://github.com/user-attachments/assets/2cc0c1ed-baf6-4cb5-837c-1f82d2850005"
/>

and clans modal:
<img width="927" height="778" alt="image"
src="https://github.com/user-attachments/assets/87e7ae53-6531-41cd-8795-4ea1d8fb67af"
/>

with dedicated url:
<img width="1273" height="916" alt="image"
src="https://github.com/user-attachments/assets/dd1ccff7-5d3c-4c70-a7d2-0c7399a88a08"
/>



## 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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

w.o.n

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ryan
2026-07-18 16:04:09 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent ea464d44bc
commit a5480ec7f7
11 changed files with 441 additions and 11 deletions
+189
View File
@@ -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<boolean> };
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<CopyButton>("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();
});
});