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
+19 -4
View File
@@ -34,6 +34,7 @@ import "./components/SubscriptionPanel";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK";
import { playerProfileUrl } from "./PlayerProfileModal";
import { translateText } from "./Utils";
@customElement("account-modal")
@@ -393,12 +394,26 @@ export class AccountModal extends BaseModal {
translateText("account_modal.no_stats"),
);
}
const publicId = this.userMeResponse?.player?.publicId ?? "";
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="text-blue-400">📊</span>
${translateText("account_modal.stats_overview")}
</h3>
<div class="flex items-center justify-between gap-2 mb-4">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<span class="text-blue-400">📊</span>
${translateText("account_modal.stats_overview")}
</h3>
${publicId
? html`
<copy-button
compact
class="shrink-0"
.copyText=${playerProfileUrl(publicId)}
.displayText=${translateText("player_profile.share")}
.showVisibilityToggle=${false}
></copy-button>
`
: ""}
</div>
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
+38
View File
@@ -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<PlayerProfile | false> {
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
+26
View File
@@ -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;
+1
View File
@@ -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",
+5
View File
@@ -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", {
+121
View File
@@ -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`
<copy-button
compact
class="shrink-0"
.copyText=${playerProfileUrl(this.publicId)}
.displayText=${this.publicId}
.showVisibilityToggle=${false}
></copy-button>
`
: undefined,
});
}
protected renderBody() {
return html`
<div class="px-3 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-7">
${this.renderProfile()}
</div>
`;
}
private renderProfile() {
if (this.loading) {
return this.renderLoadingSpinner(translateText("player_profile.loading"));
}
if (!this.publicId || !this.statsTree) {
return html`
<div class="flex flex-col items-center justify-center p-12 text-center">
<span class="text-4xl mb-4">📊</span>
<p class="text-white/40 text-sm">
${translateText("player_profile.not_found")}
</p>
</div>
`;
}
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
</div>
`;
}
protected onOpen(args?: Record<string, unknown>): 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<void> {
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<HTMLElement & { returnToMembers(): void }>("clan-modal")
?.returnToMembers();
}
}
}
+11 -1
View File
@@ -548,7 +548,17 @@ export class ClanDetailView extends LitElement {
),
)}
<div class="space-y-2">
${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,
}),
),
),
)}
</div>
${renderMemberPagination(
this.memberPage,
+19 -6
View File
@@ -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}
></copy-button>
</div>
<span
class="text-white/30 text-[10px] shrink-0 text-right whitespace-nowrap"
>${translateText("clan_modal.joined_date", {
date: formatClanDate(member.joinedAt),
})}</span
>
<div class="flex items-center gap-2 shrink-0">
<span
class="text-white/30 text-[10px] text-right whitespace-nowrap"
>${translateText("clan_modal.joined_date", {
date: formatClanDate(member.joinedAt),
})}</span
>
${onViewProfile
? html`<button
class="shrink-0 px-1.5 py-0.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 transition-colors"
title=${translateText("player_profile.view")}
aria-label=${translateText("player_profile.view")}
@click=${() => onViewProfile(member.publicId)}
>
📊
</button>`
: ""}
</div>
</div>
</div>
</div>