Clan System Part 2 - UI (#3625)

## Description:

Continuation from #3276 

Adds the complete client-side clan UI as a Lit web component
(`<clan-modal>`), a typed API client with Zod-validated responses,
shared response schemas, and a reusable `<confirm-dialog>` component.


### New: `ClanModal.ts`

| View | What it does |
|------|-------------|
| **My Clans** | Lists joined clans + pending join requests (built from
`/users/@me`, no extra fetches) |
| **Browse** | Search by tag (min 3 chars), paginated results,
configurable per-page (10/25/50) |
| **Clan Detail** | Stats, paginated + searchable member list, role
badges, join/leave/request actions |
| **Manage** | Edit name (max 35 chars) + description, toggle
open/invite-only, disband |
| **Transfer** | Leadership transfer with member selector + confirmation
|
| **Requests** | Approve/deny join requests (leader/officer) |
| **Bans** | View and unban (leader/officer) |
| **My Requests** | View and withdraw outgoing requests |

### New: `ConfirmDialog.ts`

Reusable `<confirm-dialog>` Lit component — replaces native
`confirm()`/`prompt()` which are blocked or broken on mobile and
CrazyGames iframes. Supports danger/warning variants and an optional
textarea (used for ban reasons). Fires `confirm`/`cancel` events.

### New: `ClanApi.ts`

Typed API client covering all clan endpoints. Every response is
Zod-validated. Auth header is always last in the spread (can't be
overridden by callers). Unknown server error messages always fall back
to a generic client-side string — never displayed verbatim.

### New: `ClanApiSchemas.ts` (in `src/core/`)

Shared Zod schemas for clan API responses with max-length constraints on
`name` (35) and `description` (200). Lives in `core/` so it can be
consumed by both client code and the leaderboard table.

### Modified: `ApiSchemas.ts`

- Added `clans` and `clanRequests` arrays to `UserMeResponseSchema`
- Moved clan leaderboard schemas out to `ClanApiSchemas.ts`
- Renamed `LeaderboardClanTagSchema` → `RequiredClanTagSchema`

### Modified: `Api.ts`

- Added `invalidateUserMe()` to bust the cached `/users/me` response
after mutations
- Removed `fetchClanLeaderboard` (moved to `ClanApi.ts`)

### Tests

- `ClanModal.test.ts` — rendering, view navigation, user actions
- `ClanApiQueries.test.ts` — fetch functions, error handling, pagination
- `ClanApiMutations.test.ts` — join, leave, kick, ban, promote,
transfer, etc.
- `ClanApiBans.test.ts` — ban/unban calls and error paths
- `ClanApiSchemas.test.ts` — Zod schema validation edge cases
- `LeaderboardModal.test.ts` — updated imports

## Notable design decisions

- **Not-logged-in state** — shows "Sign in to join clans" instead of
false "no clans" empty state
- **Rate limit feedback** — reads `Retry-After` header and surfaces wait
time to the user

## 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
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

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

w.o.n

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
Ryan
2026-05-01 04:27:35 +01:00
committed by GitHub
parent 38bbef6ecf
commit df05d21fc2
32 changed files with 7018 additions and 102 deletions
-36
View File
@@ -2,8 +2,6 @@ import newsItemsFallback from "resources/news.json";
import { z } from "zod";
import type { NewsItem } from "../core/ApiSchemas";
import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
NewsItemSchema,
PlayerProfile,
PlayerProfileSchema,
@@ -236,40 +234,6 @@ export async function fetchGameById(
}
}
export async function fetchClanLeaderboard(): Promise<
ClanLeaderboardResponse | false
> {
try {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
console.warn(
"fetchClanLeaderboard: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn(
"fetchClanLeaderboard: Zod validation failed",
parsed.error.toString(),
);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchClanLeaderboard: request failed", err);
return false;
}
}
export async function fetchPlayerLeaderboard(
page: number,
): Promise<RankedLeaderboardResponse | "reached_limit" | false> {
+494
View File
@@ -0,0 +1,494 @@
import {
type ClanBansResponse,
ClanBansResponseSchema,
type ClanBrowseResponse,
ClanBrowseResponseSchema,
type ClanInfo,
ClanInfoSchema,
type ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
type ClanMembersResponse,
ClanMembersResponseSchema,
type ClanRequestsResponse,
ClanRequestsResponseSchema,
type ClanStats,
ClanStatsSchema,
JoinClanResponseSchema,
} from "../core/ClanApiSchemas";
import { getApiBase } from "./Api";
import { getAuthHeader } from "./Auth";
export type {
ClanBan,
ClanBansResponse,
ClanBrowseResponse,
ClanInfo,
ClanJoinRequest,
ClanMember,
ClanMembersResponse,
ClanMemberStats,
ClanMemberWL,
ClanRequestsResponse,
ClanStats,
} from "../core/ClanApiSchemas";
async function clanFetch(
path: string,
options?: RequestInit,
): Promise<Response> {
const url = `${getApiBase()}${path}`;
return fetch(url, {
...options,
headers: {
Accept: "application/json",
...options?.headers,
Authorization: await getAuthHeader(),
},
});
}
export async function fetchClanLeaderboard(): Promise<
ClanLeaderboardResponse | false
> {
try {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
console.warn(
"fetchClanLeaderboard: unexpected status",
res.status,
res.statusText,
);
return false;
}
const json = await res.json();
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn(
"fetchClanLeaderboard: Zod validation failed",
parsed.error.toString(),
);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchClanLeaderboard: request failed", err);
return false;
}
}
export async function fetchClanStats(tag: string): Promise<ClanStats | false> {
try {
const res = await fetch(
`${getApiBase()}/public/clan/${encodeURIComponent(tag)}`,
{ headers: { Accept: "application/json" } },
);
if (!res.ok) return false;
const json = await res.json();
const parsed = ClanStatsSchema.safeParse(json?.clan);
if (!parsed.success) {
console.warn("fetchClanStats: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch (err) {
console.warn("fetchClanStats: request failed", err);
return false;
}
}
export async function fetchClans(
search?: string,
page = 1,
limit = 20,
): Promise<ClanBrowseResponse | false> {
try {
const params = new URLSearchParams();
params.set("page", String(page));
params.set("limit", String(limit));
if (search && search.length >= 3) params.set("search", search);
const res = await clanFetch(`/clans?${params}`);
if (!res.ok) return false;
const json = await res.json();
const parsed = ClanBrowseResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchClans: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch {
return false;
}
}
export async function fetchClanDetail(tag: string): Promise<ClanInfo | false> {
try {
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}`);
if (!res.ok) return false;
const json = await res.json();
const parsed = ClanInfoSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchClanDetail: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch {
return false;
}
}
export type ClanMemberSort =
| "default"
| "winsTotal"
| "lossesTotal"
| "winsFfa"
| "lossesFfa"
| "winsTeam"
| "lossesTeam"
| "winsHvn"
| "lossesHvn"
| "winsRanked"
| "lossesRanked"
| "wins1v1"
| "losses1v1";
export type ClanMemberOrder = "asc" | "desc";
export async function fetchClanMembers(
tag: string,
page = 1,
limit = 20,
sort: ClanMemberSort = "default",
order?: ClanMemberOrder,
): Promise<ClanMembersResponse | false> {
try {
const params = new URLSearchParams();
params.set("page", String(page));
params.set("limit", String(limit));
if (sort !== "default") params.set("sort", sort);
if (order) params.set("order", order);
const res = await clanFetch(
`/clans/${encodeURIComponent(tag)}/members?${params}`,
);
if (!res.ok) return false;
const json = await res.json();
const parsed = ClanMembersResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchClanMembers: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch {
return false;
}
}
export async function joinClan(
tag: string,
): Promise<
{ status: "joined" | "requested" } | { error: string; reason?: string }
> {
try {
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/join`, {
method: "POST",
});
if (res.status === 409) {
const body = await res.json().catch(() => ({}));
const msg = (body as { message?: string }).message ?? "";
return {
error: msg.toLowerCase().includes("request")
? "clan_modal.error_request_pending"
: "clan_modal.error_already_member",
};
}
if (res.status === 429) {
return { error: "clan_modal.error_rate_limited_generic" };
}
if (res.status === 403) {
const body = await res.json().catch(() => ({}));
const b = body as { code?: string; reason?: string | null };
if (b.code === "BANNED") {
return {
error: b.reason
? "clan_modal.error_banned_reason"
: "clan_modal.error_banned",
...(b.reason ? { reason: b.reason } : {}),
};
}
return {
error: "clan_modal.error_failed",
};
}
if (!res.ok) {
return {
error: "clan_modal.error_failed",
};
}
const json = await res.json();
const parsed = JoinClanResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn("joinClan: Zod validation failed", parsed.error);
return { error: "clan_modal.error_failed" };
}
return parsed.data;
} catch {
return { error: "clan_modal.error_network" };
}
}
export async function leaveClan(
tag: string,
): Promise<true | { error: string }> {
try {
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/leave`, {
method: "POST",
});
if (!res.ok) {
return {
error: "clan_modal.error_failed",
};
}
return true;
} catch {
return { error: "clan_modal.error_network" };
}
}
export async function updateClan(
tag: string,
patch: { name?: string; description?: string; isOpen?: boolean },
): Promise<ClanInfo | { error: string }> {
try {
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) {
return {
error: "clan_modal.error_failed",
};
}
const json = await res.json();
const parsed = ClanInfoSchema.safeParse(json);
if (!parsed.success) {
console.warn("updateClan: Zod validation failed", parsed.error);
return { error: "clan_modal.error_failed" };
}
return parsed.data;
} catch {
return { error: "clan_modal.error_network" };
}
}
export async function disbandClan(
tag: string,
): Promise<true | { error: string }> {
try {
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}`, {
method: "DELETE",
});
if (!res.ok) {
return {
error: "clan_modal.error_failed",
};
}
return true;
} catch {
return { error: "clan_modal.error_network" };
}
}
async function memberAction(
tag: string,
targetPublicId: string,
action: string,
): Promise<true | { error: string }> {
try {
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetPublicId }),
});
if (!res.ok) {
return { error: "clan_modal.error_failed" };
}
return true;
} catch {
return { error: "clan_modal.error_network" };
}
}
export const kickMember = (tag: string, targetPublicId: string) =>
memberAction(tag, targetPublicId, "kick");
export const promoteMember = (tag: string, targetPublicId: string) =>
memberAction(tag, targetPublicId, "promote");
export const demoteMember = (tag: string, targetPublicId: string) =>
memberAction(tag, targetPublicId, "demote");
export const transferLeadership = (tag: string, targetPublicId: string) =>
memberAction(tag, targetPublicId, "transfer");
export async function fetchClanRequests(
tag: string,
page = 1,
limit = 20,
): Promise<ClanRequestsResponse | false> {
try {
const params = new URLSearchParams();
params.set("page", String(page));
params.set("limit", String(limit));
const res = await clanFetch(
`/clans/${encodeURIComponent(tag)}/requests?${params}`,
);
if (!res.ok) return false;
const json = await res.json();
const parsed = ClanRequestsResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchClanRequests: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch {
return false;
}
}
export async function approveClanRequest(
tag: string,
targetPublicId: string,
): Promise<true | { error: string }> {
try {
const res = await clanFetch(
`/clans/${encodeURIComponent(tag)}/requests/approve`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetPublicId }),
},
);
if (!res.ok) {
return {
error: "clan_modal.error_failed",
};
}
return true;
} catch {
return { error: "clan_modal.error_network" };
}
}
export async function denyClanRequest(
tag: string,
targetPublicId: string,
): Promise<true | { error: string }> {
try {
const res = await clanFetch(
`/clans/${encodeURIComponent(tag)}/requests/deny`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetPublicId }),
},
);
if (!res.ok) {
return {
error: "clan_modal.error_failed",
};
}
return true;
} catch {
return { error: "clan_modal.error_network" };
}
}
export async function withdrawClanRequest(
tag: string,
): Promise<true | { error: string }> {
try {
const res = await clanFetch(
`/clans/${encodeURIComponent(tag)}/requests/withdraw`,
{ method: "POST" },
);
if (!res.ok) {
return {
error: "clan_modal.error_failed",
};
}
return true;
} catch {
return { error: "clan_modal.error_network" };
}
}
export async function banClanMember(
tag: string,
targetPublicId: string,
reason?: string,
): Promise<true | { error: string }> {
try {
const body: { targetPublicId: string; reason?: string } = {
targetPublicId,
};
if (reason) body.reason = reason;
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/ban`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
return { error: "clan_modal.error_failed" };
}
return true;
} catch {
return { error: "clan_modal.error_network" };
}
}
export async function unbanClanMember(
tag: string,
targetPublicId: string,
): Promise<true | { error: string }> {
try {
const res = await clanFetch(`/clans/${encodeURIComponent(tag)}/unban`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetPublicId }),
});
if (!res.ok) {
return { error: "clan_modal.error_failed" };
}
return true;
} catch {
return { error: "clan_modal.error_network" };
}
}
export async function fetchClanBans(
tag: string,
page = 1,
limit = 20,
): Promise<ClanBansResponse | false> {
try {
const params = new URLSearchParams();
params.set("page", String(page));
params.set("limit", String(limit));
const res = await clanFetch(
`/clans/${encodeURIComponent(tag)}/bans?${params}`,
);
if (!res.ok) return false;
const json = await res.json();
const parsed = ClanBansResponseSchema.safeParse(json);
if (!parsed.success) {
console.warn("fetchClanBans: Zod validation failed", parsed.error);
return false;
}
return parsed.data;
} catch {
return false;
}
}
+460
View File
@@ -0,0 +1,460 @@
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getUserMe, invalidateUserMe } from "./Api";
import { type ClanInfo, type ClanMember, type ClanStats } 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/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 { translateText } from "./Utils";
type Tab = "my-clans" | "browse";
type View =
| "list"
| "detail"
| "manage"
| "transfer"
| "requests"
| "bans"
| "my-requests";
@customElement("clan-modal")
export class ClanModal extends BaseModal {
@state() private activeTab: Tab = "my-clans";
@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<string, ClanRole>();
// 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;
stats: ClanStats | null;
} | null = null;
render() {
const content = this.renderInner();
if (this.inline) return content;
return html`
<o-modal
id="clan-modal"
title=""
?hideCloseButton=${true}
?inline=${this.inline}
hideHeader
>
${content}
</o-modal>
`;
}
protected onOpen(): void {
this.loadMyClans();
}
protected onClose(): void {
this.activeTab = "my-clans";
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
this.myRole = null;
this.browseCache = null;
this.detailCache = null;
}
private async loadMyClans() {
this.loading = true;
try {
const me = await getUserMe();
if (!this.isModalOpen) return;
if (!me || Object.keys(me.user).length === 0) {
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<string, ClanRole>();
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 html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
${this.renderLoadingSpinner()}
</div>
`;
}
if (this.view === "my-requests") {
return html`<clan-my-requests-view
.myPendingRequests=${this.myPendingRequests}
@navigate-back=${() => (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";
}}
></clan-my-requests-view>`;
}
if (this.selectedClanTag) {
if (this.view === "manage") {
return html`<clan-manage-view
.clanTag=${this.selectedClanTag}
.selectedClan=${this.selectedClan}
.myPublicId=${this.myPublicId}
.myRole=${this.myRole}
@navigate-detail=${() => (this.view = "detail")}
@navigate-bans=${() => (this.view = "bans")}
@navigate-transfer=${() => (this.view = "transfer")}
@clan-updated=${(e: CustomEvent<Partial<ClanInfo>>) => {
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.view = "list";
this.loadMyClans();
}}
></clan-manage-view>`;
}
if (this.view === "transfer") {
return html`<clan-transfer-view
.clanTag=${this.selectedClanTag}
.selectedClan=${this.selectedClan}
@navigate-back=${() => (this.view = "manage")}
@leadership-transferred=${() => {
this.loadMyClans().then(() =>
this.openDetail(this.selectedClanTag),
);
}}
></clan-transfer-view>`;
}
if (this.view === "requests") {
return html`<clan-requests-view
.clanTag=${this.selectedClanTag}
.selectedClan=${this.selectedClan}
@navigate-back=${() => (this.view = "detail")}
@request-approved=${() => {
if (this.selectedClan) {
this.selectedClan = {
...this.selectedClan,
memberCount: (this.selectedClan.memberCount ?? 0) + 1,
};
}
this.detailCache = null;
}}
></clan-requests-view>`;
}
if (this.view === "bans") {
return html`<clan-bans-view
.clanTag=${this.selectedClanTag}
@navigate-back=${() => (this.view = "manage")}
></clan-bans-view>`;
}
// Default: detail view
return html`<clan-detail-view
.clanTag=${this.selectedClanTag}
.cachedClan=${this.selectedClan}
.myPublicId=${this.myPublicId}
.myClanRoles=${this.myClanRoles}
.myPendingRequests=${this.myPendingRequests}
.cachedDetail=${this.detailCache?.tag === this.selectedClanTag
? this.detailCache
: null}
@navigate-back=${() => {
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
this.myRole = null;
this.detailCache = null;
}}
@detail-loaded=${(
e: CustomEvent<{
clan: ClanInfo;
myRole: ClanRole | null;
members: ClanMember[];
membersTotal: number;
pendingRequestCount: number;
stats: ClanStats | null;
}>,
) => {
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,
stats: e.detail.stats,
};
}}
@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.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.view = "list";
this.loadMyClans();
}}
@request-sent=${(e: CustomEvent<{ tag: string; name: string }>) => {
this.myPendingRequests = [
...this.myPendingRequests,
{
tag: e.detail.tag,
name: e.detail.name,
createdAt: new Date().toISOString(),
},
];
}}
></clan-detail-view>`;
}
// List view (tabs + my clans / browse)
return html`
<div class="${this.modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.close(),
ariaLabel: translateText("common.back"),
})}
${this.renderTabs()}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1">
${this.activeTab === "my-clans"
? this.renderMyClans()
: html`<clan-browse-view
.myClanRoles=${this.myClanRoles}
.myPendingRequests=${this.myPendingRequests}
.cachedState=${this.browseCache}
@browse-updated=${(e: CustomEvent<BrowseState>) => {
this.browseCache = e.detail;
}}
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
this.openDetail(e.detail.tag)}
></clan-browse-view>`}
</div>
</div>
`;
}
private openDetail(tag: string) {
this.selectedClanTag = tag;
this.view = "detail";
}
private renderTabs() {
const tabs: { key: Tab; label: string }[] = [
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
{ key: "browse", label: translateText("clan_modal.browse") },
];
return html`
<div class="flex border-b border-white/10 px-4 lg:px-6 gap-1">
${tabs.map(
(tab) => html`
<button
@click=${() => {
this.activeTab = tab.key;
this.view = "list";
this.selectedClan = null;
this.selectedClanTag = "";
if (tab.key === "my-clans") {
this.loadMyClans();
}
}}
class="px-4 py-3 text-sm font-bold uppercase tracking-wider transition-all relative
${this.activeTab === tab.key
? "text-aquarius"
: "text-white/40 hover:text-white/70"}"
>
${tab.label}
${this.activeTab === tab.key
? html`<div
class="absolute bottom-0 left-0 right-0 h-0.5 bg-malibu-blue"
></div>`
: ""}
</button>
`,
)}
</div>
`;
}
private renderMyClans() {
const hasClans = this.myClans.length > 0;
const hasRequests = this.myPendingRequests.length > 0;
if (!hasClans && !hasRequests) {
return html`
<div class="flex flex-col items-center justify-center p-12 text-center">
<p class="text-white/40 text-sm mb-4">
${translateText("clan_modal.no_clans")}
</p>
<button
@click=${() => (this.activeTab = "browse")}
class="px-6 py-2 text-sm font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-lg transition-all"
>
${translateText("clan_modal.browse")}
</button>
</div>
`;
}
return html`
<div class="p-4 lg:p-6 space-y-3">
${hasRequests ? this.renderPendingRequestsButton() : ""}
${this.myClans.map(
(clan) => html`
<clan-card
.clan=${clan}
.clanRole=${this.myClanRoles.get(clan.tag)}
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
this.openDetail(e.detail.tag)}
></clan-card>
`,
)}
</div>
`;
}
private renderPendingRequestsButton() {
const count = this.myPendingRequests.length;
return html`
<button
@click=${() => (this.view = "my-requests")}
class="w-full flex items-center justify-between bg-amber-500/10 hover:bg-amber-500/15 rounded-xl border border-amber-500/20 p-4 transition-all cursor-pointer group"
>
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="text-left">
<span class="text-amber-400 text-sm font-bold">
${translateText("clan_modal.pending_applications")}
</span>
<span class="text-amber-400/60 text-xs block">
${translateText("clan_modal.pending_requests_count", {
count,
})}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="px-2.5 py-1 text-xs font-bold rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
>
${count}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-amber-400/40 group-hover:text-amber-400/70 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
`;
}
}
+2
View File
@@ -20,6 +20,7 @@ import {
import "./AccountModal";
import { getUserMe } from "./Api";
import { userAuth } from "./Auth";
import "./ClanModal";
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
import { getPlayerCosmeticsRefs } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
@@ -826,6 +827,7 @@ class Client {
"leaderboard-button",
"token-login",
"matchmaking-modal",
"clan-modal",
"lang-selector",
"homepage-promos",
].forEach((tag) => {
+12
View File
@@ -678,6 +678,18 @@ export function getServerNow(
return localNowMs + serverTimeOffsetMs;
}
export function showToast(
message: string,
color: "red" | "green",
duration = 3500,
): void {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: { message, color, duration },
}),
);
}
export function getSecondsUntilServerTimestamp(
targetServerTimestampMs: number,
serverTimeOffsetMs: number,
+34 -33
View File
@@ -154,42 +154,43 @@ export abstract class BaseModal extends LitElement {
}
}
/**
* Renders a standardized loading spinner with optional custom message.
* Use this for consistent loading states across all modals.
*
* @param message - Optional loading message text. Defaults to no message.
* @param spinnerColor - Optional spinner color. Defaults to 'blue'.
* @returns TemplateResult of the loading UI
*/
protected renderLoadingSpinner(
message?: string,
spinnerColor: "blue" | "green" | "yellow" | "white" = "blue",
): TemplateResult {
const colorClasses = {
blue: "border-blue-500/30 border-t-blue-500",
green: "border-green-500/30 border-t-green-500",
yellow: "border-yellow-500/30 border-t-yellow-500",
white: "border-white/20 border-t-white",
};
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full min-h-[400px]"
>
<div
class="w-12 h-12 border-4 ${colorClasses[
spinnerColor
]} rounded-full animate-spin mb-4"
></div>
${message
? html`<p
class="text-white/60 font-medium tracking-wide animate-pulse"
>
${message}
</p>`
: ""}
</div>
`;
return renderLoadingSpinner(message, spinnerColor);
}
}
const spinnerColorClasses: Record<string, string> = {
blue: "border-blue-500/30 border-t-blue-500",
green: "border-green-500/30 border-t-green-500",
yellow: "border-yellow-500/30 border-t-yellow-500",
white: "border-white/20 border-t-white",
};
/**
* Renders a standardized loading spinner with optional custom message.
* Use this for consistent loading states across all modals.
*/
export function renderLoadingSpinner(
message?: string,
spinnerColor: "blue" | "green" | "yellow" | "white" = "blue",
): TemplateResult {
return html`
<div
class="flex flex-col items-center justify-center p-12 text-white h-full min-h-[400px]"
>
<div
class="w-12 h-12 border-4 ${spinnerColorClasses[
spinnerColor
]} rounded-full animate-spin mb-4"
></div>
${message
? html`<p class="text-white/60 font-medium tracking-wide animate-pulse">
${message}
</p>`
: ""}
</div>
`;
}
+129
View File
@@ -0,0 +1,129 @@
import { html, LitElement, render as litRender } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../Utils";
/**
* A reusable inline confirmation dialog.
*
* Usage:
* ```html
* <confirm-dialog
* .message=${"Are you sure?"}
* variant="danger"
* @confirm=${() => doThing()}
* @cancel=${() => {}}
* ></confirm-dialog>
* ```
*
* For ban-style flows, add a textarea:
* ```html
* <confirm-dialog
* .message=${"Ban this player?"}
* variant="warning"
* textareaPlaceholder="Reason (optional)"
* @confirm=${(e) => ban(e.detail.text)}
* @cancel=${() => {}}
* ></confirm-dialog>
* ```
*/
@customElement("confirm-dialog")
export class ConfirmDialog extends LitElement {
@property() message = "";
@property() variant: "danger" | "warning" = "danger";
@property() textareaPlaceholder = "";
@property({ type: Boolean }) disabled = false;
@state() private text = "";
private portal: HTMLDivElement | null = null;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.portal = document.createElement("div");
document.body.appendChild(this.portal);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.portal) {
litRender(html``, this.portal);
this.portal.remove();
this.portal = null;
}
}
render() {
if (this.portal) {
litRender(this.renderOverlay(), this.portal);
}
return html``;
}
private renderOverlay() {
const isDanger = this.variant === "danger";
const borderColor = isDanger ? "border-red-500/50" : "border-amber-500/50";
const cardBg = "bg-surface";
const textColor = isDanger ? "text-red-300" : "text-amber-300";
const btnClass = isDanger
? "bg-red-600 text-white hover:bg-red-700"
: "bg-amber-600 text-white hover:bg-amber-700";
return html`
<div
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80"
@click=${(e: Event) => {
if (e.target === e.currentTarget) this.handleCancel();
}}
>
<div
class="mx-4 w-full max-w-sm p-6 rounded-2xl border ${borderColor} ${cardBg} shadow-2xl"
>
<p class="text-sm font-medium ${textColor} mb-5">${this.message}</p>
${this.textareaPlaceholder
? html`<textarea
.value=${this.text}
@input=${(e: Event) =>
(this.text = (e.target as HTMLTextAreaElement).value)}
maxlength="200"
rows="2"
placeholder="${this.textareaPlaceholder}"
class="w-full px-3 py-2 mb-4 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-amber-500/50 text-sm resize-none"
></textarea>`
: ""}
<div class="flex gap-3">
<button
@click=${() => this.handleCancel()}
?disabled=${this.disabled}
class="flex-1 px-4 py-2.5 text-xs font-bold uppercase tracking-wider rounded-xl bg-white/5 text-white/60 border border-white/10 hover:bg-white/10 hover:text-white/80 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("common.cancel")}
</button>
<button
@click=${() => this.handleConfirm()}
?disabled=${this.disabled}
class="flex-1 px-4 py-2.5 text-xs font-bold uppercase tracking-wider rounded-xl ${btnClass} transition-all disabled:opacity-50 disabled:pointer-events-none border-0"
>
${translateText("common.confirm")}
</button>
</div>
</div>
</div>
`;
}
private handleConfirm() {
this.dispatchEvent(
new CustomEvent("confirm", { detail: { text: this.text } }),
);
this.text = "";
}
private handleCancel() {
this.dispatchEvent(new CustomEvent("cancel"));
this.text = "";
}
}
+5
View File
@@ -123,6 +123,11 @@ export class DesktopNavBar extends LitElement {
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-clan"
data-i18n="main.clans"
></button>
<div class="relative">
<button
class="nav-menu-item text-white/70 hover:text-malibu-blue font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-malibu-blue "
+5
View File
@@ -114,6 +114,11 @@ export class MobileNavBar extends LitElement {
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<button
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
data-page="page-clan"
data-i18n="main.clans"
></button>
<div
class="no-crazygames nav-menu-item flex items-center w-full cursor-pointer"
data-page="page-item-store"
+225
View File
@@ -0,0 +1,225 @@
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 { modalHeader } from "../ui/ModalHeader";
import {
formatClanDate,
modalContainerClass,
renderLoadingSpinner,
renderMemberSearchInput,
renderServerPagination,
showToast,
} from "./ClanShared";
@customElement("clan-bans-view")
export class ClanBansView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@state() private bans: ClanBan[] = [];
@state() private bansTotal = 0;
@state() private bansPage = 1;
@state() private bansLimit = 20;
@state() private memberActionPending = false;
@state() private loading = false;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
connectedCallback() {
super.connectedCallback();
this.loadBans(1);
}
disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
super.disconnectedCallback();
}
private async loadBans(page: number, showLoading = true) {
if (showLoading) this.loading = true;
else this.memberActionPending = true;
try {
const data = await fetchClanBans(this.clanTag, page);
if (!data) {
showToast(translateText("clan_modal.error_failed"), "red");
return;
}
if (data.results.length === 0 && page > 1) {
await this.loadBans(1, false);
return;
}
this.bans = data.results;
this.bansTotal = data.total;
this.bansLimit = data.limit;
this.bansPage = data.page;
} finally {
if (showLoading) this.loading = false;
else this.memberActionPending = false;
}
}
private async handleUnban(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await unbanClanMember(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
this.bans = this.bans.filter((b) => b.publicId !== publicId);
this.bansTotal--;
showToast(translateText("clan_modal.member_unbanned"), "green");
if (this.bans.length === 0 && this.bansPage > 1) {
await this.loadBans(this.bansPage - 1, false);
}
} finally {
this.memberActionPending = false;
}
}
private onSearchInput(e: Event) {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberSearchDebounce = setTimeout(() => {
this.memberSearch = (e.target as HTMLInputElement).value;
this.requestUpdate();
}, 200);
}
render() {
if (this.loading)
return html`<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.banned_players"),
onBack: () =>
this.dispatchEvent(
new CustomEvent("navigate-back", {
bubbles: true,
composed: true,
}),
),
ariaLabel: translateText("common.back"),
})}${renderLoadingSpinner()}
</div>`;
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;
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.banned_players"),
onBack: () =>
this.dispatchEvent(
new CustomEvent("navigate-back", {
bubbles: true,
composed: true,
}),
),
ariaLabel: translateText("common.back"),
rightContent: html`<span
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
>${this.bansTotal}</span
>`,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
${renderMemberSearchInput(
(e) => this.onSearchInput(e),
"clan_modal.search_members_placeholder",
)}
${filtered.length === 0
? html`<div
class="flex flex-col items-center justify-center p-12 text-center"
>
<p class="text-white/40 text-sm">
${translateText("clan_modal.no_bans")}
</p>
</div>`
: html`
<div class="space-y-3">
${filtered.map(
(ban) => html`
<div
class="bg-white/5 rounded-xl border border-white/10 p-4 space-y-2"
>
<div class="flex items-center gap-2">
<div
class="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center shrink-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</div>
<copy-button
compact
.copyText=${ban.publicId}
.displayText=${ban.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<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>
<span class="text-white/30 text-xs shrink-0"
>${formatClanDate(ban.createdAt)}</span
>
<div class="flex-1"></div>
<button
@click=${() => this.handleUnban(ban.publicId)}
?disabled=${this.memberActionPending}
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none shrink-0"
>
${translateText("clan_modal.unban")}
</button>
</div>
${ban.reason
? html`<div class="text-white/50 text-xs pl-10">
${translateText("clan_modal.ban_reason", {
reason: ban.reason,
})}
</div>`
: ""}
</div>
`,
)}
</div>
${totalPages > 1
? renderServerPagination(this.bansPage, totalPages, (p) =>
this.loadBans(p, false),
)
: ""}
`}
</div>
</div>
`;
}
}
@@ -0,0 +1,185 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { type ClanBrowseResponse, fetchClans } from "../../ClanApi";
import { translateText } from "../../Utils";
import "./ClanCard";
import { type ClanRole, renderLoadingSpinner } from "./ClanShared";
export interface BrowseState {
data: ClanBrowseResponse | null;
page: number;
query: string;
}
@customElement("clan-browse-view")
export class ClanBrowseView extends LitElement {
createRenderRoot() {
return this;
}
@property({ type: Object }) myClanRoles: Map<string, ClanRole> = new Map();
@property({ type: Array }) myPendingRequests: { tag: string }[] = [];
@property({ type: Object }) cachedState: BrowseState | null = null;
@state() private searchQuery = "";
@state() private browseData: ClanBrowseResponse | null = null;
@state() private browsePage = 1;
@state() private loading = false;
@state() private errorMsg = "";
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
private asyncGeneration = 0;
private emitState() {
this.dispatchEvent(
new CustomEvent("browse-updated", {
detail: {
data: this.browseData,
page: this.browsePage,
query: this.searchQuery,
} satisfies BrowseState,
bubbles: true,
composed: true,
}),
);
}
async loadBrowse() {
const gen = ++this.asyncGeneration;
this.loading = true;
this.errorMsg = "";
try {
const data = await fetchClans(
this.searchQuery || undefined,
this.browsePage,
);
if (gen !== this.asyncGeneration) return;
if (data === false) throw new Error("fetch failed");
this.browseData = data;
this.emitState();
} catch {
if (gen !== this.asyncGeneration) return;
this.errorMsg = translateText("clan_modal.error_loading");
} finally {
if (gen === this.asyncGeneration) this.loading = false;
}
}
private onSearchInput(e: Event) {
this.searchQuery = (e.target as HTMLInputElement).value;
if (this.searchDebounce) clearTimeout(this.searchDebounce);
this.searchDebounce = setTimeout(() => {
this.browsePage = 1;
this.loadBrowse();
}, 400);
}
connectedCallback() {
super.connectedCallback();
if (this.cachedState?.data) {
this.browseData = this.cachedState.data;
this.browsePage = this.cachedState.page;
this.searchQuery = this.cachedState.query;
} else {
this.loadBrowse();
}
}
disconnectedCallback() {
if (this.searchDebounce) clearTimeout(this.searchDebounce);
super.disconnectedCallback();
}
render() {
if (this.loading && !this.browseData)
return html`<div class="p-4 lg:p-6">${renderLoadingSpinner()}</div>`;
const totalPages = this.browseData
? Math.ceil(this.browseData.total / this.browseData.limit)
: 0;
const pendingTags = new Set(this.myPendingRequests.map((r) => r.tag));
const filtered = (this.browseData?.results ?? []).filter(
(clan) => !this.myClanRoles.has(clan.tag),
);
return html`
<div class="p-4 lg:p-6 space-y-4">
<div class="relative">
<input
type="text"
.value=${this.searchQuery}
@input=${(e: Event) => this.onSearchInput(e)}
class="w-full pl-10 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm"
placeholder="${translateText("clan_modal.search_placeholder")}"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-white/30 absolute left-3 top-1/2 -translate-y-1/2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
</div>
${this.errorMsg
? html`<p class="text-red-400 text-sm text-center py-4">
${this.errorMsg}
</p>`
: ""}
<div class="space-y-3">
${filtered.length === 0 && this.browseData
? html`<p class="text-white/40 text-sm text-center py-8">
${translateText("clan_modal.no_results")}
</p>`
: filtered.map(
(clan) =>
html`<clan-card
.clan=${clan}
?pending=${pendingTags.has(clan.tag)}
></clan-card>`,
)}
</div>
${totalPages > 1
? html`
<div class="flex items-center justify-center gap-2 pt-2">
<button
@click=${() => {
this.browsePage = Math.max(1, this.browsePage - 1);
this.loadBrowse();
}}
?disabled=${this.browsePage <= 1}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all ${this
.browsePage <= 1
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&lt;
</button>
<span class="text-xs text-white/50 font-medium">
${this.browsePage} / ${totalPages}
</span>
<button
@click=${() => {
this.browsePage = Math.min(totalPages, this.browsePage + 1);
this.loadBrowse();
}}
?disabled=${this.browsePage >= totalPages}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all ${this
.browsePage >= totalPages
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&gt;
</button>
</div>
`
: ""}
</div>
`;
}
}
+108
View File
@@ -0,0 +1,108 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { ClanInfo } from "../../ClanApi";
import { translateText } from "../../Utils";
import { translateClanRole } from "./ClanShared";
@customElement("clan-card")
export class ClanCard extends LitElement {
@property({ type: Object }) clan!: ClanInfo;
@property() clanRole?: string;
@property({ type: Boolean }) pending = false;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.style.display = "block";
}
private onClick() {
this.dispatchEvent(
new CustomEvent("clan-select", {
detail: { tag: this.clan.tag },
bubbles: true,
composed: true,
}),
);
}
private renderBadge() {
const base =
"text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0";
if (this.clanRole) {
const colors =
this.clanRole === "leader"
? "bg-amber-500/20 text-amber-400 border border-amber-500/30"
: "bg-malibu-blue/15 text-aquarius border border-malibu-blue/30";
return html`<span class="${base} ${colors}"
>${translateClanRole(this.clanRole)}</span
>`;
}
if (this.pending) {
return html`<span
class="${base} bg-amber-500/20 text-amber-400 border border-amber-500/30"
>${translateText("clan_modal.request_pending")}</span
>`;
}
if (this.clan.isOpen) {
return html`<span
class="${base} bg-green-500/20 text-green-400 border border-green-500/30"
>${translateText("clan_modal.open")}</span
>`;
}
return html`<span
class="${base} bg-red-500/20 text-red-400 border border-red-500/30"
>${translateText("clan_modal.invite_only")}</span
>`;
}
render() {
const clan = this.clan;
return html`
<button
@click=${() => this.onClick()}
class="w-full text-left bg-white/5 hover:bg-white/10 rounded-xl border border-white/10 hover:border-white/20 p-4 transition-all cursor-pointer group"
>
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-xl bg-gradient-to-br ${clan.isOpen
? "from-malibu-blue/20 to-aquarius/20"
: "from-amber-500/20 to-orange-500/20"} flex items-center justify-center border border-white/10 shrink-0"
>
<span class="text-white font-bold text-sm">${clan.tag}</span>
</div>
<div class="flex-1 min-w-0">
<span class="text-white font-bold truncate block"
>${clan.name}</span
>
<div class="flex items-center gap-4 mt-1 text-xs text-white/40">
<span
>${translateText("clan_modal.member_count", {
count: clan.memberCount ?? 0,
})}</span
>
</div>
</div>
${this.renderBadge()}
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-white/20 group-hover:text-white/50 transition-colors shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
`;
}
}
@@ -0,0 +1,540 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { invalidateUserMe } from "../../Api";
import {
type ClanInfo,
type ClanMember,
type ClanMemberOrder,
type ClanMemberSort,
type ClanStats,
fetchClanDetail,
fetchClanMembers,
fetchClanStats,
joinClan,
leaveClan,
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../ConfirmDialog";
import "../CopyButton";
import { modalHeader } from "../ui/ModalHeader";
import {
type ClanRole,
defaultOrderForSort,
filterMembersBySearch,
modalContainerClass,
renderClanWL,
renderLoadingSpinner,
renderMemberPagination,
renderMemberRow,
renderMemberSearchInput,
renderMemberSortControl,
renderStat,
showToast,
} from "./ClanShared";
@customElement("clan-detail-view")
export class ClanDetailView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@property() myPublicId: string | null = null;
@property({ type: Object }) myClanRoles: Map<string, ClanRole> = new Map();
@property({ type: Array }) myPendingRequests: {
tag: string;
name: string;
createdAt: string;
}[] = [];
@property({ type: Object }) cachedDetail: {
tag: string;
members: ClanMember[];
membersTotal: number;
pendingRequestCount: number;
stats: ClanStats | null;
} | null = null;
@property({ type: Object }) cachedClan: ClanInfo | null = null;
@state() private selectedClan: ClanInfo | null = null;
@state() private myRole: ClanRole | null = null;
@state() private members: ClanMember[] = [];
@state() private membersTotal = 0;
@state() private memberPage = 1;
@state() private membersPerPage = 10;
@state() private memberSort: ClanMemberSort = "default";
@state() private memberOrder: ClanMemberOrder = "asc";
@state() private pendingRequestCount = 0;
@state() private clanStats: ClanStats | null = null;
@state() private loading = false;
@state() private actionPending = false;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
private asyncGeneration = 0;
connectedCallback() {
super.connectedCallback();
if (this.cachedDetail && this.cachedDetail.tag === this.clanTag) {
this.restoreFromCache(this.cachedDetail);
} else if (this.clanTag) {
this.loadDetail();
}
}
private restoreFromCache(cache: NonNullable<typeof this.cachedDetail>) {
this.selectedClan = this.cachedClan;
this.members = cache.members;
this.membersTotal = cache.membersTotal;
this.pendingRequestCount = cache.pendingRequestCount;
this.clanStats = cache.stats;
this.memberPage = 1;
const knownRole = this.myClanRoles.get(this.clanTag);
this.myRole = knownRole ?? null;
}
disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
super.disconnectedCallback();
}
private async loadDetail() {
const gen = ++this.asyncGeneration;
this.loading = true;
this.myRole = null;
this.pendingRequestCount = 0;
this.memberSearch = "";
const isMember = this.myClanRoles.has(this.clanTag);
const [detail, membersRes, stats] = await Promise.all([
fetchClanDetail(this.clanTag),
isMember
? fetchClanMembers(
this.clanTag,
1,
this.membersPerPage,
this.memberSort,
this.memberOrder,
)
: Promise.resolve(false as const),
fetchClanStats(this.clanTag),
]);
if (gen !== this.asyncGeneration) return;
this.clanStats = stats || null;
this.loading = false;
if (!detail) {
showToast(translateText("clan_modal.failed_to_load_clan"), "red");
this.dispatchEvent(
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
);
return;
}
this.selectedClan = detail;
this.memberPage = 1;
if (membersRes) {
this.members = membersRes.results;
this.membersTotal = membersRes.total;
this.pendingRequestCount = membersRes.pendingRequests ?? 0;
const knownRole = this.myClanRoles.get(this.clanTag);
if (knownRole) {
this.myRole = knownRole;
} else {
const me = this.myPublicId
? membersRes.results.find((m) => m.publicId === this.myPublicId)
: null;
this.myRole = me ? me.role : null;
}
} else {
this.members = [];
this.membersTotal = 0;
this.myRole = null;
}
this.dispatchEvent(
new CustomEvent("detail-loaded", {
detail: {
clan: detail,
myRole: this.myRole,
members: this.members,
membersTotal: this.membersTotal,
pendingRequestCount: this.pendingRequestCount,
stats: this.clanStats,
},
bubbles: true,
composed: true,
}),
);
}
private async loadMemberPage(page: number) {
if (!this.selectedClan) return;
const res = await fetchClanMembers(
this.selectedClan.tag,
page,
this.membersPerPage,
this.memberSort,
this.memberOrder,
);
if (!res) return;
if (res.results.length === 0 && page > 1) {
await this.loadMemberPage(1);
return;
}
this.members = res.results;
this.membersTotal = res.total;
this.memberPage = page;
this.pendingRequestCount = res.pendingRequests ?? 0;
if (this.selectedClan.memberCount !== res.total) {
this.selectedClan = { ...this.selectedClan, memberCount: res.total };
}
}
private onSortChange(sort: ClanMemberSort) {
if (sort === this.memberSort) return;
this.memberSort = sort;
this.memberOrder = defaultOrderForSort(sort);
this.loadMemberPage(1);
}
private onOrderToggle() {
this.memberOrder = this.memberOrder === "asc" ? "desc" : "asc";
this.loadMemberPage(1);
}
private async handleJoin() {
if (!this.selectedClan || this.actionPending) return;
this.actionPending = true;
try {
const result = await joinClan(this.selectedClan.tag);
if ("error" in result) {
showToast(
result.reason
? translateText(result.error, { reason: result.reason })
: translateText(result.error),
"red",
);
return;
}
invalidateUserMe();
if (result.status === "joined") {
// Joining an open clan should immediately switch this detail page into
// member mode and refresh member-only data without requiring remount.
this.myRole = "member";
await this.loadMemberPage(1);
this.dispatchEvent(
new CustomEvent("clan-joined", {
detail: { tag: this.selectedClan.tag },
bubbles: true,
composed: true,
}),
);
} else {
this.dispatchEvent(
new CustomEvent("request-sent", {
detail: {
tag: this.selectedClan.tag,
name: this.selectedClan.name,
},
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.join_request_sent"), "green");
}
} finally {
this.actionPending = false;
}
}
private async handleLeave() {
if (!this.selectedClan || this.actionPending) return;
this.actionPending = true;
try {
const result = await leaveClan(this.selectedClan.tag);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
invalidateUserMe();
this.dispatchEvent(
new CustomEvent("clan-left", {
detail: { tag: this.selectedClan.tag },
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.left_clan"), "green");
} finally {
this.actionPending = false;
}
}
private onSearchInput(e: Event) {
const val = (e.target as HTMLInputElement).value;
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberSearchDebounce = setTimeout(() => {
this.memberSearch = val;
this.requestUpdate();
}, 200);
}
render() {
if (this.loading) {
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.title"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
})}
${renderLoadingSpinner()}
</div>
`;
}
const clan = this.selectedClan;
if (!clan) return "";
const isMember = this.myRole !== null;
const isLeader = this.myRole === "leader";
const isOfficer = this.myRole === "officer";
const canManageRequests = isLeader || isOfficer;
const hasPendingRequest = this.myPendingRequests.some(
(r) => r.tag === clan.tag,
);
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: clan.name,
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
rightContent: html`
<span
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
>
[${clan.tag}]
</span>
`,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
<div class="space-y-6">
<div class="bg-white/5 rounded-xl border border-white/10 p-5">
<p class="text-white/70 text-sm">
${clan.description ||
translateText("clan_modal.no_description")}
</p>
</div>
<div class="grid grid-cols-2 gap-3">
${renderStat(
translateText("clan_modal.members"),
`${clan.memberCount ?? 0}`,
)}
${renderStat(
translateText("clan_modal.status"),
clan.isOpen
? translateText("clan_modal.open")
: translateText("clan_modal.invite_only"),
)}
</div>
${this.clanStats ? renderClanWL(this.clanStats) : ""}
${canManageRequests && this.pendingRequestCount > 0
? this.renderRequestsButton()
: ""}
${isMember ? this.renderMembersList() : ""}
<div class="flex flex-wrap gap-3">
${this.renderActionButtons(
isMember,
isLeader,
isOfficer,
hasPendingRequest,
clan,
)}
</div>
</div>
</div>
</div>
`;
}
private renderRequestsButton() {
return html`
<button
@click=${() =>
this.dispatchEvent(
new CustomEvent("navigate-requests", {
bubbles: true,
composed: true,
}),
)}
class="w-full flex items-center justify-between bg-amber-500/10 hover:bg-amber-500/15 rounded-xl border border-amber-500/20 p-4 transition-all cursor-pointer group"
>
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
</div>
<div class="text-left">
<span class="text-amber-400 text-sm font-bold">
${translateText("clan_modal.join_requests")}
</span>
<span class="text-amber-400/60 text-xs block">
${translateText("clan_modal.pending_requests_count", {
count: this.pendingRequestCount,
})}
</span>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="px-2.5 py-1 text-xs font-bold rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30"
>
${this.pendingRequestCount}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-amber-400/40 group-hover:text-amber-400/70 transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
`;
}
private renderMembersList() {
const filtered = filterMembersBySearch(this.members, this.memberSearch);
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-5 space-y-3">
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
${translateText("clan_modal.members")}
</h3>
${renderMemberSearchInput(
(e: Event) => this.onSearchInput(e),
undefined,
renderMemberSortControl(
this.memberSort,
this.memberOrder,
(s) => this.onSortChange(s),
() => this.onOrderToggle(),
),
)}
<div class="space-y-2">
${filtered.map((m) => renderMemberRow(m, this.myPublicId))}
</div>
${renderMemberPagination(
this.memberPage,
this.membersTotal,
this.membersPerPage,
(p) => this.loadMemberPage(p),
(pp) => {
this.membersPerPage = pp;
this.loadMemberPage(1);
},
)}
</div>
`;
}
private renderActionButtons(
isMember: boolean,
isLeader: boolean,
isOfficer: boolean,
hasPendingRequest: boolean,
clan: ClanInfo,
) {
const buttons: ReturnType<typeof html>[] = [];
if (!isMember && hasPendingRequest) {
buttons.push(html`
<button
disabled
class="flex-1 px-6 py-3 text-sm font-bold text-white/40 uppercase tracking-wider bg-white/5 rounded-xl border border-white/10 cursor-not-allowed"
>
${translateText("clan_modal.request_pending")}
</button>
`);
} else if (!isMember && clan.isOpen) {
buttons.push(html`
<button
@click=${() => this.handleJoin()}
?disabled=${this.actionPending}
class="flex-1 px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-xl transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.join_clan")}
</button>
`);
} else if (!isMember && !clan.isOpen) {
buttons.push(html`
<button
@click=${() => this.handleJoin()}
?disabled=${this.actionPending}
class="flex-1 px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 rounded-xl transition-all shadow-lg hover:shadow-amber-900/40 border border-white/5 disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.request_invite")}
</button>
`);
}
if (isMember && !isLeader) {
buttons.push(html`
<button
@click=${() => this.handleLeave()}
?disabled=${this.actionPending}
class="flex-1 px-6 py-3 text-sm font-bold text-white/70 uppercase tracking-wider bg-red-600/30 hover:bg-red-600/50 rounded-xl transition-all border border-red-500/30 disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.leave_clan")}
</button>
`);
}
if (isLeader || isOfficer) {
buttons.push(html`
<button
@click=${() =>
this.dispatchEvent(
new CustomEvent("navigate-manage", {
bubbles: true,
composed: true,
}),
)}
class="flex-1 px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-white/10 hover:bg-white/15 rounded-xl transition-all border border-white/10"
>
${translateText("clan_modal.manage_clan")}
</button>
`);
}
return buttons;
}
private back() {
this.dispatchEvent(
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
);
}
}
@@ -0,0 +1,615 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { invalidateUserMe } from "../../Api";
import {
banClanMember,
type ClanInfo,
type ClanMember,
type ClanMemberOrder,
type ClanMemberSort,
demoteMember,
disbandClan,
fetchClanMembers,
kickMember,
promoteMember,
updateClan,
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../ConfirmDialog";
import "../CopyButton";
import { modalHeader } from "../ui/ModalHeader";
import {
type ClanRole,
defaultOrderForSort,
filterMembersBySearch,
formatClanDate,
modalContainerClass,
renderLoadingSpinner,
renderMemberPagination,
renderMemberSearchInput,
renderMemberSortControl,
renderRoleIcon,
showToast,
} from "./ClanShared";
@customElement("clan-manage-view")
export class ClanManageView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@property({ type: Object }) selectedClan: ClanInfo | null = null;
@property() myPublicId: string | null = null;
@property() myRole: ClanRole | null = null;
@state() private manageName = "";
@state() private manageDescription = "";
@state() private manageIsOpen = true;
@state() private saving = false;
@state() private members: ClanMember[] = [];
@state() private membersTotal = 0;
@state() private memberPage = 1;
@state() private membersPerPage = 10;
@state() private memberSort: ClanMemberSort = "default";
@state() private memberOrder: ClanMemberOrder = "asc";
@state() private memberActionPending = false;
@state() private loading = false;
@state() private confirmAction: "disband" | "kick" | "ban" | null = null;
@state() private confirmTargetId: string | null = null;
@state() private pendingRequestCount = 0;
@state() private actionPending = false;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
connectedCallback() {
super.connectedCallback();
if (this.selectedClan) {
this.manageName = this.selectedClan.name;
this.manageDescription = this.selectedClan.description ?? "";
this.manageIsOpen = this.selectedClan.isOpen ?? true;
}
this.loadMembers(1);
}
disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
super.disconnectedCallback();
}
private async loadMembers(page: number) {
if (this.members.length === 0) this.loading = true;
const res = await fetchClanMembers(
this.clanTag,
page,
this.membersPerPage,
this.memberSort,
this.memberOrder,
);
if (!res) {
this.loading = false;
return;
}
if (res.results.length === 0 && page > 1) {
await this.loadMembers(1);
return;
}
this.members = res.results;
this.membersTotal = res.total;
this.memberPage = page;
this.pendingRequestCount = res.pendingRequests ?? 0;
if (this.selectedClan && this.selectedClan.memberCount !== res.total) {
this.dispatchEvent(
new CustomEvent("clan-updated", {
detail: { memberCount: res.total },
bubbles: true,
composed: true,
}),
);
}
this.loading = false;
}
private async handleSaveSettings() {
const clan = this.selectedClan;
if (!clan) return;
const patch: { name?: string; description?: string; isOpen?: boolean } = {};
if (this.manageName !== clan.name) patch.name = this.manageName;
if ((this.manageDescription ?? "") !== (clan.description ?? ""))
patch.description = this.manageDescription;
if (this.manageIsOpen !== (clan.isOpen ?? true))
patch.isOpen = this.manageIsOpen;
if (Object.keys(patch).length === 0) return;
this.saving = true;
const result = await updateClan(this.clanTag, patch);
if ("error" in result) {
showToast(translateText(result.error), "red");
this.saving = false;
return;
}
this.dispatchEvent(
new CustomEvent("clan-updated", {
detail: {
name: result.name,
description: result.description,
isOpen: result.isOpen,
},
bubbles: true,
composed: true,
}),
);
this.saving = false;
showToast(translateText("clan_modal.settings_saved"), "green");
this.dispatchEvent(
new CustomEvent("navigate-detail", { bubbles: true, composed: true }),
);
}
private async handlePromote(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await promoteMember(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
await this.loadMembers(this.memberPage);
showToast(translateText("clan_modal.member_promoted"), "green");
} finally {
this.memberActionPending = false;
}
}
private async handleDemote(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await demoteMember(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
await this.loadMembers(this.memberPage);
showToast(translateText("clan_modal.member_demoted"), "green");
} finally {
this.memberActionPending = false;
}
}
private async handleKick(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await kickMember(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
await this.loadMembers(this.memberPage);
showToast(translateText("clan_modal.member_kicked"), "green");
} finally {
this.memberActionPending = false;
}
}
private async handleBan(publicId: string, reason: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await banClanMember(
this.clanTag,
publicId,
reason.trim().slice(0, 200) || undefined,
);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
await this.loadMembers(this.memberPage);
showToast(translateText("clan_modal.member_banned"), "green");
} finally {
this.memberActionPending = false;
}
}
private async handleDisband() {
if (this.actionPending) return;
this.actionPending = true;
try {
const result = await disbandClan(this.clanTag);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
invalidateUserMe();
this.dispatchEvent(
new CustomEvent("clan-disbanded", {
detail: { tag: this.clanTag },
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.clan_disbanded"), "green");
} finally {
this.actionPending = false;
}
}
private clearConfirm() {
this.confirmAction = null;
this.confirmTargetId = null;
}
private onSearchInput(e: Event) {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberSearchDebounce = setTimeout(() => {
this.memberSearch = (e.target as HTMLInputElement).value;
this.requestUpdate();
}, 200);
}
private onSortChange(sort: ClanMemberSort) {
if (sort === this.memberSort) return;
this.memberSort = sort;
this.memberOrder = defaultOrderForSort(sort);
this.loadMembers(1);
}
private onOrderToggle() {
this.memberOrder = this.memberOrder === "asc" ? "desc" : "asc";
this.loadMembers(1);
}
private navigateDetail = () =>
this.dispatchEvent(
new CustomEvent("navigate-detail", { bubbles: true, composed: true }),
);
render() {
if (this.loading)
return html`<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.manage_clan"),
onBack: this.navigateDetail,
ariaLabel: translateText("common.back"),
})}
${renderLoadingSpinner()}
</div>`;
const clan = this.selectedClan;
if (!clan) return "";
return html`${this.renderManageContent(clan)}${this.renderConfirmOverlay()}`;
}
private renderConfirmOverlay() {
if (!this.confirmAction) return "";
if (this.confirmAction === "disband") {
return html`<confirm-dialog
.message=${translateText("clan_modal.confirm_disband", {
tag: this.selectedClan?.tag ?? "",
name: this.selectedClan?.name ?? "",
})}
variant="danger"
?disabled=${this.actionPending}
@confirm=${() => {
this.clearConfirm();
this.handleDisband();
}}
@cancel=${() => this.clearConfirm()}
></confirm-dialog>`;
}
if (this.confirmAction === "kick" && this.confirmTargetId) {
return html`<confirm-dialog
.message=${translateText("clan_modal.confirm_kick")}
variant="warning"
?disabled=${this.memberActionPending}
@confirm=${() => {
const id = this.confirmTargetId!;
this.clearConfirm();
this.handleKick(id);
}}
@cancel=${() => this.clearConfirm()}
></confirm-dialog>`;
}
if (this.confirmAction === "ban" && this.confirmTargetId) {
return html`<confirm-dialog
.message=${translateText("clan_modal.confirm_ban")}
variant="warning"
textareaPlaceholder=${translateText("clan_modal.ban_reason_prompt")}
?disabled=${this.memberActionPending}
@confirm=${(e: CustomEvent<{ text: string }>) => {
const id = this.confirmTargetId!;
const reason = e.detail.text;
this.clearConfirm();
this.handleBan(id, reason);
}}
@cancel=${() => this.clearConfirm()}
></confirm-dialog>`;
}
return "";
}
private renderManageContent(clan: ClanInfo) {
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.manage_clan"),
onBack: this.navigateDetail,
ariaLabel: translateText("common.back"),
rightContent: html`<span
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
>[${clan.tag}]</span
>`,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
<div class="space-y-6">
<!-- Edit Settings -->
<div
class="bg-white/5 rounded-2xl border border-white/10 p-6 space-y-5"
>
<h3
class="text-sm font-bold text-white/60 uppercase tracking-wider"
>
${translateText("clan_modal.clan_settings")}
</h3>
<div>
<label
class="block text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2"
>${translateText("clan_modal.clan_name")}</label
>
<input
type="text"
.value=${this.manageName}
@input=${(e: Event) =>
(this.manageName = (e.target as HTMLInputElement).value)}
maxlength="35"
class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm"
/>
</div>
<div>
<label
class="block text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2"
>${translateText("clan_modal.description")}</label
>
<textarea
.value=${this.manageDescription}
@input=${(e: Event) =>
(this.manageDescription = (
e.target as HTMLTextAreaElement
).value)}
maxlength="200"
rows="3"
class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm resize-none"
></textarea>
</div>
<div class="flex items-center justify-between">
<div>
<div class="text-white text-sm font-bold">
${translateText("clan_modal.open_clan")}
</div>
<div class="text-white/40 text-xs">
${translateText("clan_modal.open_clan_desc")}
</div>
</div>
<button
role="switch"
aria-checked="${this.manageIsOpen}"
aria-label="${translateText("clan_modal.open_clan")}"
@click=${() => (this.manageIsOpen = !this.manageIsOpen)}
class="relative w-12 h-7 rounded-full transition-all ${this
.manageIsOpen
? "bg-malibu-blue"
: "bg-white/20"}"
>
<div
class="absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-all ${this
.manageIsOpen
? "left-6"
: "left-1"}"
></div>
</button>
</div>
<button
@click=${() => this.handleSaveSettings()}
?disabled=${this.saving}
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 rounded-xl transition-all disabled:opacity-50"
>
${this.saving
? translateText("clan_modal.saving")
: translateText("clan_modal.save_changes")}
</button>
</div>
<!-- Member Management -->
<div
class="bg-white/5 rounded-2xl border border-white/10 p-6 space-y-4"
>
<h3
class="text-sm font-bold text-white/60 uppercase tracking-wider"
>
${translateText("clan_modal.members")}
(${clan.memberCount ?? 0})
</h3>
${renderMemberSearchInput(
(e) => this.onSearchInput(e),
undefined,
renderMemberSortControl(
this.memberSort,
this.memberOrder,
(s) => this.onSortChange(s),
() => this.onOrderToggle(),
),
)}
${(() => {
const filtered = filterMembersBySearch(
this.members,
this.memberSearch,
);
return html`
<div class="space-y-2">
${filtered.map((m) => this.renderManageMemberRow(m))}
</div>
${renderMemberPagination(
this.memberPage,
this.membersTotal,
this.membersPerPage,
(p) => this.loadMembers(p),
(pp) => {
this.membersPerPage = pp;
this.loadMembers(1);
},
)}
`;
})()}
</div>
<!-- Danger Zone -->
<div
class="bg-red-500/5 rounded-2xl border border-red-500/20 p-6 space-y-4"
>
<h3
class="text-sm font-bold text-red-400/80 uppercase tracking-wider"
>
${translateText("clan_modal.danger_zone")}
</h3>
<button
@click=${() =>
this.dispatchEvent(
new CustomEvent("navigate-bans", {
bubbles: true,
composed: true,
}),
)}
class="w-full px-6 py-3 text-sm font-bold text-red-400 uppercase tracking-wider bg-red-600/20 hover:bg-red-600/30 rounded-xl transition-all border border-red-500/30"
>
${translateText("clan_modal.banned_players")}
</button>
${this.myRole === "leader"
? html`
<button
@click=${() =>
this.dispatchEvent(
new CustomEvent("navigate-transfer", {
bubbles: true,
composed: true,
}),
)}
class="w-full px-6 py-3 text-sm font-bold text-amber-400 uppercase tracking-wider bg-amber-600/20 hover:bg-amber-600/30 rounded-xl transition-all border border-amber-500/30"
>
${translateText("clan_modal.transfer_leadership")}
</button>
<button
@click=${() => {
this.confirmAction = "disband";
this.confirmTargetId = null;
}}
?disabled=${this.confirmAction === "disband"}
class="w-full px-6 py-3 text-sm font-bold text-red-400 uppercase tracking-wider bg-red-600/20 hover:bg-red-600/30 rounded-xl transition-all border border-red-500/30 disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.disband_clan")}
</button>
`
: ""}
</div>
</div>
</div>
</div>
`;
}
private renderManageMemberRow(member: ClanMember) {
const isLeader = member.role === "leader";
const isMe = member.publicId === this.myPublicId;
const canModerate =
!isMe &&
!isLeader &&
(this.myRole === "leader" ||
(this.myRole === "officer" && member.role === "member"));
const canPromote =
!isMe && this.myRole === "leader" && member.role === "member";
const canDemote =
!isMe && this.myRole === "leader" && member.role === "officer";
return html`
<div
class="flex flex-col py-2.5 px-3 rounded-xl border
${isMe
? "bg-malibu-blue/10 border-malibu-blue/20"
: "bg-white/5 border-white/10"}"
>
<div class="flex items-center flex-wrap gap-1.5">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0
${isMe
? "bg-malibu-blue/20 text-aquarius"
: "bg-white/10 text-white/50"}"
>
${renderRoleIcon(member.role)}
</div>
<copy-button
compact
.copyText=${member.publicId}
.displayText=${member.publicId}
.showVisibilityToggle=${false}
.showCopyIcon=${false}
></copy-button>
<span class="text-white/30 text-[10px] whitespace-nowrap">
${translateText("clan_modal.joined_date", {
date: formatClanDate(member.joinedAt),
})}
</span>
<div class="flex items-center gap-1.5 ml-auto flex-wrap justify-end">
${canPromote
? html`<button
@click=${() => this.handlePromote(member.publicId)}
?disabled=${this.memberActionPending}
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-purple-500/10 text-purple-400/70 border border-purple-500/20 hover:bg-purple-500/20 hover:text-purple-400 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.promote")}
</button>`
: ""}
${canDemote
? html`<button
@click=${() => this.handleDemote(member.publicId)}
?disabled=${this.memberActionPending}
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-white/5 text-white/40 border border-white/10 hover:bg-white/10 hover:text-white/60 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.demote")}
</button>`
: ""}
${canModerate
? html`
<button
@click=${() => {
this.confirmAction = "kick";
this.confirmTargetId = member.publicId;
}}
?disabled=${this.memberActionPending ||
this.confirmAction !== null}
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-red-500/10 text-red-400/70 border border-red-500/20 hover:bg-red-500/20 hover:text-red-400 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.kick")}
</button>
<button
@click=${() => {
this.confirmAction = "ban";
this.confirmTargetId = member.publicId;
}}
?disabled=${this.memberActionPending ||
this.confirmAction !== null}
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-red-500/10 text-red-400/70 border border-red-500/20 hover:bg-red-500/20 hover:text-red-400 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.ban")}
</button>
`
: ""}
</div>
</div>
</div>
`;
}
}
@@ -0,0 +1,105 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { invalidateUserMe } from "../../Api";
import { withdrawClanRequest } from "../../ClanApi";
import { translateText } from "../../Utils";
import { modalHeader } from "../ui/ModalHeader";
import { formatClanDate, modalContainerClass, showToast } from "./ClanShared";
@customElement("clan-my-requests-view")
export class ClanMyRequestsView extends LitElement {
createRenderRoot() {
return this;
}
@property({ type: Array }) myPendingRequests: {
tag: string;
name: string;
createdAt: string;
}[] = [];
@state() private actionPending = false;
async handleWithdrawRequest(tag: string) {
if (this.actionPending) return;
this.actionPending = true;
try {
const result = await withdrawClanRequest(tag);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
invalidateUserMe();
showToast(translateText("clan_modal.join_request_cancelled"), "green");
this.dispatchEvent(
new CustomEvent("request-withdrawn", {
detail: { tag },
bubbles: true,
composed: true,
}),
);
} finally {
this.actionPending = false;
}
}
render() {
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.pending_applications"),
onBack: () =>
this.dispatchEvent(
new CustomEvent("navigate-back", {
bubbles: true,
composed: true,
}),
),
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
${this.myPendingRequests.length === 0
? html`<p class="text-white/40 text-sm text-center py-8">
${translateText("clan_modal.no_pending_applications")}
</p>`
: html`
<div class="space-y-3">
${this.myPendingRequests.map(
(req) => html`
<div
class="flex items-center gap-3 bg-white/5 rounded-xl border border-white/10 p-4"
>
<div
class="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center border border-amber-500/20 shrink-0"
>
<span class="text-amber-400 font-bold text-xs"
>${req.tag}</span
>
</div>
<div class="flex-1 min-w-0">
<span
class="text-white font-bold text-sm truncate block"
>${req.name}</span
>
<span class="text-white/30 text-xs">
${translateText("clan_modal.applied")}
${formatClanDate(req.createdAt)}
</span>
</div>
<button
@click=${() => this.handleWithdrawRequest(req.tag)}
?disabled=${this.actionPending}
class="text-[10px] font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-red-500/15 text-red-400 border border-red-500/20 hover:bg-red-500/25 transition-all cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.cancel_request")}
</button>
</div>
`,
)}
</div>
`}
</div>
</div>
`;
}
}
@@ -0,0 +1,218 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import {
approveClanRequest,
type ClanInfo,
type ClanJoinRequest,
denyClanRequest,
fetchClanRequests,
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../CopyButton";
import { modalHeader } from "../ui/ModalHeader";
import {
filterRequestsBySearch,
formatClanDate,
modalContainerClass,
renderLoadingSpinner,
renderMemberSearchInput,
renderServerPagination,
showToast,
} from "./ClanShared";
@customElement("clan-requests-view")
export class ClanRequestsView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@property({ type: Object }) selectedClan: ClanInfo | null = null;
@state() private requests: ClanJoinRequest[] = [];
@state() private requestsTotal = 0;
@state() private requestsPage = 1;
@state() private requestsLimit = 20;
@state() private memberActionPending = false;
@state() private loading = false;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
connectedCallback() {
super.connectedCallback();
this.loadRequests(1);
}
disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
super.disconnectedCallback();
}
private async loadRequests(page: number, showLoading = true) {
if (showLoading) this.loading = true;
else this.memberActionPending = true;
try {
const data = await fetchClanRequests(this.clanTag, page);
if (!data) {
if (showLoading)
showToast(translateText("clan_modal.failed_to_load_requests"), "red");
return;
}
this.requests = data.results;
this.requestsTotal = data.total;
this.requestsLimit = data.limit;
this.requestsPage = page;
} finally {
if (showLoading) this.loading = false;
else this.memberActionPending = false;
}
}
private async handleApprove(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await approveClanRequest(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
this.requests = this.requests.filter((r) => r.publicId !== publicId);
this.requestsTotal--;
this.dispatchEvent(
new CustomEvent("request-approved", {
detail: { publicId },
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.request_approved"), "green");
} finally {
this.memberActionPending = false;
}
}
private async handleDeny(publicId: string) {
if (this.memberActionPending) return;
this.memberActionPending = true;
try {
const result = await denyClanRequest(this.clanTag, publicId);
if (result !== true) {
showToast(translateText(result.error), "red");
return;
}
this.requests = this.requests.filter((r) => r.publicId !== publicId);
this.requestsTotal--;
this.dispatchEvent(
new CustomEvent("request-denied", {
detail: { publicId },
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.request_denied"), "green");
} finally {
this.memberActionPending = false;
}
}
private onSearchInput(e: Event) {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberSearchDebounce = setTimeout(() => {
this.memberSearch = (e.target as HTMLInputElement).value;
this.requestUpdate();
}, 200);
}
render() {
if (this.loading)
return html`<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.join_requests"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
})}${renderLoadingSpinner()}
</div>`;
const totalPages = Math.ceil(this.requestsTotal / this.requestsLimit);
const filtered = filterRequestsBySearch(this.requests, this.memberSearch);
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.join_requests"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
rightContent: html`<span
class="text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full bg-white/10 text-white/50 border border-white/10"
>${this.requestsTotal}</span
>`,
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
${renderMemberSearchInput(
(e) => this.onSearchInput(e),
"clan_modal.search_requests_placeholder",
)}
${filtered.length === 0
? html`<div
class="flex flex-col items-center justify-center p-12 text-center"
>
<p class="text-white/40 text-sm">
${translateText("clan_modal.no_requests")}
</p>
</div>`
: html`
<div class="space-y-3">
${filtered.map(
(req) => html`
<div
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>
<span class="text-white/30 text-[10px]">
${translateText("clan_modal.requested_on", {
tag: this.selectedClan?.tag ?? this.clanTag,
date: formatClanDate(req.createdAt),
})}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click=${() => this.handleApprove(req.publicId)}
?disabled=${this.memberActionPending}
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-green-500/20 text-green-400 border border-green-500/30 hover:bg-green-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.approve")}
</button>
<button
@click=${() => this.handleDeny(req.publicId)}
?disabled=${this.memberActionPending}
class="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg bg-red-500/20 text-red-400 border border-red-500/30 hover:bg-red-500/30 transition-all disabled:opacity-50 disabled:pointer-events-none"
>
${translateText("clan_modal.deny")}
</button>
</div>
</div>
`,
)}
</div>
${totalPages > 1
? renderServerPagination(this.requestsPage, totalPages, (p) =>
this.loadRequests(p, false),
)
: ""}
`}
</div>
</div>
`;
}
private back() {
this.dispatchEvent(
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
);
}
}
+477
View File
@@ -0,0 +1,477 @@
import { html, type TemplateResult } from "lit";
import type {
ClanJoinRequest,
ClanMember,
ClanMemberOrder,
ClanMemberSort,
ClanMemberStats,
ClanStats,
} from "../../ClanApi";
import { showToast, translateText } from "../../Utils";
export { renderLoadingSpinner } from "../BaseModal";
export { showToast };
export type ClanRole = "leader" | "officer" | "member";
export function defaultOrderForSort(sort: ClanMemberSort): ClanMemberOrder {
return sort === "default" ? "asc" : "desc";
}
export const modalContainerClass =
"h-full flex flex-col overflow-hidden bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10";
const dateCache = new Map<string, string>();
export function formatClanDate(iso: string): string {
let cached = dateCache.get(iso);
if (!cached) {
cached = new Date(iso).toLocaleDateString();
dateCache.set(iso, cached);
}
return cached;
}
export function translateClanRole(role: string): string {
return translateText(`clan_modal.role_${role}`);
}
export function renderRoleIcon(role: string): TemplateResult {
if (role === "leader") {
return html`<span class="text-sm">👑</span>`;
}
if (role === "officer") {
return html`<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-purple-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>`;
}
return html`<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-white/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>`;
}
export function renderStat(label: string, value: string): TemplateResult {
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-4 text-center">
<div
class="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1"
>
${label}
</div>
<div class="text-white font-bold text-sm truncate">${value}</div>
</div>
`;
}
export function renderClanWL(stats: ClanStats): TemplateResult | string {
if (stats.games === 0) return "";
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-5 space-y-3">
<h3 class="text-sm font-bold text-white/60 uppercase tracking-wider">
${translateText("clan_modal.statistics")}
</h3>
<div class="space-y-1.5">
${statBuckets.map(({ key, labelKey }) =>
renderWLBarRow(
translateText(labelKey),
stats.stats[key].wins,
stats.stats[key].losses,
),
)}
</div>
</div>
`;
}
function renderPaginationButtons(
currentPage: number,
totalPages: number,
onPageChange: (page: number) => void,
): TemplateResult {
return html`
<div class="flex items-center gap-1">
<button
@click=${() => onPageChange(1)}
?disabled=${currentPage <= 1}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all
${currentPage <= 1
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&lt;&lt;
</button>
<button
@click=${() => onPageChange(Math.max(1, currentPage - 1))}
?disabled=${currentPage <= 1}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all
${currentPage <= 1
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&lt;
</button>
<span class="text-xs text-white/50 font-medium px-1">
${currentPage} / ${totalPages}
</span>
<button
@click=${() => onPageChange(Math.min(totalPages, currentPage + 1))}
?disabled=${currentPage >= totalPages}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all
${currentPage >= totalPages
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&gt;
</button>
<button
@click=${() => onPageChange(totalPages)}
?disabled=${currentPage >= totalPages}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all
${currentPage >= totalPages
? "text-white/20 cursor-not-allowed"
: "text-white/60 hover:text-white hover:bg-white/10"}"
>
&gt;&gt;
</button>
</div>
`;
}
export function renderServerPagination(
currentPage: number,
totalPages: number,
onPageChange: (page: number) => void,
): TemplateResult {
return html`
<div
class="flex items-center justify-center gap-1 pt-4 border-t border-white/10"
>
${renderPaginationButtons(currentPage, totalPages, onPageChange)}
</div>
`;
}
export function renderMemberSearchInput(
onInput: (e: Event) => void,
placeholderKey = "clan_modal.search_members_placeholder",
trailing?: TemplateResult,
): TemplateResult {
const input = html`
<div class="relative w-full sm:flex-1 sm:min-w-0">
<input
type="text"
@input=${onInput}
class="w-full h-10 pl-10 pr-4 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm"
placeholder="${translateText(placeholderKey)}"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-4 h-4 text-white/30 absolute left-3 top-1/2 -translate-y-1/2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
</div>
`;
if (!trailing) {
return html`<div class="mb-3">${input}</div>`;
}
return html`
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
${input}${trailing}
</div>
`;
}
const sortOptions: { value: ClanMemberSort; labelKey: string }[] = [
{ value: "default", labelKey: "clan_modal.sort_default" },
{ value: "winsTotal", labelKey: "clan_modal.sort_total_wins" },
{ value: "lossesTotal", labelKey: "clan_modal.sort_total_losses" },
{ value: "winsFfa", labelKey: "clan_modal.sort_ffa_wins" },
{ value: "lossesFfa", labelKey: "clan_modal.sort_ffa_losses" },
{ value: "winsTeam", labelKey: "clan_modal.sort_team_wins" },
{ value: "lossesTeam", labelKey: "clan_modal.sort_team_losses" },
{ value: "winsHvn", labelKey: "clan_modal.sort_hvn_wins" },
{ value: "lossesHvn", labelKey: "clan_modal.sort_hvn_losses" },
{ value: "winsRanked", labelKey: "clan_modal.sort_ranked_wins" },
{ value: "lossesRanked", labelKey: "clan_modal.sort_ranked_losses" },
{ value: "wins1v1", labelKey: "clan_modal.sort_1v1_wins" },
{ value: "losses1v1", labelKey: "clan_modal.sort_1v1_losses" },
];
function renderOrderIcon(order: ClanMemberOrder): TemplateResult {
// asc: bars grow downward (-, --, ---). desc: bars shrink downward (---, --, -).
const widths =
order === "asc" ? ["w-1.5", "w-2.5", "w-3.5"] : ["w-3.5", "w-2.5", "w-1.5"];
return html`
<span
class="flex flex-col items-start justify-center gap-[3px] w-4 h-4"
aria-hidden="true"
>
${widths.map(
(w) => html`<span class="${w} h-[2px] bg-current rounded-sm"></span>`,
)}
</span>
`;
}
export function renderMemberSortControl(
sort: ClanMemberSort,
order: ClanMemberOrder,
onSortChange: (sort: ClanMemberSort) => void,
onOrderToggle: () => void,
): TemplateResult {
const orderLabel = translateText(
order === "asc"
? "clan_modal.sort_order_asc"
: "clan_modal.sort_order_desc",
);
return html`
<div class="flex items-center gap-2 shrink-0">
<label
class="text-[10px] font-bold text-white/40 uppercase tracking-wider hidden sm:inline"
>
${translateText("clan_modal.sort_by")}
</label>
<select
@change=${(e: Event) =>
onSortChange((e.target as HTMLSelectElement).value as ClanMemberSort)}
class="flex-1 sm:flex-none h-10 pl-3 pr-8 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10 text-sm appearance-none bg-no-repeat bg-[right_0.5rem_center] bg-[length:1rem] bg-[url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22 fill=%22none%22 stroke=%22rgba(255,255,255,0.5)%22 stroke-width=%222%22><path stroke-linecap=%22round%22 stroke-linejoin=%22round%22 d=%22m6 9 6 6 6-6%22/></svg>')]"
>
${sortOptions.map(
(opt) => html`
<option
value=${opt.value}
?selected=${opt.value === sort}
class="bg-neutral-900"
>
${translateText(opt.labelKey)}
</option>
`,
)}
</select>
<button
type="button"
@click=${onOrderToggle}
title=${orderLabel}
aria-label=${orderLabel}
class="h-10 w-10 shrink-0 flex items-center justify-center bg-white/5 border border-white/10 rounded-xl text-white/70 hover:text-white hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all"
>
${renderOrderIcon(order)}
</button>
</div>
`;
}
const perPageOptions = [10, 25, 50] as const;
export function renderMemberPagination(
memberPage: number,
totalMembers: number,
membersPerPage: number,
onPageChange: (page: number) => void,
onPerPageChange: (perPage: number) => void,
): TemplateResult | string {
const totalPages = Math.ceil(totalMembers / membersPerPage);
if (totalMembers <= perPageOptions[0]) return "";
return html`
<div
class="flex flex-wrap items-center justify-between gap-3 pt-4 border-t border-white/10"
>
<div class="flex items-center gap-2">
<span
class="text-[10px] font-bold text-white/40 uppercase tracking-wider"
>
${translateText("clan_modal.per_page")}
</span>
${perPageOptions.map(
(opt) => html`
<button
@click=${() => onPerPageChange(opt)}
class="px-2 py-1 text-xs font-bold rounded-lg transition-all
${membersPerPage === opt
? "bg-malibu-blue/15 text-aquarius border border-malibu-blue/30"
: "text-white/40 hover:text-white/70 border border-transparent"}"
>
${opt}
</button>
`,
)}
</div>
${renderPaginationButtons(memberPage, totalPages, onPageChange)}
</div>
`;
}
const statBuckets = [
{ key: "total" as const, labelKey: "clan_modal.stats_total" },
{ key: "ffa" as const, labelKey: "clan_modal.stats_ffa" },
{ key: "team" as const, labelKey: "clan_modal.stats_team" },
{ key: "hvn" as const, labelKey: "clan_modal.stats_hvn" },
{ key: "ranked" as const, labelKey: "clan_modal.stats_ranked" },
{ key: "1v1" as const, labelKey: "clan_modal.stats_1v1" },
];
function renderWLBarRow(
label: string,
wins: number,
losses: number,
): TemplateResult {
const total = wins + losses;
const hasGames = total > 0;
const rate = hasGames ? Math.round((wins / total) * 100) : 0;
const winPct = hasGames ? (wins / total) * 100 : 0;
const lossPct = hasGames ? 100 - winPct : 0;
const rateClass = !hasGames
? "text-white/25"
: rate >= 50
? "text-green-400"
: "text-red-400/90";
return html`
<div class="flex items-center gap-2">
<span
class="text-[10px] font-bold uppercase tracking-wider text-white/50 w-14 shrink-0 truncate"
title=${label}
>
${label}
</span>
<div
class="flex-1 flex h-5 rounded-md overflow-hidden bg-white/5 text-[11px] font-bold text-white tabular-nums"
role="img"
aria-label="${wins} wins, ${losses} losses"
>
${wins > 0
? html`<div
class="bg-malibu-blue flex items-center px-1.5 overflow-hidden whitespace-nowrap"
style="width:${winPct}%"
>
${wins}W
</div>`
: ""}
${losses > 0
? html`<div
class="bg-red-500 flex items-center justify-end px-1.5 overflow-hidden whitespace-nowrap"
style="width:${lossPct}%"
>
${losses}L
</div>`
: ""}
</div>
<span
class="text-xs font-bold shrink-0 tabular-nums w-9 text-right ${rateClass}"
>
${hasGames ? `${rate}%` : "—"}
</span>
</div>
`;
}
export function renderMemberStats(
stats: ClanMemberStats | undefined,
): TemplateResult | string {
if (!stats) return "";
return html`
<div class="mt-1.5 space-y-1">
${statBuckets.map(({ key, labelKey }) =>
renderWLBarRow(
translateText(labelKey),
stats[key].wins,
stats[key].losses,
),
)}
</div>
`;
}
export function renderMemberRow(
member: ClanMember,
myPublicId: string | null,
): TemplateResult {
const isMe = member.publicId === myPublicId;
return html`
<div
class="flex flex-col py-2.5 px-3 rounded-xl border
${isMe
? "bg-malibu-blue/10 border-malibu-blue/20"
: "bg-white/5 border-white/10"}"
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0
${isMe
? "bg-malibu-blue/20 text-aquarius"
: "bg-white/10 text-white/50"}"
>
${renderRoleIcon(member.role)}
</div>
<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>
</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>
</div>
</div>
${renderMemberStats(member.stats)}
</div>
`;
}
export function filterMembersBySearch(
members: ClanMember[],
search: string,
): ClanMember[] {
if (!search) return members;
const q = search.toLowerCase();
return members.filter(
(m) =>
m.publicId.toLowerCase().includes(q) || m.role.toLowerCase().includes(q),
);
}
export function filterRequestsBySearch(
requests: ClanJoinRequest[],
search: string,
): ClanJoinRequest[] {
if (!search) return requests;
const q = search.toLowerCase();
return requests.filter((r) => r.publicId.toLowerCase().includes(q));
}
@@ -0,0 +1,258 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { invalidateUserMe } from "../../Api";
import {
type ClanInfo,
type ClanMember,
fetchClanMembers,
transferLeadership,
} from "../../ClanApi";
import { translateText } from "../../Utils";
import "../ConfirmDialog";
import "../CopyButton";
import { modalHeader } from "../ui/ModalHeader";
import {
filterMembersBySearch,
modalContainerClass,
renderLoadingSpinner,
renderMemberSearchInput,
renderRoleIcon,
renderServerPagination,
showToast,
translateClanRole,
} from "./ClanShared";
@customElement("clan-transfer-view")
export class ClanTransferView extends LitElement {
createRenderRoot() {
return this;
}
@property() clanTag = "";
@property({ type: Object }) selectedClan: ClanInfo | null = null;
@state() private transferTarget: string | null = null;
@state() private actionPending = false;
@state() private members: ClanMember[] = [];
@state() private membersTotal = 0;
@state() private memberPage = 1;
@state() private membersPerPage = 10;
@state() private loading = false;
@state() private errorMsg = "";
@state() private confirmAction: "transfer" | null = null;
private memberSearch = "";
private memberSearchDebounce: ReturnType<typeof setTimeout> | null = null;
connectedCallback() {
super.connectedCallback();
this.loadMembers(1);
}
disconnectedCallback() {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
super.disconnectedCallback();
}
private async loadMembers(page: number) {
if (page === 1) this.loading = true;
const res = await fetchClanMembers(this.clanTag, page, this.membersPerPage);
if (!res) {
this.loading = false;
return;
}
if (res.results.length === 0 && page > 1) {
await this.loadMembers(1);
return;
}
this.members = res.results;
this.membersTotal = res.total;
this.memberPage = res.page;
this.transferTarget = null;
this.loading = false;
}
private async handleTransfer() {
if (!this.transferTarget || this.actionPending) return;
this.actionPending = true;
this.errorMsg = "";
try {
const result = await transferLeadership(
this.clanTag,
this.transferTarget,
);
if (result !== true) {
showToast(translateText(result.error), "red");
this.errorMsg = translateText(result.error);
return;
}
invalidateUserMe();
this.dispatchEvent(
new CustomEvent("leadership-transferred", {
detail: { tag: this.clanTag },
bubbles: true,
composed: true,
}),
);
showToast(translateText("clan_modal.leadership_transferred"), "green");
} finally {
this.actionPending = false;
}
}
private onSearchInput(e: Event) {
if (this.memberSearchDebounce) clearTimeout(this.memberSearchDebounce);
this.memberSearchDebounce = setTimeout(() => {
this.memberSearch = (e.target as HTMLInputElement).value;
this.requestUpdate();
}, 200);
}
render() {
if (this.loading)
return html`<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.transfer_leadership"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
})}${renderLoadingSpinner()}
</div>`;
const nonLeaders = this.members.filter(
(m: ClanMember) => m.role !== "leader",
);
const totalMemberPages = Math.ceil(this.membersTotal / this.membersPerPage);
return html`
${this.renderContent(nonLeaders, totalMemberPages)}
${this.renderConfirmOverlay()}
`;
}
private renderConfirmOverlay() {
if (this.confirmAction !== "transfer" || !this.transferTarget) return "";
return html`<confirm-dialog
.message=${translateText("clan_modal.confirm_transfer", {
name: this.transferTarget,
})}
variant="warning"
?disabled=${this.actionPending}
@confirm=${() => {
this.confirmAction = null;
this.handleTransfer();
}}
@cancel=${() => {
this.confirmAction = null;
}}
></confirm-dialog>`;
}
private renderContent(nonLeaders: ClanMember[], totalMemberPages: number) {
return html`
<div class="${modalContainerClass}">
${modalHeader({
title: translateText("clan_modal.transfer_leadership"),
onBack: () => this.back(),
ariaLabel: translateText("common.back"),
})}
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1 p-4 lg:p-6">
<div class="space-y-6">
${this.errorMsg
? html`<p class="text-red-400 text-sm">${this.errorMsg}</p>`
: ""}
<div
class="bg-amber-500/10 rounded-xl border border-amber-500/20 p-4"
>
<p class="text-amber-400/80 text-sm">
${translateText("clan_modal.transfer_warning")}
</p>
</div>
${renderMemberSearchInput((e) => this.onSearchInput(e))}
<div class="space-y-2">
${filterMembersBySearch(nonLeaders, this.memberSearch).map(
(m) => html`
<button
@click=${() => (this.transferTarget = m.publicId)}
class="w-full flex items-center gap-3 py-2.5 px-3 rounded-xl border cursor-pointer transition-all text-left focus:outline-none focus:ring-2 focus:ring-amber-500/50
${this.transferTarget === m.publicId
? "bg-amber-500/10 border-amber-500/20"
: "bg-white/5 border-white/10 hover:bg-white/10"}"
aria-selected=${this.transferTarget === m.publicId}
>
<div
class="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/50 text-xs font-bold shrink-0"
>
${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>
</div>
<span
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full shrink-0
${m.role === "officer"
? "bg-purple-500/20 text-purple-400 border border-purple-500/30"
: "bg-white/10 text-white/40 border border-white/10"}"
>
${translateClanRole(m.role)}
</span>
${this.transferTarget === m.publicId
? html`<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-amber-400 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>`
: ""}
</button>
`,
)}
</div>
${totalMemberPages > 1
? renderServerPagination(this.memberPage, totalMemberPages, (p) =>
this.loadMembers(p),
)
: ""}
<button
@click=${() => (this.confirmAction = "transfer")}
class="w-full px-6 py-3 text-sm font-bold text-white uppercase tracking-wider rounded-xl transition-all border disabled:opacity-50 disabled:pointer-events-none
${this.transferTarget && !this.actionPending
? "bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 shadow-lg hover:shadow-amber-900/40 border-white/5"
: "bg-white/5 border-white/10 text-white/30 cursor-not-allowed"}"
?disabled=${!this.transferTarget || this.actionPending}
>
${this.transferTarget
? translateText("clan_modal.confirm_transfer", {
name: this.transferTarget,
})
: translateText("clan_modal.select_new_leader")}
</button>
</div>
</div>
</div>
`;
}
private back() {
this.dispatchEvent(
new CustomEvent("navigate-back", { bubbles: true, composed: true }),
);
}
}
@@ -3,8 +3,8 @@ import { customElement, state } from "lit/decorators.js";
import {
ClanLeaderboardEntry,
ClanLeaderboardResponse,
} from "../../../core/ApiSchemas";
import { fetchClanLeaderboard } from "../../Api";
} from "../../../core/ClanApiSchemas";
import { fetchClanLeaderboard } from "../../ClanApi";
import { translateText } from "../../Utils";
export type ClanSortColumn =
+23 -24
View File
@@ -14,7 +14,7 @@ const LeaderboardUsernameSchema = z
.string()
.transform(stripClanTagFromUsername)
.pipe(z.string().min(1).max(64));
const LeaderboardClanTagSchema = ClanTagSchema.unwrap();
const RequiredClanTagSchema = ClanTagSchema.unwrap();
export const RefreshResponseSchema = z.object({
token: z.string(),
@@ -96,6 +96,26 @@ export const UserMeResponseSchema = z.object({
hard: z.coerce.number(),
})
.optional(),
clans: z
.array(
z.object({
tag: RequiredClanTagSchema,
name: z.string(),
role: z.enum(["leader", "officer", "member"]),
joinedAt: z.iso.datetime(),
memberCount: z.number().int().min(1),
}),
)
.optional(),
clanRequests: z
.array(
z.object({
tag: RequiredClanTagSchema,
name: z.string(),
createdAt: z.iso.datetime(),
}),
)
.optional(),
}),
});
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
@@ -140,32 +160,11 @@ export const PlayerProfileSchema = z.object({
});
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
export const ClanLeaderboardEntrySchema = z.object({
clanTag: LeaderboardClanTagSchema,
games: z.number(),
wins: z.number(),
losses: z.number(),
playerSessions: z.number(),
weightedWins: z.number(),
weightedLosses: z.number(),
weightedWLRatio: z.number(),
});
export type ClanLeaderboardEntry = z.infer<typeof ClanLeaderboardEntrySchema>;
export const ClanLeaderboardResponseSchema = z.object({
start: z.iso.datetime(),
end: z.iso.datetime(),
clans: ClanLeaderboardEntrySchema.array(),
});
export type ClanLeaderboardResponse = z.infer<
typeof ClanLeaderboardResponseSchema
>;
export const PlayerLeaderboardEntrySchema = z.object({
rank: z.number(),
playerId: z.string(),
username: LeaderboardUsernameSchema,
clanTag: LeaderboardClanTagSchema.nullable().optional(),
clanTag: RequiredClanTagSchema.nullable().optional(),
flag: z.string().optional(),
elo: z.number(),
games: z.number(),
@@ -194,7 +193,7 @@ export const RankedLeaderboardEntrySchema = z.object({
public_id: z.string(),
user: DiscordUserSchema.nullable().optional(),
username: LeaderboardUsernameSchema,
clanTag: LeaderboardClanTagSchema.nullable().optional(),
clanTag: RequiredClanTagSchema.nullable().optional(),
});
export type RankedLeaderboardEntry = z.infer<
typeof RankedLeaderboardEntrySchema
+130
View File
@@ -0,0 +1,130 @@
import { z } from "zod";
import { ClanTagSchema } from "./Schemas";
const RequiredClanTagSchema = ClanTagSchema.unwrap();
export const ClanLeaderboardEntrySchema = z.object({
clanTag: RequiredClanTagSchema,
games: z.number(),
wins: z.number(),
losses: z.number(),
playerSessions: z.number(),
weightedWins: z.number(),
weightedLosses: z.number(),
weightedWLRatio: z.number(),
});
export type ClanLeaderboardEntry = z.infer<typeof ClanLeaderboardEntrySchema>;
export const ClanLeaderboardResponseSchema = z.object({
start: z.iso.datetime(),
end: z.iso.datetime(),
clans: ClanLeaderboardEntrySchema.array(),
total: z.number().optional(),
limit: z.number().optional(),
});
export type ClanLeaderboardResponse = z.infer<
typeof ClanLeaderboardResponseSchema
>;
export const ClanInfoSchema = z.object({
name: z.string().max(35),
tag: RequiredClanTagSchema,
description: z.string().max(200),
isOpen: z.boolean(),
createdAt: z.iso.datetime().optional(),
memberCount: z.number().optional(),
});
export type ClanInfo = z.infer<typeof ClanInfoSchema>;
export const ClanBrowseResponseSchema = z.object({
results: ClanInfoSchema.array(),
total: z.number(),
page: z.number(),
limit: z.number(),
});
export type ClanBrowseResponse = z.infer<typeof ClanBrowseResponseSchema>;
export const ClanMemberWLSchema = z.object({
wins: z.number(),
losses: z.number(),
});
export type ClanMemberWL = z.infer<typeof ClanMemberWLSchema>;
export const ClanMemberStatsSchema = z.object({
total: ClanMemberWLSchema,
ffa: ClanMemberWLSchema,
team: ClanMemberWLSchema,
hvn: ClanMemberWLSchema,
ranked: ClanMemberWLSchema,
"1v1": ClanMemberWLSchema,
});
export type ClanMemberStats = z.infer<typeof ClanMemberStatsSchema>;
export const ClanMemberSchema = z.object({
role: z.enum(["leader", "officer", "member"]),
joinedAt: z.iso.datetime(),
publicId: z.string(),
stats: ClanMemberStatsSchema.optional(),
});
export type ClanMember = z.infer<typeof ClanMemberSchema>;
export const ClanMembersResponseSchema = z.object({
results: ClanMemberSchema.array(),
total: z.number(),
page: z.number(),
limit: z.number(),
pendingRequests: z.number().optional(),
});
export type ClanMembersResponse = z.infer<typeof ClanMembersResponseSchema>;
export const ClanJoinRequestSchema = z.object({
publicId: z.string(),
createdAt: z.iso.datetime(),
});
export type ClanJoinRequest = z.infer<typeof ClanJoinRequestSchema>;
export const ClanRequestsResponseSchema = z.object({
results: ClanJoinRequestSchema.array(),
total: z.number(),
page: z.number(),
limit: z.number(),
});
export type ClanRequestsResponse = z.infer<typeof ClanRequestsResponseSchema>;
export const ClanBanSchema = z.object({
publicId: z.string(),
bannedBy: z.string(),
reason: z.string().max(200).nullable(),
createdAt: z.iso.datetime(),
});
export type ClanBan = z.infer<typeof ClanBanSchema>;
export const ClanBansResponseSchema = z.object({
results: ClanBanSchema.array(),
total: z.number(),
page: z.number(),
limit: z.number(),
});
export type ClanBansResponse = z.infer<typeof ClanBansResponseSchema>;
export const JoinClanResponseSchema = z.object({
status: z.enum(["joined", "requested"]),
});
export type JoinClanResponse = z.infer<typeof JoinClanResponseSchema>;
export const ClanStatsSchema = z.object({
clanTag: RequiredClanTagSchema,
games: z.number(),
wins: z.number(),
losses: z.number(),
stats: ClanMemberStatsSchema,
teamTypeWL: z.record(
z.string(),
z.object({ wl: z.tuple([z.number(), z.number()]) }),
),
teamCountWL: z.record(
z.string(),
z.object({ wl: z.tuple([z.number(), z.number()]) }),
),
});
export type ClanStats = z.infer<typeof ClanStatsSchema>;