mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 06:03:05 +00:00
feat(client): account usernames + verified badge on player-facing lists (#4653)
## Summary Client side of openfrontio/infra#436 — every player-facing GET now returns the player's **account username** next to their `publicId`, and this PR renders it everywhere, with a blue verified check for premium/indefinite bare-name holders. ### Schemas (all additive, `.nullable().optional()` — no deploy-order constraint) - `FriendEntry`, `PlayerProfile`: `username` - `ClanMember`, `ClanJoinRequest`: `username`; `ClanBan`: `username` + `bannedByUsername` - `RankedLeaderboardEntry`: `accountUsername` (the existing session `username` field is untouched) ### `<player-name>` element One component owns player-identity rendering on account surfaces: - displays `username ?? publicId` - click-to-copy copies the **username when set**, the publicId otherwise; `copyText` overrides (profile modal copies the share URL) - `onNameClick` replaces copying with an action (clan member rows + leaderboard rows open the player's profile); `nameClass` overrides the chip styling so leaderboard names keep their original bold look - appends the verified check (same mark as the username-row toggle) when `isVerifiedUsername(username)` Used by: friends list + requests, all clan member/request/ban rows (bans badge both the banned player and the issuing officer), ranked leaderboard rows + sticky self row (showing `accountUsername ?? public_id` — the session name is deliberately dropped ahead of its API removal), profile modal header. ### Verified inference No API flag needed: account-username bases can never contain dots, so a dotless display name **is** the bare-name claim (premium/indefinite). `TEMPORARY####` server renames are excluded, matching the play-toggle eligibility rule. The leaderboard derives the badge from `accountUsername` only — session names are free-form and would false-positive. Also: clan member/request/ban search filters now match usernames, not just publicIds, and sending a friend request refetches the request lists instead of inserting the raw input (which may be a username, not a publicId). ## Test plan - [x] `npm test` — full suite green (2087 + 176 server) - [x] New schema tests: each field's set / null / absent states; `accountUsername` coexists with session `username` - [x] `isVerifiedUsername` unit tests (bare / suffixed / null / TEMPORARY) - [x] tsc, ESLint, Prettier clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,7 @@
|
||||
"no_stats": "No stats available yet. Play some games to start tracking.",
|
||||
"not_found": "Not Found",
|
||||
"or": "OR",
|
||||
"public_player_id": "Public Player ID:",
|
||||
"public_player_id": "Public ID:",
|
||||
"reactivate_subscription": "Reactivate",
|
||||
"recovery_email_sent": "Recovery email sent to {email}",
|
||||
"reward_daily": "Daily subscription reward",
|
||||
@@ -544,7 +544,7 @@
|
||||
"no_friends": "You haven't added any friends yet.",
|
||||
"outgoing": "Outgoing",
|
||||
"pending_requests": "Pending Requests",
|
||||
"public_id_placeholder": "Enter their public player ID",
|
||||
"public_id_placeholder": "Enter their username or player ID",
|
||||
"remove": "Remove",
|
||||
"request_accepted": "Friend request accepted",
|
||||
"request_auto_accepted": "Friend request accepted — you are now friends",
|
||||
@@ -1624,6 +1624,7 @@
|
||||
"too_long": "Username must not exceed {max} characters.",
|
||||
"too_short": "Username must be at least {min} characters long.",
|
||||
"verified_heading": "Verified name",
|
||||
"verified_player": "Verified player",
|
||||
"verified_sub_required": "You need an active subscription to play under your verified name.",
|
||||
"verified_sub_required_confirm": "View store",
|
||||
"verified_toggle": "Verified"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { html } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import type { PlayerStatsTree } from "../core/ApiSchemas";
|
||||
import { isVerifiedUsername, type PlayerStatsTree } from "../core/ApiSchemas";
|
||||
import { fetchPublicPlayerProfile } from "./Api";
|
||||
import "./components/baseComponents/stats/PlayerStatsTree";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CopyButton";
|
||||
import "./components/PlayerName";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { verifiedBadge } from "./components/ui/VerifiedBadge";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
/** Build a shareable profile URL for a publicId. */
|
||||
@@ -18,9 +19,10 @@ export class PlayerProfileModal extends BaseModal {
|
||||
protected routerName = "profile";
|
||||
|
||||
@state() private publicId: string | null = null;
|
||||
@state() private username: string | null = null;
|
||||
@state() private statsTree: PlayerStatsTree | null = null;
|
||||
@state() private loading = false;
|
||||
private openedFrom: "clan" | null = null;
|
||||
private openedFrom: "clan" | "leaderboard" | null = null;
|
||||
|
||||
protected modalConfig() {
|
||||
return { maxWidth: "960px" };
|
||||
@@ -29,17 +31,28 @@ export class PlayerProfileModal extends BaseModal {
|
||||
protected renderHeaderSlot() {
|
||||
return modalHeader({
|
||||
title: translateText("player_profile.title"),
|
||||
// The account username takes over the title when set — not uppercased
|
||||
// like the default title, since name casing is meaningful — and the
|
||||
// right chip then always shows the publicId.
|
||||
titleContent: this.username
|
||||
? html`<span
|
||||
class="text-white text-xl lg:text-2xl font-bold tracking-wide break-words hyphens-auto min-w-0 inline-flex items-center gap-2"
|
||||
>
|
||||
${this.username}
|
||||
${isVerifiedUsername(this.username)
|
||||
? verifiedBadge("w-5 h-5")
|
||||
: nothing}
|
||||
</span>`
|
||||
: undefined,
|
||||
onBack: () => this.back(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: this.publicId
|
||||
? html`
|
||||
<copy-button
|
||||
compact
|
||||
<player-name
|
||||
class="shrink-0"
|
||||
.publicId=${this.publicId}
|
||||
.copyText=${playerProfileUrl(this.publicId)}
|
||||
.displayText=${this.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
></copy-button>
|
||||
></player-name>
|
||||
`
|
||||
: undefined,
|
||||
});
|
||||
@@ -82,6 +95,7 @@ export class PlayerProfileModal extends BaseModal {
|
||||
? args.publicID
|
||||
: null;
|
||||
this.publicId = publicId;
|
||||
this.username = null;
|
||||
this.statsTree = null;
|
||||
this.loading = publicId !== null;
|
||||
if (publicId !== null) {
|
||||
@@ -95,10 +109,12 @@ export class PlayerProfileModal extends BaseModal {
|
||||
if (this.publicId !== publicId) return;
|
||||
this.loading = false;
|
||||
this.statsTree = profile === false ? null : profile.stats;
|
||||
this.username = profile === false ? null : (profile.username ?? null);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.publicId = null;
|
||||
this.username = null;
|
||||
this.statsTree = null;
|
||||
this.loading = false;
|
||||
this.openedFrom = null;
|
||||
@@ -109,6 +125,11 @@ export class PlayerProfileModal extends BaseModal {
|
||||
this.open({ publicID: publicId });
|
||||
}
|
||||
|
||||
public openFromLeaderboard(publicId: string): void {
|
||||
this.openedFrom = "leaderboard";
|
||||
this.open({ publicID: publicId });
|
||||
}
|
||||
|
||||
private back(): void {
|
||||
const openedFrom = this.openedFrom;
|
||||
this.close();
|
||||
@@ -116,6 +137,10 @@ export class PlayerProfileModal extends BaseModal {
|
||||
document
|
||||
.querySelector<HTMLElement & { returnToMembers(): void }>("clan-modal")
|
||||
?.returnToMembers();
|
||||
} else if (openedFrom === "leaderboard") {
|
||||
document
|
||||
.querySelector<HTMLElement & { open(): void }>("leaderboard-modal")
|
||||
?.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sendFriendRequest,
|
||||
} from "../FriendsApi";
|
||||
import { showToast, translateText } from "../Utils";
|
||||
import "./CopyButton";
|
||||
import "./PlayerName";
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
@@ -116,10 +116,16 @@ export class FriendsList extends LitElement {
|
||||
await this.loadAll();
|
||||
} else {
|
||||
showToast(translateText("friends.request_sent"), "green");
|
||||
this.outgoing = [
|
||||
...this.outgoing,
|
||||
{ publicId: target, createdAt: new Date().toISOString() },
|
||||
];
|
||||
// Refetch rather than inserting the raw input: the target may have
|
||||
// been typed as a username, and the server-returned entry carries
|
||||
// the real publicId + account username (so the verified check shows).
|
||||
// If the refetch fails the list is just stale until the next load —
|
||||
// never insert `target`, which may not be a publicId.
|
||||
const requests = await fetchFriendRequests();
|
||||
if (requests) {
|
||||
this.incoming = requests.incoming;
|
||||
this.outgoing = requests.outgoing;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.actionPending = false;
|
||||
@@ -261,7 +267,7 @@ export class FriendsList extends LitElement {
|
||||
}}
|
||||
class="flex-1 px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-mono text-sm"
|
||||
placeholder=${translateText("friends.public_id_placeholder")}
|
||||
maxlength="22"
|
||||
maxlength="25"
|
||||
?disabled=${this.actionPending}
|
||||
/>
|
||||
<button
|
||||
@@ -325,13 +331,10 @@ export class FriendsList extends LitElement {
|
||||
class="flex items-center gap-3 bg-white/5 rounded-lg border border-white/10 p-3"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${entry.publicId}
|
||||
.displayText=${entry.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<player-name
|
||||
.username=${entry.username}
|
||||
.publicId=${entry.publicId}
|
||||
></player-name>
|
||||
<div class="text-white/30 text-[10px] mt-0.5">
|
||||
${this.formatDate(entry.createdAt)}
|
||||
</div>
|
||||
@@ -400,13 +403,10 @@ export class FriendsList extends LitElement {
|
||||
class="flex items-center gap-3 bg-white/5 rounded-lg border border-white/10 p-3"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${f.publicId}
|
||||
.displayText=${f.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<player-name
|
||||
.username=${f.username}
|
||||
.publicId=${f.publicId}
|
||||
></player-name>
|
||||
<div class="text-white/30 text-[10px] mt-0.5">
|
||||
${translateText("friends.friends_since", {
|
||||
date: this.formatDate(f.createdAt),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { isVerifiedUsername } from "../../core/ApiSchemas";
|
||||
import { translateText } from "../Utils";
|
||||
import "./CopyButton";
|
||||
import { verifiedBadge } from "./ui/VerifiedBadge";
|
||||
|
||||
/**
|
||||
* Standard rendering of a player identity on account surfaces (friends, clan
|
||||
* lists, leaderboards, profiles): the account username when set — with the
|
||||
* verified check when it's a bare-name claim (isVerifiedUsername) — falling
|
||||
* back to the publicId. Clicking copies the account username when set, the
|
||||
* publicId otherwise. `copyText` overrides the copy payload entirely (e.g. a
|
||||
* share URL); `onNameClick` replaces copying with an action (e.g. opening
|
||||
* the player's profile), styled as the same chip unless `nameClass`
|
||||
* overrides it.
|
||||
*/
|
||||
@customElement("player-name")
|
||||
export class PlayerName extends LitElement {
|
||||
@property({ attribute: false }) username: string | null | undefined = null;
|
||||
@property({ type: String }) publicId = "";
|
||||
// Copy payload override (e.g. a share URL).
|
||||
@property({ type: String }) copyText = "";
|
||||
// When set, clicking the name runs this instead of copying.
|
||||
@property({ attribute: false }) onNameClick: (() => void) | null = null;
|
||||
// Styling override for the clickable name (e.g. leaderboard rows keep
|
||||
// their original bold look instead of the publicId-chip look).
|
||||
@property({ type: String }) nameClass = "";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
const displayName = this.username ?? this.publicId;
|
||||
const copyText =
|
||||
this.copyText !== "" ? this.copyText : (this.username ?? this.publicId);
|
||||
// inline-flex so the element sits on one line with inline siblings
|
||||
// (dates, role chips) while keeping the badge glued to the name.
|
||||
return html`
|
||||
<span class="inline-flex items-center gap-1.5 min-w-0 max-w-full">
|
||||
${this.onNameClick
|
||||
? html`<button
|
||||
type="button"
|
||||
class=${this.nameClass !== ""
|
||||
? this.nameClass
|
||||
: "text-xs text-white/60 font-mono bg-white/5 px-2 py-0.5 rounded border border-white/5 hover:bg-white/10 hover:text-white transition-colors"}
|
||||
title=${translateText("player_profile.view")}
|
||||
aria-label=${translateText("player_profile.view")}
|
||||
@click=${() => this.onNameClick?.()}
|
||||
>
|
||||
${displayName}
|
||||
</button>`
|
||||
: html`<copy-button
|
||||
compact
|
||||
.copyText=${copyText}
|
||||
.displayText=${displayName}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>`}
|
||||
${isVerifiedUsername(this.username) ? verifiedBadge() : nothing}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { type ClanBan, fetchClanBans, unbanClanMember } from "../../ClanApi";
|
||||
import { translateText } from "../../Utils";
|
||||
import "../CopyButton";
|
||||
import "../PlayerName";
|
||||
import {
|
||||
formatClanDate,
|
||||
renderLoadingSpinner,
|
||||
@@ -94,9 +94,13 @@ export class ClanBansView extends LitElement {
|
||||
|
||||
const totalPages = Math.ceil(this.bansTotal / this.bansLimit);
|
||||
const filtered = this.memberSearch
|
||||
? this.bans.filter((b) =>
|
||||
b.publicId.toLowerCase().includes(this.memberSearch.toLowerCase()),
|
||||
)
|
||||
? this.bans.filter((b) => {
|
||||
const q = this.memberSearch.toLowerCase();
|
||||
return (
|
||||
b.publicId.toLowerCase().includes(q) ||
|
||||
(b.username?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
})
|
||||
: this.bans;
|
||||
|
||||
return html`
|
||||
@@ -146,23 +150,17 @@ export class ClanBansView extends LitElement {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${ban.publicId}
|
||||
.displayText=${ban.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<player-name
|
||||
.username=${ban.username}
|
||||
.publicId=${ban.publicId}
|
||||
></player-name>
|
||||
<span class="text-white/30 text-xs shrink-0"
|
||||
>${translateText("clan_modal.banned_by_label")}</span
|
||||
>
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${ban.bannedBy}
|
||||
.displayText=${ban.bannedBy}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<player-name
|
||||
.username=${ban.bannedByUsername}
|
||||
.publicId=${ban.bannedBy}
|
||||
></player-name>
|
||||
<span class="text-white/30 text-xs shrink-0"
|
||||
>${formatClanDate(ban.createdAt)}</span
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "../../ClanApi";
|
||||
import { translateText } from "../../Utils";
|
||||
import "../ConfirmDialog";
|
||||
import "../CopyButton";
|
||||
import "../PlayerName";
|
||||
import {
|
||||
type ClanRole,
|
||||
defaultOrderForSort,
|
||||
@@ -554,13 +554,10 @@ export class ClanManageView extends LitElement {
|
||||
>
|
||||
${renderRoleIcon(member.role)}
|
||||
</div>
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${member.publicId}
|
||||
.displayText=${member.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<player-name
|
||||
.username=${member.username}
|
||||
.publicId=${member.publicId}
|
||||
></player-name>
|
||||
<span class="text-white/30 text-[10px] whitespace-nowrap">
|
||||
${translateText("clan_modal.joined_date", {
|
||||
date: formatClanDate(member.joinedAt),
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
fetchClanRequests,
|
||||
} from "../../ClanApi";
|
||||
import { translateText } from "../../Utils";
|
||||
import "../CopyButton";
|
||||
import "../PlayerName";
|
||||
import {
|
||||
filterRequestsBySearch,
|
||||
formatClanDate,
|
||||
@@ -153,13 +153,10 @@ export class ClanRequestsView extends LitElement {
|
||||
class="flex items-center gap-3 bg-white/5 rounded-xl border border-white/10 p-4"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${req.publicId}
|
||||
.displayText=${req.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<player-name
|
||||
.username=${req.username}
|
||||
.publicId=${req.publicId}
|
||||
></player-name>
|
||||
<span class="text-white/30 text-[10px]">
|
||||
${translateText("clan_modal.requested_on", {
|
||||
tag: this.selectedClan?.tag ?? this.clanTag,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ClanMemberStats,
|
||||
} from "../../ClanApi";
|
||||
import { showToast, translateText } from "../../Utils";
|
||||
import "../PlayerName";
|
||||
import "./ClanStatsBreakdown";
|
||||
export { renderLoadingSpinner } from "../BaseModal";
|
||||
export { showToast };
|
||||
@@ -400,13 +401,14 @@ export function renderMemberRow(
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${member.publicId}
|
||||
.displayText=${member.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<player-name
|
||||
.username=${member.username}
|
||||
.publicId=${member.publicId}
|
||||
.nameClass=${"font-bold text-blue-300 truncate text-base hover:underline"}
|
||||
.onNameClick=${onViewProfile
|
||||
? () => onViewProfile(member.publicId)
|
||||
: null}
|
||||
></player-name>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
@@ -415,16 +417,6 @@ export function renderMemberRow(
|
||||
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>
|
||||
@@ -442,7 +434,9 @@ export function filterMembersBySearch(
|
||||
const q = search.toLowerCase();
|
||||
return members.filter(
|
||||
(m) =>
|
||||
m.publicId.toLowerCase().includes(q) || m.role.toLowerCase().includes(q),
|
||||
m.publicId.toLowerCase().includes(q) ||
|
||||
m.role.toLowerCase().includes(q) ||
|
||||
(m.username?.toLowerCase().includes(q) ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -452,5 +446,9 @@ export function filterRequestsBySearch(
|
||||
): ClanJoinRequest[] {
|
||||
if (!search) return requests;
|
||||
const q = search.toLowerCase();
|
||||
return requests.filter((r) => r.publicId.toLowerCase().includes(q));
|
||||
return requests.filter(
|
||||
(r) =>
|
||||
r.publicId.toLowerCase().includes(q) ||
|
||||
(r.username?.toLowerCase().includes(q) ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../../ClanApi";
|
||||
import { translateText } from "../../Utils";
|
||||
import "../ConfirmDialog";
|
||||
import "../CopyButton";
|
||||
import "../PlayerName";
|
||||
import {
|
||||
filterMembersBySearch,
|
||||
renderLoadingSpinner,
|
||||
@@ -169,13 +169,10 @@ export class ClanTransferView extends LitElement {
|
||||
${renderRoleIcon(m.role)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<copy-button
|
||||
compact
|
||||
.copyText=${m.publicId}
|
||||
.displayText=${m.publicId}
|
||||
.showVisibilityToggle=${false}
|
||||
.showCopyIcon=${false}
|
||||
></copy-button>
|
||||
<player-name
|
||||
.username=${m.username}
|
||||
.publicId=${m.publicId}
|
||||
></player-name>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlayerLeaderboardEntry } from "../../../core/ApiSchemas";
|
||||
import { RankedType } from "../../../core/game/Game";
|
||||
import { fetchPlayerLeaderboard, getUserMe } from "../../Api";
|
||||
import { translateText } from "../../Utils";
|
||||
import "../PlayerName";
|
||||
|
||||
@customElement("leaderboard-player-list")
|
||||
export class LeaderboardPlayerList extends LitElement {
|
||||
@@ -73,7 +74,7 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
].map((entry) => ({
|
||||
rank: entry.rank,
|
||||
playerId: entry.public_id,
|
||||
username: entry.username,
|
||||
accountUsername: entry.accountUsername ?? null,
|
||||
clanTag: entry.clanTag ?? undefined,
|
||||
elo: entry.elo,
|
||||
games: entry.total,
|
||||
@@ -140,6 +141,16 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
this.schedulePlayerFillCheck();
|
||||
}
|
||||
|
||||
// Same handoff as the clan views: the profile modal's back button reopens
|
||||
// the leaderboard (its loaded pages survive the close).
|
||||
private openProfile(publicId: string) {
|
||||
document
|
||||
.querySelector<
|
||||
HTMLElement & { openFromLeaderboard(publicId: string): void }
|
||||
>("player-profile-modal")
|
||||
?.openFromLeaderboard(publicId);
|
||||
}
|
||||
|
||||
// TODO: consider IntersectionObserver for better visibility detection?
|
||||
private isVisible() {
|
||||
return this.isConnected && this.getClientRects().length > 0;
|
||||
@@ -248,9 +259,12 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
${player.clanTag}
|
||||
</div>`
|
||||
: ""}
|
||||
<span class="font-bold text-blue-300 truncate text-base"
|
||||
>${player.username}</span
|
||||
>
|
||||
<player-name
|
||||
.username=${player.accountUsername}
|
||||
.publicId=${player.playerId}
|
||||
.nameClass=${"font-bold text-blue-300 truncate text-base hover:underline"}
|
||||
.onNameClick=${() => this.openProfile(player.playerId)}
|
||||
></player-name>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right">
|
||||
@@ -440,9 +454,13 @@ export class LeaderboardPlayerList extends LitElement {
|
||||
${this.currentUserEntry.clanTag}
|
||||
</div>`
|
||||
: ""}
|
||||
<span class="font-bold text-white text-base"
|
||||
>${this.currentUserEntry.username}</span
|
||||
>
|
||||
<player-name
|
||||
.username=${this.currentUserEntry.accountUsername}
|
||||
.publicId=${this.currentUserEntry.playerId}
|
||||
.nameClass=${"font-bold text-white text-base hover:underline"}
|
||||
.onNameClick=${() =>
|
||||
this.openProfile(this.currentUserEntry!.playerId)}
|
||||
></player-name>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end w-20">
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
// The same blue check-circle as the UsernameInput verified toggle, sized for
|
||||
// inline placement next to a player name in lists. Callers gate on
|
||||
// isVerifiedUsername(accountUsername) — never on session/free-form names.
|
||||
export function verifiedBadge(sizeClass = "w-4 h-4"): TemplateResult {
|
||||
return html`<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="${sizeClass} text-blue-400 shrink-0"
|
||||
role="img"
|
||||
aria-label=${translateText("username.verified_player")}
|
||||
>
|
||||
<title>${translateText("username.verified_player")}</title>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor"></circle>
|
||||
<path
|
||||
d="M7.5 12.5l3 3 6-6.5"
|
||||
stroke="white"
|
||||
stroke-width="2.2"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>`;
|
||||
}
|
||||
+32
-1
@@ -128,6 +128,23 @@ export function isTemporaryUsername(base: string | null | undefined): boolean {
|
||||
return typeof base === "string" && /^TEMPORARY\d{4}$/.test(base);
|
||||
}
|
||||
|
||||
// Whether a pre-rendered account username belongs to a verified player
|
||||
// (premium/indefinite bare-name claim holder). The server renders everyone
|
||||
// else as "base.suffix" and bases can never contain dots
|
||||
// (AccountUsernameSchema), so a dotless name IS the bare display — no
|
||||
// dedicated flag needed on list endpoints. TEMPORARY#### server renames are
|
||||
// bare too but aren't a chosen name, so they don't get the badge (matches
|
||||
// the verified-name play toggle's eligibility rule).
|
||||
export function isVerifiedUsername(
|
||||
username: string | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
typeof username === "string" &&
|
||||
!username.includes(".") &&
|
||||
!isTemporaryUsername(username)
|
||||
);
|
||||
}
|
||||
|
||||
export const UserMeResponseSchema = z.object({
|
||||
user: z.object({
|
||||
discord: DiscordUserSchema.optional(),
|
||||
@@ -263,6 +280,11 @@ export type PlayerStatsTree = z.infer<typeof PlayerStatsTreeSchema>;
|
||||
export const PlayerProfileSchema = z.object({
|
||||
createdAt: z.iso.datetime(),
|
||||
user: DiscordUserSchema.optional(),
|
||||
// Account username, pre-rendered by the server (bare base or "base.suffix").
|
||||
// null = the player never set one. Render `username ?? publicId`; never
|
||||
// parse it — compare players by publicId only. Optional so responses from
|
||||
// an API without the field still parse.
|
||||
username: z.string().nullable().optional(),
|
||||
stats: PlayerStatsTreeSchema,
|
||||
});
|
||||
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
|
||||
@@ -321,7 +343,9 @@ export type PublicPlayerGamesResponse = z.infer<
|
||||
export const PlayerLeaderboardEntrySchema = z.object({
|
||||
rank: z.number(),
|
||||
playerId: z.string(),
|
||||
username: LeaderboardUsernameSchema,
|
||||
// Account username (null = never set). The leaderboard displays this or
|
||||
// the playerId — the per-session name is deliberately ignored.
|
||||
accountUsername: z.string().nullable().optional(),
|
||||
clanTag: RequiredClanTagSchema.nullable().optional(),
|
||||
flag: z.string().optional(),
|
||||
elo: z.number(),
|
||||
@@ -351,6 +375,10 @@ export const RankedLeaderboardEntrySchema = z.object({
|
||||
public_id: z.string(),
|
||||
user: DiscordUserSchema.nullable().optional(),
|
||||
username: LeaderboardUsernameSchema,
|
||||
// Account username (null = never set), unlike `username` which is the name
|
||||
// from the player's most recent ranked session. The client displays
|
||||
// `accountUsername ?? public_id` and ignores the session name.
|
||||
accountUsername: z.string().nullable().optional(),
|
||||
clanTag: RequiredClanTagSchema.nullable().optional(),
|
||||
});
|
||||
export type RankedLeaderboardEntry = z.infer<
|
||||
@@ -366,6 +394,9 @@ export type RankedLeaderboardResponse = z.infer<
|
||||
|
||||
export const FriendEntrySchema = z.object({
|
||||
publicId: z.string(),
|
||||
// Account username (null = never set), always the other party's. Render
|
||||
// `username ?? publicId`; identify players by publicId only.
|
||||
username: z.string().nullable().optional(),
|
||||
createdAt: z.iso.datetime(),
|
||||
});
|
||||
export type FriendEntry = z.infer<typeof FriendEntrySchema>;
|
||||
|
||||
@@ -128,6 +128,10 @@ export const ClanMemberSchema = z.object({
|
||||
role: z.enum(["leader", "officer", "member"]),
|
||||
joinedAt: z.iso.datetime(),
|
||||
publicId: z.string(),
|
||||
// Account username, pre-rendered by the server (null = never set). Render
|
||||
// `username ?? publicId`; identify players by publicId only. Optional so
|
||||
// responses from an API without the field still parse.
|
||||
username: z.string().nullable().optional(),
|
||||
stats: ClanMemberStatsSchema.optional(),
|
||||
});
|
||||
export type ClanMember = z.infer<typeof ClanMemberSchema>;
|
||||
@@ -143,6 +147,8 @@ export type ClanMembersResponse = z.infer<typeof ClanMembersResponseSchema>;
|
||||
|
||||
export const ClanJoinRequestSchema = z.object({
|
||||
publicId: z.string(),
|
||||
// Requester's account username (null = never set).
|
||||
username: z.string().nullable().optional(),
|
||||
createdAt: z.iso.datetime(),
|
||||
});
|
||||
export type ClanJoinRequest = z.infer<typeof ClanJoinRequestSchema>;
|
||||
@@ -157,7 +163,11 @@ export type ClanRequestsResponse = z.infer<typeof ClanRequestsResponseSchema>;
|
||||
|
||||
export const ClanBanSchema = z.object({
|
||||
publicId: z.string(),
|
||||
// Banned player's account username (null = never set).
|
||||
username: z.string().nullable().optional(),
|
||||
bannedBy: z.string(),
|
||||
// Account username of the officer who issued the ban.
|
||||
bannedByUsername: z.string().nullable().optional(),
|
||||
reason: z.string().max(200).nullable(),
|
||||
createdAt: z.iso.datetime(),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
ClaimAllRewardsResponseSchema,
|
||||
ClaimRewardResponseSchema,
|
||||
FriendEntrySchema,
|
||||
GoogleUser,
|
||||
GoogleUserSchema,
|
||||
isTemporaryUsername,
|
||||
isVerifiedUsername,
|
||||
PlayerGameModeFilterSchema,
|
||||
PlayerGameResultSchema,
|
||||
PlayerGameTypeFilterSchema,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
PublicPlayerGameSchema,
|
||||
PublicPlayerGamesResponseSchema,
|
||||
PutUsernameResponseSchema,
|
||||
RankedLeaderboardEntrySchema,
|
||||
RewardSchema,
|
||||
UserMeResponseSchema,
|
||||
} from "../src/core/ApiSchemas";
|
||||
@@ -69,6 +72,91 @@ describe("PlayerProfileSchema", () => {
|
||||
.success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a pre-rendered account username", () => {
|
||||
const result = PlayerProfileSchema.safeParse({
|
||||
...base,
|
||||
username: "bob.4821",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBe("bob.4821");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts username: null (player never set one)", () => {
|
||||
const result = PlayerProfileSchema.safeParse({ ...base, username: null });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a profile without username (older API)", () => {
|
||||
const result = PlayerProfileSchema.safeParse(base);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FriendEntrySchema", () => {
|
||||
const base = {
|
||||
publicId: "abc123",
|
||||
createdAt: "2024-01-15T12:00:00.000Z",
|
||||
};
|
||||
|
||||
it("accepts an entry with an account username", () => {
|
||||
const result = FriendEntrySchema.safeParse({
|
||||
...base,
|
||||
username: "bob.4821",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts username: null (friend never set one)", () => {
|
||||
const result = FriendEntrySchema.safeParse({ ...base, username: null });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an entry without username (older API)", () => {
|
||||
expect(FriendEntrySchema.safeParse(base).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RankedLeaderboardEntrySchema accountUsername", () => {
|
||||
const base = {
|
||||
rank: 1,
|
||||
elo: 1500,
|
||||
peakElo: 1600,
|
||||
wins: 10,
|
||||
losses: 5,
|
||||
total: 15,
|
||||
public_id: "abc123",
|
||||
username: "xX_Sniper_Xx",
|
||||
};
|
||||
|
||||
it("keeps accountUsername verbatim alongside the session username", () => {
|
||||
const result = RankedLeaderboardEntrySchema.safeParse({
|
||||
...base,
|
||||
accountUsername: "bob.4821",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.accountUsername).toBe("bob.4821");
|
||||
expect(result.data.username).toBe("xX_Sniper_Xx");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts accountUsername: null (player never set one)", () => {
|
||||
const result = RankedLeaderboardEntrySchema.safeParse({
|
||||
...base,
|
||||
accountUsername: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an entry without accountUsername (older API)", () => {
|
||||
expect(RankedLeaderboardEntrySchema.safeParse(base).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlayerGameModeFilterSchema", () => {
|
||||
@@ -567,3 +655,28 @@ describe("isTemporaryUsername", () => {
|
||||
expect(isTemporaryUsername(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVerifiedUsername", () => {
|
||||
it.each(["bob", "big_boss", "a-b_c9"])(
|
||||
"treats bare (dotless) display %s as verified",
|
||||
(name) => {
|
||||
expect(isVerifiedUsername(name)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["bob.4821", "big_boss.0042"])(
|
||||
"treats suffixed display %s as not verified",
|
||||
(name) => {
|
||||
expect(isVerifiedUsername(name)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("never verifies an unset username", () => {
|
||||
expect(isVerifiedUsername(null)).toBe(false);
|
||||
expect(isVerifiedUsername(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("never verifies a TEMPORARY#### server rename, even though it is bare", () => {
|
||||
expect(isVerifiedUsername("TEMPORARY1234")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,6 +241,7 @@ describe("LeaderboardModal", () => {
|
||||
total: 10,
|
||||
public_id: "player-1",
|
||||
username: "Alpha",
|
||||
accountUsername: "alpha.4821",
|
||||
clanTag: "[AAA]",
|
||||
},
|
||||
{
|
||||
@@ -252,6 +253,7 @@ describe("LeaderboardModal", () => {
|
||||
total: 10,
|
||||
public_id: "player-2",
|
||||
username: "Bravo",
|
||||
accountUsername: null,
|
||||
clanTag: null,
|
||||
},
|
||||
],
|
||||
@@ -269,7 +271,7 @@ describe("LeaderboardModal", () => {
|
||||
expect(playerData[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
playerId: "player-1",
|
||||
username: "Alpha",
|
||||
accountUsername: "alpha.4821",
|
||||
clanTag: "[AAA]",
|
||||
elo: 1200,
|
||||
games: 10,
|
||||
@@ -278,10 +280,12 @@ describe("LeaderboardModal", () => {
|
||||
winRate: 0.6,
|
||||
}),
|
||||
);
|
||||
// The session username ("Bravo") is deliberately ignored — display
|
||||
// falls back to the playerId when no account username is set.
|
||||
expect(playerData[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
playerId: "player-2",
|
||||
username: "Bravo",
|
||||
accountUsername: null,
|
||||
clanTag: undefined,
|
||||
winRate: 0.4,
|
||||
}),
|
||||
|
||||
@@ -153,6 +153,26 @@ describe("ClanMemberSchema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an account username, null, or absence (older API)", () => {
|
||||
const base = {
|
||||
role: "member",
|
||||
joinedAt: "2024-03-01T09:30:00.000Z",
|
||||
publicId: "abc123",
|
||||
};
|
||||
const named = ClanMemberSchema.safeParse({
|
||||
...base,
|
||||
username: "bob.4821",
|
||||
});
|
||||
expect(named.success).toBe(true);
|
||||
if (named.success) {
|
||||
expect(named.data.username).toBe("bob.4821");
|
||||
}
|
||||
expect(
|
||||
ClanMemberSchema.safeParse({ ...base, username: null }).success,
|
||||
).toBe(true);
|
||||
expect(ClanMemberSchema.safeParse(base).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects stats missing a bucket", () => {
|
||||
const result = ClanMemberSchema.safeParse({
|
||||
role: "member",
|
||||
@@ -183,6 +203,21 @@ describe("ClanJoinRequestSchema", () => {
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the requester's account username, null, or absence", () => {
|
||||
const base = {
|
||||
publicId: "player-xyz",
|
||||
createdAt: "2024-06-10T08:00:00.000Z",
|
||||
};
|
||||
expect(
|
||||
ClanJoinRequestSchema.safeParse({ ...base, username: "bob.4821" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
ClanJoinRequestSchema.safeParse({ ...base, username: null }).success,
|
||||
).toBe(true);
|
||||
expect(ClanJoinRequestSchema.safeParse(base).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClanBanSchema", () => {
|
||||
@@ -227,6 +262,23 @@ describe("ClanBanSchema", () => {
|
||||
const result = ClanBanSchema.safeParse({ ...validBan, bannedBy: null });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts account usernames for both the banned player and the officer", () => {
|
||||
const result = ClanBanSchema.safeParse({
|
||||
...validBan,
|
||||
username: null,
|
||||
bannedByUsername: "bigboss",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.username).toBeNull();
|
||||
expect(result.data.bannedByUsername).toBe("bigboss");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a ban without the username fields (older API)", () => {
|
||||
expect(ClanBanSchema.safeParse(validBan).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ClanGameResultSchema", () => {
|
||||
|
||||
Reference in New Issue
Block a user