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
+5
View File
@@ -238,6 +238,11 @@
class="hidden w-full h-full page-content relative z-50"
></troubleshooting-modal>
<clan-modal
id="page-clan"
inline
class="hidden w-full h-full page-content relative z-50"
></clan-modal>
<account-modal
id="page-account"
inline
+111
View File
@@ -16,6 +16,7 @@
"summary_send": "Send",
"summary_keep": "Keep",
"cancel": "Cancel",
"confirm": "Confirm",
"send": "Send",
"cap_label": "Cap",
"cap_tooltip": "Recipients remaining capacity",
@@ -49,6 +50,7 @@
"leaderboard": "Leaderboard",
"account": "Account",
"help": "Help",
"clans": "Clans",
"menu": "Menu",
"troubleshooting": "Troubleshooting",
"go_to_troubleshooting": "Go to our troubleshooting page"
@@ -222,6 +224,115 @@
"logging_in": "Logging in...",
"success": "Successfully logged in as {email}!"
},
"clan_modal": {
"title": "Clans",
"my_clans": "My Clans",
"browse": "Browse",
"no_clans": "You're not in any clans yet.",
"sign_in_for_clans": "Sign in to join and manage clans",
"request_pending": "Request Pending",
"search_placeholder": "Search by clan tag...",
"no_results": "No clans found.",
"invite_only": "Invite Only",
"members": "Members",
"status": "Status",
"open": "Open",
"join_clan": "Join Clan",
"request_invite": "Request Invite",
"leave_clan": "Leave Clan",
"manage_clan": "Manage",
"transfer_leadership": "Transfer Leadership",
"clan_name": "Clan Name",
"description": "Description",
"open_clan": "Open Clan",
"open_clan_desc": "Anyone can join without an invite",
"clan_settings": "Clan Settings",
"save_changes": "Save Changes",
"promote": "Promote",
"demote": "Demote",
"kick": "Kick",
"danger_zone": "Danger Zone",
"disband_clan": "Disband Clan",
"transfer_warning": "This will make the selected member the new leader. You will become a regular member. This action cannot be undone.",
"confirm_transfer": "Transfer leadership to {name}",
"select_new_leader": "Select a new leader",
"search_members_placeholder": "Filter current page by ID or role...",
"search_requests_placeholder": "Search by player public ID...",
"per_page": "Per page",
"sort_by": "Sort by",
"sort_default": "Role",
"sort_total_wins": "Total Wins",
"sort_total_losses": "Total Losses",
"sort_ffa_wins": "FFA Wins",
"sort_ffa_losses": "FFA Losses",
"sort_team_wins": "Team Wins",
"sort_team_losses": "Team Losses",
"sort_hvn_wins": "HvN Wins",
"sort_hvn_losses": "HvN Losses",
"sort_ranked_wins": "Ranked Wins",
"sort_ranked_losses": "Ranked Losses",
"sort_1v1_wins": "1v1 Wins",
"sort_1v1_losses": "1v1 Losses",
"sort_order_asc": "Ascending",
"sort_order_desc": "Descending",
"join_requests": "Join Requests",
"no_requests": "No pending join requests.",
"pending_requests_count": "{count, plural, one {# pending request} other {# pending requests}}",
"approve": "Approve",
"deny": "Deny",
"requested_on": "Requested to join [{tag}] on {date}.",
"pending_applications": "Pending Applications",
"no_pending_applications": "No pending applications.",
"applied": "Applied",
"cancel_request": "Cancel",
"statistics": "Statistics",
"stats_total": "Total",
"stats_ffa": "FFA",
"stats_team": "Teams",
"stats_hvn": "HvN",
"stats_ranked": "Ranked",
"stats_1v1": "1v1",
"no_description": "No description",
"saving": "Saving...",
"join_request_cancelled": "Join request cancelled.",
"failed_to_load_clan": "Failed to load clan",
"join_request_sent": "Join request sent! Waiting for approval.",
"left_clan": "You left the clan.",
"settings_saved": "Clan settings saved!",
"clan_disbanded": "Clan disbanded.",
"member_promoted": "Member promoted!",
"member_demoted": "Member demoted.",
"member_kicked": "Member kicked.",
"leadership_transferred": "Leadership transferred!",
"failed_to_load_requests": "Failed to load requests",
"request_approved": "Request approved!",
"request_denied": "Request denied.",
"ban": "Ban",
"unban": "Unban",
"banned_players": "Banned Players",
"no_bans": "No banned players.",
"ban_reason_prompt": "Ban reason (optional, max 200 characters):",
"confirm_ban": "Are you sure you want to ban this player? They will be removed from the clan and unable to rejoin.",
"member_banned": "Player banned.",
"member_unbanned": "Player unbanned.",
"banned_by_label": "by",
"ban_reason": "Reason: {reason}",
"error_banned": "You are banned from this clan.",
"error_banned_reason": "You are banned from this clan. Reason: {reason}",
"confirm_kick": "Are you sure you want to kick this member?",
"confirm_disband": "Are you sure you want to disband [{tag}] {name}? This cannot be undone.",
"joined_date": "Member since {date}.",
"member_count": "{count, plural, one {# member} other {# members}}",
"role_leader": "Leader",
"role_officer": "Officer",
"role_member": "Member",
"error_already_member": "Already a member",
"error_request_pending": "Join request already pending",
"error_rate_limited_generic": "Please wait before joining another clan",
"error_network": "Network error",
"error_failed": "Action failed",
"error_loading": "Failed to load"
},
"account_modal": {
"title": "Account",
"connected_as": "Connected as",
-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>;
+13 -7
View File
@@ -44,13 +44,6 @@ vi.mock("../../src/client/Api", () => {
return {
getApiBase: vi.fn(getApiBase),
getUserMe: vi.fn(async () => false),
fetchClanLeaderboard: vi.fn(async () => {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return false;
return res.json();
}),
fetchPlayerLeaderboard: vi.fn(async (page: number) => {
const url = new URL(`${getApiBase()}/leaderboard/ranked`);
url.searchParams.set("page", String(page));
@@ -71,6 +64,19 @@ vi.mock("../../src/client/Api", () => {
};
});
vi.mock("../../src/client/ClanApi", () => {
const getApiBase = () => "http://localhost:3000";
return {
fetchClanLeaderboard: vi.fn(async () => {
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
headers: { Accept: "application/json" },
});
if (!res.ok) return false;
return res.json();
}),
};
});
const jsonRes = (data: any, ok = true, status = 200) => ({
ok,
status,
+166
View File
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../src/client/Api", () => ({
getApiBase: vi.fn(() => "http://localhost:3000"),
}));
vi.mock("../../../src/client/Auth", () => ({
getAuthHeader: vi.fn(async () => "Bearer test-token"),
}));
import {
banClanMember,
fetchClanBans,
unbanClanMember,
} from "../../../src/client/ClanApi";
const okJson = (data: unknown, status = 200) => ({
ok: true,
status,
json: async () => data,
});
const failRes = (status: number, data: unknown = {}) => ({
ok: false,
status,
json: async () => data,
});
const mockFetch = (impl: (...args: unknown[]) => unknown) =>
vi.stubGlobal("fetch", vi.fn(impl));
beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("banClanMember", () => {
it("returns true on 204 success", async () => {
mockFetch(() => ({ ok: true, status: 204, json: async () => ({}) }));
const result = await banClanMember("TEST", "player-1");
expect(result).toBe(true);
});
it("sends reason in request body when provided", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve({ ok: true, status: 204, json: async () => ({}) }),
);
vi.stubGlobal("fetch", fetchSpy);
await banClanMember("TEST", "player-1", "spamming");
const body = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
expect(body).toEqual({ targetPublicId: "player-1", reason: "spamming" });
});
it("omits reason from request body when not provided", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve({ ok: true, status: 204, json: async () => ({}) }),
);
vi.stubGlobal("fetch", fetchSpy);
await banClanMember("TEST", "player-1");
const body = JSON.parse(fetchSpy.mock.calls[0]![1]?.body as string);
expect(body).toEqual({ targetPublicId: "player-1" });
expect(body).not.toHaveProperty("reason");
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "insufficient permissions" }));
const result = await banClanMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await banClanMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("unbanClanMember", () => {
it("returns true on success", async () => {
mockFetch(() => ({ ok: true, status: 204, json: async () => ({}) }));
const result = await unbanClanMember("TEST", "player-1");
expect(result).toBe(true);
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(409, { message: "Player not currently banned" }));
const result = await unbanClanMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await unbanClanMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("fetchClanBans", () => {
const bansResponse = {
results: [
{
publicId: "banned-1",
bannedBy: "officer-1",
reason: "toxic",
createdAt: "2024-06-01T00:00:00.000Z",
},
],
total: 1,
page: 1,
limit: 20,
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(bansResponse));
const result = await fetchClanBans("TEST");
expect(result).toEqual(bansResponse);
});
it("passes page and limit as query params", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(bansResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanBans("TEST", 2, 10);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("page")).toBe("2");
expect(url.searchParams.get("limit")).toBe("10");
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(403));
const result = await fetchClanBans("TEST");
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ results: "not-an-array", total: 0 }));
const result = await fetchClanBans("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClanBans("TEST");
expect(result).toBe(false);
});
});
+423
View File
@@ -0,0 +1,423 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../src/client/Api", () => ({
getApiBase: vi.fn(() => "http://localhost:3000"),
}));
vi.mock("../../../src/client/Auth", () => ({
getAuthHeader: vi.fn(async () => "Bearer test-token"),
}));
import {
approveClanRequest,
demoteMember,
denyClanRequest,
disbandClan,
joinClan,
kickMember,
leaveClan,
promoteMember,
transferLeadership,
updateClan,
withdrawClanRequest,
} from "../../../src/client/ClanApi";
const okJson = (data: unknown, status = 200) => ({
ok: true,
status,
json: async () => data,
});
const failRes = (status: number, data: unknown = {}) => ({
ok: false,
status,
headers: new Headers(),
json: async () => data,
});
const mockFetch = (impl: (...args: unknown[]) => unknown) =>
vi.stubGlobal("fetch", vi.fn(impl));
beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("joinClan", () => {
it("returns { status: 'joined' } on success", async () => {
mockFetch(() => okJson({ status: "joined" }));
const result = await joinClan("TEST");
expect(result).toEqual({ status: "joined" });
});
it("returns { status: 'requested' } for open-request clans", async () => {
mockFetch(() => okJson({ status: "requested" }));
const result = await joinClan("CLSD");
expect(result).toEqual({ status: "requested" });
});
it("returns error key on 409 (already member)", async () => {
mockFetch(() => failRes(409));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_already_member" });
});
it("returns request pending error on 409 when message contains 'request'", async () => {
mockFetch(() => failRes(409, { message: "join request already pending" }));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_request_pending" });
});
it("returns rate limited error on 429", async () => {
mockFetch(() => failRes(429));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_rate_limited_generic" });
});
it("returns generic error on other non-ok response", async () => {
mockFetch(() => failRes(400, { message: "clan is full" }));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("gone"))),
);
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
it("returns banned error with reason on 403 BANNED with reason", async () => {
mockFetch(() => failRes(403, { code: "BANNED", reason: "toxic behavior" }));
const result = await joinClan("TEST");
expect(result).toEqual({
error: "clan_modal.error_banned_reason",
reason: "toxic behavior",
});
});
it("returns banned error without reason on 403 BANNED with null reason", async () => {
mockFetch(() => failRes(403, { code: "BANNED", reason: null }));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_banned" });
});
it("returns generic 403 error when code is not BANNED", async () => {
mockFetch(() => failRes(403, { message: "not authorized" }));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns fallback error when 403 body has no code or message", async () => {
mockFetch(() => failRes(403, {}));
const result = await joinClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
});
describe("leaveClan", () => {
it("returns true on success", async () => {
mockFetch(() => okJson({}));
const result = await leaveClan("TEST");
expect(result).toBe(true);
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(400, { message: "not a member" }));
const result = await leaveClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns generic error when no message in failure body", async () => {
mockFetch(() => failRes(500, {}));
const result = await leaveClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await leaveClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("kickMember", () => {
it("returns true on success", async () => {
mockFetch(() => okJson({}));
const result = await kickMember("TEST", "player-1");
expect(result).toBe(true);
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "not authorized" }));
const result = await kickMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await kickMember("TEST", "player-1");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("promoteMember", () => {
it("returns true on success", async () => {
mockFetch(() => okJson({}));
const result = await promoteMember("TEST", "player-2");
expect(result).toBe(true);
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "insufficient permissions" }));
const result = await promoteMember("TEST", "player-2");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await promoteMember("TEST", "player-2");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("demoteMember", () => {
it("returns true on success", async () => {
mockFetch(() => okJson({}));
const result = await demoteMember("TEST", "player-3");
expect(result).toBe(true);
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(400, { message: "cannot demote leader" }));
const result = await demoteMember("TEST", "player-3");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await demoteMember("TEST", "player-3");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("transferLeadership", () => {
it("returns true on success and POSTs to /transfer with the target", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
const result = await transferLeadership("TEST", "player-4");
expect(result).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string; body: string },
];
expect(url).toContain("/clans/TEST/transfer");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ targetPublicId: "player-4" });
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "not the leader" }));
const result = await transferLeadership("TEST", "player-4");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await transferLeadership("TEST", "player-4");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("disbandClan", () => {
it("returns true on success and uses DELETE", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
const result = await disbandClan("TEST");
expect(result).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string },
];
expect(url).toContain("/clans/TEST");
expect(init.method).toBe("DELETE");
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "not the leader" }));
const result = await disbandClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await disbandClan("TEST");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
it("encodes the tag in the URL path", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
await disbandClan("A B");
const [url] = fetchMock.mock.calls[0] as unknown as [string];
expect(url).toContain("/clans/A%20B");
});
});
describe("withdrawClanRequest", () => {
it("returns true on success and POSTs to /requests/withdraw", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
const result = await withdrawClanRequest("TEST");
expect(result).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string },
];
expect(url).toContain("/clans/TEST/requests/withdraw");
expect(init.method).toBe("POST");
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(404, { message: "no pending request" }));
const result = await withdrawClanRequest("TEST");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await withdrawClanRequest("TEST");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("approveClanRequest", () => {
it("returns true on success and POSTs to /requests/approve with the target", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
const result = await approveClanRequest("TEST", "applicant-1");
expect(result).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string; body: string },
];
expect(url).toContain("/clans/TEST/requests/approve");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ targetPublicId: "applicant-1" });
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(403, { message: "insufficient role" }));
const result = await approveClanRequest("TEST", "applicant-1");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await approveClanRequest("TEST", "applicant-1");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("denyClanRequest", () => {
it("returns true on success and POSTs to /requests/deny with the target", async () => {
const fetchMock = vi.fn(() => okJson({}));
vi.stubGlobal("fetch", fetchMock);
const result = await denyClanRequest("TEST", "applicant-2");
expect(result).toBe(true);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string; body: string },
];
expect(url).toContain("/clans/TEST/requests/deny");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ targetPublicId: "applicant-2" });
});
it("returns error object on failure", async () => {
mockFetch(() => failRes(404, { message: "no such request" }));
const result = await denyClanRequest("TEST", "applicant-2");
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await denyClanRequest("TEST", "applicant-2");
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
describe("updateClan", () => {
const validClan = {
name: "Updated Clan",
tag: "TEST",
description: "New description",
isOpen: false,
createdAt: "2024-01-01T00:00:00.000Z",
memberCount: 10,
};
it("returns parsed ClanInfo on success and uses PATCH", async () => {
const fetchMock = vi.fn(() => okJson(validClan));
vi.stubGlobal("fetch", fetchMock);
const result = await updateClan("TEST", { name: "Updated Clan" });
expect(result).toEqual(validClan);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ method: string; body: string },
];
expect(url).toContain("/clans/TEST");
expect(init.method).toBe("PATCH");
expect(JSON.parse(init.body)).toEqual({ name: "Updated Clan" });
});
it("returns error object on non-ok response", async () => {
mockFetch(() => failRes(403, { message: "not authorized" }));
const result = await updateClan("TEST", { isOpen: true });
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns error object when Zod validation fails on 200 body", async () => {
mockFetch(() => okJson({ tag: 123, name: null }));
const result = await updateClan("TEST", { description: "x" });
expect(result).toEqual({ error: "clan_modal.error_failed" });
});
it("returns network error on fetch failure", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await updateClan("TEST", { name: "x" });
expect(result).toEqual({ error: "clan_modal.error_network" });
});
});
+396
View File
@@ -0,0 +1,396 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../src/client/Api", () => ({
getApiBase: vi.fn(() => "http://localhost:3000"),
}));
vi.mock("../../../src/client/Auth", () => ({
getAuthHeader: vi.fn(async () => "Bearer test-token"),
}));
import {
fetchClanDetail,
fetchClanLeaderboard,
fetchClanMembers,
fetchClanRequests,
fetchClans,
fetchClanStats,
} from "../../../src/client/ClanApi";
const okJson = (data: unknown, status = 200) => ({
ok: true,
status,
json: async () => data,
});
const failRes = (status: number, data: unknown = {}) => ({
ok: false,
status,
json: async () => data,
});
const mockFetch = (impl: (...args: unknown[]) => unknown) =>
vi.stubGlobal("fetch", vi.fn(impl));
beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("fetchClanLeaderboard", () => {
const leaderboardData = {
start: "2024-01-01T00:00:00.000Z",
end: "2024-01-07T23:59:59.000Z",
clans: [],
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(leaderboardData));
const result = await fetchClanLeaderboard();
expect(result).toEqual(leaderboardData);
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(500));
const result = await fetchClanLeaderboard();
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("Network failure"))),
);
const result = await fetchClanLeaderboard();
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ start: "bad-date", end: "bad-date", clans: [] }));
const result = await fetchClanLeaderboard();
expect(result).toBe(false);
});
});
describe("fetchClanStats", () => {
const clanStats = {
clanTag: "TEST",
games: 20,
wins: 15,
losses: 5,
stats: {
total: { wins: 15, losses: 5 },
ffa: { wins: 7, losses: 3 },
team: { wins: 4, losses: 1 },
hvn: { wins: 1, losses: 0 },
ranked: { wins: 3, losses: 1 },
"1v1": { wins: 3, losses: 1 },
},
teamTypeWL: { ffa: { wl: [15, 5] } },
teamCountWL: { "2": { wl: [10, 3] } },
};
it("returns parsed data from json.clan on success", async () => {
mockFetch(() => okJson({ clan: clanStats }));
const result = await fetchClanStats("TEST");
expect(result).toEqual(clanStats);
});
it("returns false when json.clan is missing", async () => {
mockFetch(() => okJson({}));
const result = await fetchClanStats("TEST");
expect(result).toBe(false);
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(404));
const result = await fetchClanStats("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClanStats("TEST");
expect(result).toBe(false);
});
});
describe("fetchClanDetail", () => {
const clanInfo = {
name: "Test Clan",
tag: "TEST",
description: "We test things",
isOpen: false,
createdAt: "2024-01-01T00:00:00.000Z",
memberCount: 10,
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(clanInfo));
const result = await fetchClanDetail("TEST");
expect(result).toEqual(clanInfo);
});
it("returns false on 404", async () => {
mockFetch(() => failRes(404));
const result = await fetchClanDetail("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("timeout"))),
);
const result = await fetchClanDetail("TEST");
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ tag: 123, name: null, isOpen: "not-a-boolean" }));
const result = await fetchClanDetail("TEST");
expect(result).toBe(false);
});
});
describe("fetchClans", () => {
const browseResponse = {
results: [],
total: 0,
page: 1,
limit: 20,
};
it("passes page and limit as query params", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(browseResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClans(undefined, 3, 10);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("page")).toBe("3");
expect(url.searchParams.get("limit")).toBe("10");
});
it("passes search param when provided and long enough", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(browseResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClans("abc", 1, 20);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("search")).toBe("abc");
});
it("omits search param for 2-char query (below min length of 3)", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(browseResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClans("AB", 1, 20);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("search")).toBeNull();
});
it("omits search param when too short and non-alphanumeric", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(browseResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClans("a", 1, 20);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.has("search")).toBe(false);
});
it("returns false on failure", async () => {
mockFetch(() => failRes(500));
const result = await fetchClans();
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ results: "not-an-array", total: "bad" }));
const result = await fetchClans();
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClans();
expect(result).toBe(false);
});
});
describe("fetchClanMembers", () => {
const membersResponse = {
results: [
{
publicId: "abc123",
role: "leader",
joinedAt: "2024-01-01T00:00:00.000Z",
},
],
total: 1,
page: 1,
limit: 20,
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(membersResponse));
const result = await fetchClanMembers("TEST");
expect(result).toEqual(membersResponse);
});
it("passes page and limit as query params", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(membersResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanMembers("TEST", 3, 50);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("page")).toBe("3");
expect(url.searchParams.get("limit")).toBe("50");
});
it("includes the optional pendingRequests field", async () => {
mockFetch(() => okJson({ ...membersResponse, pendingRequests: 5 }));
const result = await fetchClanMembers("TEST");
expect(result).not.toBe(false);
if (result) expect(result.pendingRequests).toBe(5);
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(500));
const result = await fetchClanMembers("TEST");
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ results: "not-array", total: "bad" }));
const result = await fetchClanMembers("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClanMembers("TEST");
expect(result).toBe(false);
});
it("sends Authorization header", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(membersResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanMembers("TEST");
const headers = fetchSpy.mock.calls[0]![1]?.headers as Record<
string,
string
>;
expect(headers.Authorization).toBe("Bearer test-token");
});
});
describe("fetchClanRequests", () => {
const requestsResponse = {
results: [
{
publicId: "player1",
createdAt: "2024-06-01T00:00:00.000Z",
},
],
total: 1,
page: 1,
limit: 20,
};
it("returns parsed data on success", async () => {
mockFetch(() => okJson(requestsResponse));
const result = await fetchClanRequests("TEST");
expect(result).toEqual(requestsResponse);
});
it("passes page and limit as query params", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(requestsResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanRequests("TEST", 2, 10);
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
const url = new URL(calledUrl);
expect(url.searchParams.get("page")).toBe("2");
expect(url.searchParams.get("limit")).toBe("10");
});
it("returns false on non-ok response", async () => {
mockFetch(() => failRes(403));
const result = await fetchClanRequests("TEST");
expect(result).toBe(false);
});
it("returns false when Zod validation fails", async () => {
mockFetch(() => okJson({ results: 42, total: "bad" }));
const result = await fetchClanRequests("TEST");
expect(result).toBe(false);
});
it("returns false on network error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
const result = await fetchClanRequests("TEST");
expect(result).toBe(false);
});
it("sends Authorization header", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okJson(requestsResponse)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanRequests("TEST");
const headers = fetchSpy.mock.calls[0]![1]?.headers as Record<
string,
string
>;
expect(headers.Authorization).toBe("Bearer test-token");
});
});
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import {
ClanBanSchema,
ClanInfoSchema,
ClanJoinRequestSchema,
ClanMemberSchema,
ClanStatsSchema,
} from "../../../src/core/ClanApiSchemas";
describe("ClanInfoSchema", () => {
const base = {
name: "Test Clan",
tag: "TEST",
description: "A clan",
isOpen: true,
};
it("accepts valid data with ISO datetime createdAt", () => {
const result = ClanInfoSchema.safeParse({
...base,
createdAt: "2024-01-15T12:00:00.000Z",
memberCount: 5,
});
expect(result.success).toBe(true);
});
it("rejects non-ISO strings for createdAt", () => {
const result = ClanInfoSchema.safeParse({
...base,
createdAt: "January 15, 2024",
});
expect(result.success).toBe(false);
});
it("accepts data without optional createdAt", () => {
const result = ClanInfoSchema.safeParse(base);
expect(result.success).toBe(true);
});
it("accepts data without optional memberCount", () => {
const result = ClanInfoSchema.safeParse({
...base,
createdAt: "2024-01-15T12:00:00.000Z",
});
expect(result.success).toBe(true);
});
it("accepts data with neither createdAt nor memberCount", () => {
const result = ClanInfoSchema.safeParse(base);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.createdAt).toBeUndefined();
expect(result.data.memberCount).toBeUndefined();
}
});
});
describe("ClanMemberSchema", () => {
it("accepts a valid member with ISO datetime joinedAt", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: "abc123",
});
expect(result.success).toBe(true);
});
it("rejects a plain string for joinedAt", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
joinedAt: "last Tuesday",
publicId: "abc123",
});
expect(result.success).toBe(false);
});
it("rejects null publicId", () => {
const result = ClanMemberSchema.safeParse({
role: "leader",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: null,
});
expect(result.success).toBe(false);
});
it("accepts stats with total/ffa/team/ranked/1v1 win-loss breakdown", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: "abc123",
stats: {
total: { wins: 8, losses: 8 },
ffa: { wins: 2, losses: 4 },
team: { wins: 5, losses: 1 },
hvn: { wins: 0, losses: 0 },
ranked: { wins: 1, losses: 3 },
"1v1": { wins: 1, losses: 3 },
},
});
expect(result.success).toBe(true);
});
it("treats stats as optional for backwards compatibility", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: "abc123",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.stats).toBeUndefined();
}
});
it("rejects stats missing a bucket", () => {
const result = ClanMemberSchema.safeParse({
role: "member",
joinedAt: "2024-03-01T09:30:00.000Z",
publicId: "abc123",
stats: {
ffa: { wins: 1, losses: 1 },
team: { wins: 1, losses: 1 },
},
});
expect(result.success).toBe(false);
});
});
describe("ClanJoinRequestSchema", () => {
it("accepts a valid join request with ISO datetime createdAt", () => {
const result = ClanJoinRequestSchema.safeParse({
publicId: "player-xyz",
createdAt: "2024-06-10T08:00:00.000Z",
});
expect(result.success).toBe(true);
});
it("rejects a plain string for createdAt", () => {
const result = ClanJoinRequestSchema.safeParse({
publicId: "player-xyz",
createdAt: "2024-06-10",
});
expect(result.success).toBe(false);
});
});
describe("ClanStatsSchema", () => {
const validStats = {
clanTag: "ABcd1",
games: 10,
wins: 7,
losses: 3,
stats: {
total: { wins: 7, losses: 3 },
ffa: { wins: 3, losses: 2 },
team: { wins: 2, losses: 1 },
hvn: { wins: 1, losses: 0 },
ranked: { wins: 1, losses: 0 },
"1v1": { wins: 1, losses: 0 },
},
teamTypeWL: { ffa: { wl: [7, 3] } },
teamCountWL: { "2": { wl: [4, 1] } },
};
it("accepts a valid clan tag (2-5 alphanumeric chars)", () => {
for (const tag of ["AB", "abc12", "XYZAB"]) {
const result = ClanStatsSchema.safeParse({ ...validStats, clanTag: tag });
expect(result.success, `tag "${tag}" should be valid`).toBe(true);
}
});
it("rejects tags that are too short", () => {
const result = ClanStatsSchema.safeParse({ ...validStats, clanTag: "A" });
expect(result.success).toBe(false);
});
it("rejects tags that are too long", () => {
const result = ClanStatsSchema.safeParse({
...validStats,
clanTag: "TOOLNG",
});
expect(result.success).toBe(false);
});
it("rejects tags with non-alphanumeric characters", () => {
const result = ClanStatsSchema.safeParse({
...validStats,
clanTag: "AB-CD",
});
expect(result.success).toBe(false);
});
});
describe("ClanBanSchema", () => {
const validBan = {
publicId: "player-1",
bannedBy: "officer-1",
reason: "spamming",
createdAt: "2024-06-01T00:00:00.000Z",
};
it("accepts a valid ban with reason", () => {
const result = ClanBanSchema.safeParse(validBan);
expect(result.success).toBe(true);
});
it("accepts a ban with null reason", () => {
const result = ClanBanSchema.safeParse({ ...validBan, reason: null });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBeNull();
}
});
it("rejects a ban with missing reason field", () => {
const result = ClanBanSchema.safeParse({
publicId: validBan.publicId,
bannedBy: validBan.bannedBy,
createdAt: validBan.createdAt,
});
expect(result.success).toBe(false);
});
it("rejects a non-ISO string for createdAt", () => {
const result = ClanBanSchema.safeParse({
...validBan,
createdAt: "June 1 2024",
});
expect(result.success).toBe(false);
});
it("rejects null bannedBy", () => {
const result = ClanBanSchema.safeParse({ ...validBan, bannedBy: null });
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,823 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
apiMockFactory,
authMockFactory,
clanApiMockFactory,
configLoaderMockFactory,
crazyGamesSdkMockFactory,
flushAsync,
getElState,
makeClan,
setElState,
setState,
stubLocalStorage,
utilsMockFactory,
virtualizerMockFactory,
waitForSubComponent,
} from "./ClanModalTestUtils";
vi.mock("@lit-labs/virtualizer/virtualize.js", () => virtualizerMockFactory());
vi.mock("../../../src/client/Api", () => apiMockFactory());
vi.mock("../../../src/client/ClanApi", () => clanApiMockFactory());
vi.mock("../../../src/client/Utils", () => utilsMockFactory());
vi.mock("../../../src/client/Auth", () => authMockFactory());
vi.mock("../../../src/core/configuration/ConfigLoader", () =>
configLoaderMockFactory(),
);
vi.mock("../../../src/client/CrazyGamesSDK", () => crazyGamesSdkMockFactory());
stubLocalStorage();
import type { ClanInfo } from "../../../src/client/ClanApi";
import { ClanModal } from "../../../src/client/ClanModal";
describe("ClanModal — handlers", () => {
let modal: ClanModal;
beforeEach(async () => {
if (!customElements.get("clan-modal")) {
customElements.define("clan-modal", ClanModal);
}
modal = document.createElement("clan-modal") as ClanModal;
// Use inline mode so no nested o-modal custom element is needed.
modal.setAttribute("inline", "");
document.body.appendChild(modal);
await modal.updateComplete;
});
afterEach(() => {
document.body.removeChild(modal);
vi.clearAllMocks();
});
describe("handleApprove increments selectedClan.memberCount", () => {
it("increments memberCount by 1 after successful approveClanRequest", async () => {
const { approveClanRequest, fetchClanRequests } = await import(
"../../../src/client/ClanApi"
);
(approveClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(fetchClanRequests as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{ publicId: "applicant-1", createdAt: "2024-06-01T00:00:00Z" },
],
total: 1,
page: 1,
limit: 20,
});
const clan = makeClan({ memberCount: 5 });
setState(modal, "selectedClan" as keyof ClanModal, clan as never);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "requests" as never);
await waitForSubComponent(modal, "clan-requests-view");
// Click the approve button for the pending applicant
const approveButtons = Array.from(
modal.querySelectorAll("button"),
).filter((b) => b.textContent?.includes("clan_modal.approve"));
expect(approveButtons.length).toBeGreaterThan(0);
approveButtons[0].click();
// Wait for the async handleApprove to complete
await flushAsync(modal);
expect(approveClanRequest).toHaveBeenCalledWith("TST", "applicant-1");
// ClanModal's selectedClan.memberCount should be incremented via request-approved event
const updatedClan = (modal as unknown as { selectedClan: ClanInfo })
.selectedClan;
expect(updatedClan?.memberCount).toBe(6);
});
it("does not increment memberCount when approveClanRequest fails", async () => {
const { approveClanRequest, fetchClanRequests } = await import(
"../../../src/client/ClanApi"
);
(approveClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue({
error: "clan_modal.error_generic",
});
(fetchClanRequests as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{ publicId: "applicant-1", createdAt: "2024-06-01T00:00:00Z" },
],
total: 1,
page: 1,
limit: 20,
});
const clan = makeClan({ memberCount: 5 });
setState(modal, "selectedClan" as keyof ClanModal, clan as never);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "requests" as never);
await waitForSubComponent(modal, "clan-requests-view");
const approveButtons = Array.from(
modal.querySelectorAll("button"),
).filter((b) => b.textContent?.includes("clan_modal.approve"));
approveButtons[0].click();
await flushAsync(modal);
const updatedClan = (modal as unknown as { selectedClan: ClanInfo })
.selectedClan;
// memberCount must remain at 5 — the failure path must not mutate it
expect(updatedClan?.memberCount).toBe(5);
});
it("treats undefined memberCount as 0 and increments to 1", async () => {
const { approveClanRequest, fetchClanRequests } = await import(
"../../../src/client/ClanApi"
);
(approveClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(fetchClanRequests as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{ publicId: "applicant-1", createdAt: "2024-06-01T00:00:00Z" },
],
total: 1,
page: 1,
limit: 20,
});
const clan = makeClan({ memberCount: undefined });
setState(modal, "selectedClan" as keyof ClanModal, clan as never);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "requests" as never);
await waitForSubComponent(modal, "clan-requests-view");
const approveButtons = Array.from(
modal.querySelectorAll("button"),
).filter((b) => b.textContent?.includes("clan_modal.approve"));
approveButtons[0].click();
await flushAsync(modal);
const updatedClan = (modal as unknown as { selectedClan: ClanInfo })
.selectedClan;
expect(updatedClan?.memberCount).toBe(1);
});
});
describe("Ban feature — manage view", () => {
let manageView: Element;
beforeEach(async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
role: "member",
joinedAt: "2024-03-01T00:00:00Z",
publicId: "target-player",
},
],
total: 1,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ memberCount: 5 }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "manage" as never);
manageView = await waitForSubComponent(modal, "clan-manage-view");
});
it("renders a Ban button for non-leader members in manage view", () => {
const banButtons = Array.from(modal.querySelectorAll("button")).filter(
(b) => b.textContent?.trim() === "clan_modal.ban",
);
expect(banButtons.length).toBeGreaterThan(0);
});
it("handleBan calls banClanMember after confirm-dialog confirm", async () => {
const { banClanMember } = await import("../../../src/client/ClanApi");
(banClanMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
// Step 1: Click Ban button to open confirm dialog
const banButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.ban",
);
banButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
// Step 2: Find the confirm-dialog and fire its confirm event with reason text
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(
new CustomEvent("confirm", { detail: { text: "bad behavior" } }),
);
await flushAsync(manageView);
expect(banClanMember).toHaveBeenCalledWith(
"TST",
"target-player",
"bad behavior",
);
});
it("handleBan aborts when confirm-dialog cancel is clicked", async () => {
const { banClanMember } = await import("../../../src/client/ClanApi");
// Step 1: Click Ban button to open confirm dialog
const banButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.ban",
);
banButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
// Step 2: Fire cancel event
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(new CustomEvent("cancel"));
await flushAsync(manageView);
expect(banClanMember).not.toHaveBeenCalled();
});
it("handleBan sends undefined reason when confirm text is empty", async () => {
const { banClanMember } = await import("../../../src/client/ClanApi");
(banClanMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
// Step 1: Click Ban button
const banButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.ban",
);
banButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
// Step 2: Confirm with empty text
const dialog = modal.querySelector("confirm-dialog");
dialog!.dispatchEvent(
new CustomEvent("confirm", { detail: { text: " " } }),
);
await flushAsync(manageView);
expect(banClanMember).toHaveBeenCalledWith(
"TST",
"target-player",
undefined,
);
});
it("handleBan syncs memberCount via clan-updated event on success", async () => {
const { banClanMember, fetchClanMembers } = await import(
"../../../src/client/ClanApi"
);
(banClanMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
// Server returns the post-ban member total (was 5, now 4).
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 4,
page: 1,
limit: 10,
pendingRequests: 0,
});
// Step 1: Click Ban button
const banButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.ban",
);
banButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
// Step 2: Confirm
const dialog = modal.querySelector("confirm-dialog");
dialog!.dispatchEvent(
new CustomEvent("confirm", { detail: { text: "reason" } }),
);
await flushAsync(manageView, modal);
// ClanManageView's loadMembers dispatches clan-updated when memberCount differs,
// which ClanModal handles by updating selectedClan.
const updatedClan = (modal as unknown as { selectedClan: ClanInfo })
.selectedClan;
expect(updatedClan?.memberCount).toBe(4);
});
});
describe("handleUnban", () => {
it("removes ban from list and decrements total on success", async () => {
const { unbanClanMember, fetchClanBans } = await import(
"../../../src/client/ClanApi"
);
(unbanClanMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(fetchClanBans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
publicId: "banned-1",
bannedBy: "officer-1",
reason: null,
createdAt: "2024-06-01T00:00:00.000Z",
},
],
total: 1,
page: 1,
limit: 20,
});
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "bans" as never);
const bansView = await waitForSubComponent(modal, "clan-bans-view");
const unbanButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.unban",
);
expect(unbanButton).toBeTruthy();
unbanButton!.click();
await flushAsync(bansView);
expect(unbanClanMember).toHaveBeenCalledWith("TST", "banned-1");
const bansTotal = getElState<number>(bansView, "bansTotal");
expect(bansTotal).toBe(0);
});
});
describe("handleKick", () => {
let manageView: Element;
beforeEach(async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
role: "member",
joinedAt: "2024-03-01T00:00:00Z",
publicId: "target-player",
},
],
total: 5,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ memberCount: 5 }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "manage" as never);
manageView = await waitForSubComponent(modal, "clan-manage-view");
});
it("calls kickMember and syncs memberCount on success", async () => {
const { kickMember, fetchClanMembers } = await import(
"../../../src/client/ClanApi"
);
(kickMember as ReturnType<typeof vi.fn>).mockResolvedValue(true);
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 4,
page: 1,
limit: 10,
pendingRequests: 0,
});
const kickButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.kick",
);
kickButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const dialog = modal.querySelector("confirm-dialog");
dialog!.dispatchEvent(new CustomEvent("confirm"));
await flushAsync(manageView, modal);
expect(kickMember).toHaveBeenCalledWith("TST", "target-player");
// ClanManageView's loadMembers dispatches clan-updated when total differs (5→4),
// which ClanModal handles by updating selectedClan.
expect(
(modal as unknown as { selectedClan: ClanInfo }).selectedClan
?.memberCount,
).toBe(4);
});
it("does not mutate state when kickMember fails", async () => {
const { kickMember, fetchClanMembers } = await import(
"../../../src/client/ClanApi"
);
(kickMember as ReturnType<typeof vi.fn>).mockResolvedValue({
error: "clan_modal.error_generic",
});
const fetchSpy = fetchClanMembers as ReturnType<typeof vi.fn>;
fetchSpy.mockClear();
const kickButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.kick",
);
kickButton!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
modal
.querySelector("confirm-dialog")!
.dispatchEvent(new CustomEvent("confirm"));
await flushAsync(manageView);
expect(kickMember).toHaveBeenCalledWith("TST", "target-player");
// Failed call must not refresh the member page or change memberCount.
expect(fetchSpy).not.toHaveBeenCalled();
expect(
(modal as unknown as { selectedClan: ClanInfo }).selectedClan
?.memberCount,
).toBe(5);
});
});
describe("handleDisband", () => {
let manageView: Element;
beforeEach(async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 3,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ memberCount: 3 }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(
modal,
"myClans" as keyof ClanModal,
[makeClan({ memberCount: 3 })] as never,
);
setState(
modal,
"myClanRoles" as keyof ClanModal,
new Map([["TST", "leader"]]) as never,
);
setState(modal, "view" as keyof ClanModal, "manage" as never);
manageView = await waitForSubComponent(modal, "clan-manage-view");
});
it("calls disbandClan, clears selection, and returns to list on success", async () => {
const { disbandClan } = await import("../../../src/client/ClanApi");
(disbandClan as ReturnType<typeof vi.fn>).mockResolvedValue(true);
// Open the disband confirm dialog on the manage view.
setElState(manageView, "confirmAction", "disband");
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(new CustomEvent("confirm"));
await flushAsync(modal);
expect(disbandClan).toHaveBeenCalledWith("TST");
const m = modal as unknown as {
selectedClan: ClanInfo | null;
myRole: string | null;
view: string;
myClans: ClanInfo[];
};
expect(m.selectedClan).toBeNull();
expect(m.myRole).toBeNull();
expect(m.view).toBe("list");
expect(m.myClans.find((c) => c.tag === "TST")).toBeUndefined();
});
it("preserves selection when disbandClan fails", async () => {
const { disbandClan } = await import("../../../src/client/ClanApi");
(disbandClan as ReturnType<typeof vi.fn>).mockResolvedValue({
error: "clan_modal.error_generic",
});
setElState(manageView, "confirmAction", "disband");
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(new CustomEvent("confirm"));
await flushAsync(manageView, modal);
const m = modal as unknown as {
selectedClan: ClanInfo | null;
view: string;
};
expect(disbandClan).toHaveBeenCalledWith("TST");
// Selection and view stay intact so the user can retry.
expect(m.selectedClan?.tag).toBe("TST");
expect(m.view).toBe("manage");
});
});
describe("handleDeny", () => {
let requestsView: Element;
beforeEach(async () => {
const { fetchClanRequests } = await import("../../../src/client/ClanApi");
(fetchClanRequests as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{ publicId: "applicant-1", createdAt: "2024-06-01T00:00:00Z" },
{ publicId: "applicant-2", createdAt: "2024-06-02T00:00:00Z" },
],
total: 2,
page: 1,
limit: 20,
});
setState(modal, "selectedClan" as keyof ClanModal, makeClan() as never);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "requests" as never);
requestsView = await waitForSubComponent(modal, "clan-requests-view");
});
it("removes the request and decrements totals on success", async () => {
const { denyClanRequest } = await import("../../../src/client/ClanApi");
(denyClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const denyButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.includes("clan_modal.deny"),
);
denyButton!.click();
await flushAsync(requestsView);
expect(denyClanRequest).toHaveBeenCalledWith("TST", "applicant-1");
const requests = getElState<{ publicId: string }[]>(
requestsView,
"requests",
);
const requestsTotal = getElState<number>(requestsView, "requestsTotal");
expect(requests.map((r) => r.publicId)).toEqual(["applicant-2"]);
expect(requestsTotal).toBe(1);
});
it("does not mutate state when denyClanRequest fails", async () => {
const { denyClanRequest } = await import("../../../src/client/ClanApi");
(denyClanRequest as ReturnType<typeof vi.fn>).mockResolvedValue({
error: "clan_modal.error_generic",
});
const denyButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.includes("clan_modal.deny"),
);
denyButton!.click();
await flushAsync(requestsView);
expect(denyClanRequest).toHaveBeenCalled();
const requests = getElState<{ publicId: string }[]>(
requestsView,
"requests",
);
const requestsTotal = getElState<number>(requestsView, "requestsTotal");
expect(requests).toHaveLength(2);
expect(requestsTotal).toBe(2);
});
});
describe("handleJoin", () => {
beforeEach(async () => {
const { fetchClanDetail, fetchClanStats } = await import(
"../../../src/client/ClanApi"
);
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeClan({ isOpen: true, memberCount: 5 }),
);
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myClanRoles" as keyof ClanModal, new Map() as never);
setState(modal, "view" as keyof ClanModal, "detail" as never);
await waitForSubComponent(modal, "clan-detail-view");
});
it("switches detail view into member mode immediately after open-clan join", async () => {
const { joinClan, fetchClanMembers } = await import(
"../../../src/client/ClanApi"
);
(joinClan as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
status: "joined",
});
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
role: "member",
joinedAt: "2024-01-01T00:00:00Z",
publicId: "test-player",
},
],
total: 6,
page: 1,
limit: 10,
pendingRequests: 0,
});
const joinButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.join_clan",
);
joinButton!.click();
await flushAsync(modal);
expect(joinClan).toHaveBeenCalledWith("TST");
expect(fetchClanMembers).toHaveBeenCalledWith(
"TST",
1,
10,
"default",
"asc",
);
const leaveButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.leave_clan",
);
expect(leaveButton).toBeTruthy();
const m = modal as unknown as {
myClanRoles: Map<string, string>;
};
expect(m.myClanRoles.get("TST")).toBe("member");
});
});
describe("handleLeave", () => {
beforeEach(async () => {
const { fetchClanDetail, fetchClanMembers, fetchClanStats } =
await import("../../../src/client/ClanApi");
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeClan(),
);
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
role: "member",
joinedAt: "2024-01-01T00:00:00Z",
publicId: "test-player",
},
],
total: 1,
page: 1,
limit: 10,
pendingRequests: 0,
});
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(
modal,
"myClanRoles" as keyof ClanModal,
new Map([["TST", "member"]]) as never,
);
setState(modal, "view" as keyof ClanModal, "detail" as never);
await waitForSubComponent(modal, "clan-detail-view");
});
it("calls leaveClan, removes role, and returns to list on success", async () => {
const { leaveClan } = await import("../../../src/client/ClanApi");
(leaveClan as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const leaveButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.leave_clan",
);
leaveButton!.click();
await flushAsync(modal);
expect(leaveClan).toHaveBeenCalledWith("TST");
const m = modal as unknown as {
selectedClan: ClanInfo | null;
myRole: string | null;
view: string;
myClanRoles: Map<string, string>;
};
expect(m.selectedClan).toBeNull();
expect(m.myRole).toBeNull();
expect(m.view).toBe("list");
expect(m.myClanRoles.has("TST")).toBe(false);
});
it("preserves selection when leaveClan fails", async () => {
const { leaveClan } = await import("../../../src/client/ClanApi");
(leaveClan as ReturnType<typeof vi.fn>).mockResolvedValue({
error: "clan_modal.error_generic",
});
const leaveButton = Array.from(modal.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "clan_modal.leave_clan",
);
leaveButton!.click();
await flushAsync(modal);
const m = modal as unknown as {
selectedClanTag: string;
view: string;
myClanRoles: Map<string, string>;
};
expect(leaveClan).toHaveBeenCalledWith("TST");
expect(m.selectedClanTag).toBe("TST");
expect(m.view).toBe("detail");
expect(m.myClanRoles.get("TST")).toBe("member");
});
});
describe("Transfer leadership — confirm flow", () => {
let transferView: Element;
beforeEach(async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
role: "member",
joinedAt: "2024-01-01T00:00:00Z",
publicId: "target-player",
},
],
total: 1,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ memberCount: 2 }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "transfer" as never);
transferView = await waitForSubComponent(modal, "clan-transfer-view");
// Set the transfer target and open confirm dialog on the transfer view
setElState(transferView, "transferTarget", "target-player");
setElState(transferView, "confirmAction", "transfer");
await (transferView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
});
it("clears confirmAction and removes the dialog after confirming", async () => {
const { transferLeadership } = await import(
"../../../src/client/ClanApi"
);
(transferLeadership as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(new CustomEvent("confirm"));
// Let handleTransfer's awaits settle.
await flushAsync(transferView);
expect(transferLeadership).toHaveBeenCalledWith("TST", "target-player");
expect(
getElState<string | null>(transferView, "confirmAction"),
).toBeNull();
expect(modal.querySelector("confirm-dialog")).toBeNull();
});
it("clears confirmAction when cancel is clicked, without calling the API", async () => {
const { transferLeadership } = await import(
"../../../src/client/ClanApi"
);
const dialog = modal.querySelector("confirm-dialog");
expect(dialog).toBeTruthy();
dialog!.dispatchEvent(new CustomEvent("cancel"));
await (transferView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
expect(transferLeadership).not.toHaveBeenCalled();
expect(
getElState<string | null>(transferView, "confirmAction"),
).toBeNull();
expect(modal.querySelector("confirm-dialog")).toBeNull();
});
});
});
@@ -0,0 +1,458 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
apiMockFactory,
authMockFactory,
clanApiMockFactory,
configLoaderMockFactory,
crazyGamesSdkMockFactory,
getElState,
makeClan,
setState,
stubLocalStorage,
utilsMockFactory,
virtualizerMockFactory,
waitForSubComponent,
} from "./ClanModalTestUtils";
vi.mock("@lit-labs/virtualizer/virtualize.js", () => virtualizerMockFactory());
vi.mock("../../../src/client/Api", () => apiMockFactory());
vi.mock("../../../src/client/ClanApi", () => clanApiMockFactory());
vi.mock("../../../src/client/Utils", () => utilsMockFactory());
vi.mock("../../../src/client/Auth", () => authMockFactory());
vi.mock("../../../src/core/configuration/ConfigLoader", () =>
configLoaderMockFactory(),
);
vi.mock("../../../src/client/CrazyGamesSDK", () => crazyGamesSdkMockFactory());
stubLocalStorage();
import { ClanModal } from "../../../src/client/ClanModal";
describe("ClanModal — rendering", () => {
let modal: ClanModal;
beforeEach(async () => {
if (!customElements.get("clan-modal")) {
customElements.define("clan-modal", ClanModal);
}
modal = document.createElement("clan-modal") as ClanModal;
// Use inline mode so no nested o-modal custom element is needed.
modal.setAttribute("inline", "");
document.body.appendChild(modal);
await modal.updateComplete;
});
afterEach(() => {
document.body.removeChild(modal);
vi.clearAllMocks();
});
// ── 1. renderClanCard: role badge vs open/invite badge ──────────────────
describe("renderClanCard — role vs open/invite badge", () => {
it("shows the role badge when a role is provided and hides open/invite badge", async () => {
// Directly invoke renderClanCard via the instance and insert the result
// into a container so we can query it. We do this by populating myClans
// and myClanRoles state so the list view renders real cards.
const { getUserMe } = await import("../../../src/client/Api");
(getUserMe as ReturnType<typeof vi.fn>).mockResolvedValue({
player: {
publicId: "test-player",
clans: [
{
tag: "TST",
name: "Test Clan",
role: "leader",
joinedAt: "2024-01-01T00:00:00Z",
},
],
clanRequests: [],
achievements: { singleplayerMap: [] },
},
user: { email: "test@test.com" },
});
// Open the modal so onOpen() → loadMyClans() runs
modal.open();
// Wait for loadMyClans async chain to complete
await new Promise((r) => setTimeout(r, 0));
await modal.updateComplete;
// The my-clans list should be rendered. Find the role badge text.
const text = modal.textContent ?? "";
// Role "leader" should appear in the badge (translateText passes key through)
expect(text).toContain("leader");
// The open/invite badge should NOT appear alongside the role badge on the
// same card. Since translateText returns the key, we check for the keys.
// "clan_modal.open" would show when no role — it must NOT appear for a
// clan where the user has a role.
expect(text).not.toContain("clan_modal.open");
expect(text).not.toContain("clan_modal.invite_only");
});
it("shows 'clan_modal.open' badge when clan is open and user has no role", async () => {
const { fetchClans } = await import("../../../src/client/ClanApi");
(fetchClans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [makeClan({ tag: "OTH", name: "Other Clan", isOpen: true })],
total: 1,
page: 1,
limit: 20,
});
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map();
setState(modal, "activeTab" as keyof ClanModal, "browse" as never);
await waitForSubComponent(modal, "clan-browse-view");
const text = modal.textContent ?? "";
expect(text).toContain("clan_modal.open");
expect(text).not.toContain("clan_modal.invite_only");
expect(text).not.toContain("leader");
});
it("shows 'clan_modal.invite_only' badge when clan is closed and user has no role", async () => {
const { fetchClans } = await import("../../../src/client/ClanApi");
(fetchClans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [makeClan({ tag: "INV", name: "Invite Clan", isOpen: false })],
total: 1,
page: 1,
limit: 20,
});
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map();
setState(modal, "activeTab" as keyof ClanModal, "browse" as never);
await waitForSubComponent(modal, "clan-browse-view");
const text = modal.textContent ?? "";
expect(text).toContain("clan_modal.invite_only");
expect(text).not.toContain("clan_modal.open");
});
it("shows amber role badge class for leader", async () => {
setState(modal, "activeTab" as keyof ClanModal, "browse" as never);
setState(
modal,
"browseData" as keyof ClanModal,
{
results: [makeClan({ isOpen: true })],
total: 1,
page: 1,
limit: 20,
} as never,
);
// Force myClanRoles to include leader role for this clan's tag
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map([["TST", "leader"]]);
setState(modal, "myClans" as keyof ClanModal, [makeClan()] as never);
setState(modal, "activeTab" as keyof ClanModal, "my-clans" as never);
await modal.updateComplete;
// Find spans that contain the translated leader role — should have amber styling
const spans = Array.from(modal.querySelectorAll("span"));
const leaderSpan = spans.find((s) =>
s.textContent?.trim().includes("role_leader"),
);
expect(leaderSpan).toBeTruthy();
expect(leaderSpan!.className).toContain("amber");
});
it("shows blue role badge class for officer/member", async () => {
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map([["TST", "officer"]]);
setState(modal, "myClans" as keyof ClanModal, [makeClan()] as never);
setState(modal, "activeTab" as keyof ClanModal, "my-clans" as never);
await modal.updateComplete;
const spans = Array.from(modal.querySelectorAll("span"));
const officerSpan = spans.find((s) =>
s.textContent?.trim().includes("role_officer"),
);
expect(officerSpan).toBeTruthy();
expect(officerSpan!.className).toContain("blue");
});
});
// ── 2. My Clans tab passes role to renderClanCard ───────────────────────
describe("My Clans tab passes role from myClanRoles map", () => {
it("renders the user's role badge on a my-clan card", async () => {
// Set up a clan in myClans and a matching entry in myClanRoles
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map([["TST", "leader"]]);
setState(modal, "myClans" as keyof ClanModal, [makeClan()] as never);
setState(modal, "activeTab" as keyof ClanModal, "my-clans" as never);
await modal.updateComplete;
const text = modal.textContent ?? "";
// The role badge text must appear; the open badge must NOT.
expect(text).toContain("leader");
expect(text).not.toContain("clan_modal.open");
expect(text).not.toContain("clan_modal.invite_only");
});
it("does NOT show a role badge when myClanRoles has no entry for the clan", async () => {
const { fetchClans } = await import("../../../src/client/ClanApi");
(fetchClans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [makeClan({ tag: "INV", name: "Invite Clan", isOpen: false })],
total: 1,
page: 1,
limit: 20,
});
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map();
setState(modal, "activeTab" as keyof ClanModal, "browse" as never);
await waitForSubComponent(modal, "clan-browse-view");
const text = modal.textContent ?? "";
expect(text).not.toContain("leader");
expect(text).not.toContain("officer");
// invite_only badge should appear since isOpen is false and no role
expect(text).toContain("clan_modal.invite_only");
});
});
// ── 3. memberCount fallback — display "0" when undefined ───────────────
describe("memberCount fallback", () => {
it("shows 0 members in the clan card when memberCount is undefined", async () => {
// translateText is mocked to return the key, so member_count key will appear.
// We verify the count passed to it is 0 by checking the rendered output
// does not contain "undefined".
setState(
modal,
"myClans" as keyof ClanModal,
[makeClan({ memberCount: undefined })] as never,
);
(modal as unknown as { myClanRoles: Map<string, string> }).myClanRoles =
new Map();
setState(modal, "activeTab" as keyof ClanModal, "my-clans" as never);
await modal.updateComplete;
expect(modal.textContent).not.toContain("undefined");
// translateText mock swallows args and returns the key, so verify it
// was called with count: 0 (the fallback) rather than count: undefined.
const { translateText } = await import("../../../src/client/Utils");
const calls = (translateText as ReturnType<typeof vi.fn>).mock.calls;
const memberCountCall = calls.find(
(c) => c[0] === "clan_modal.member_count",
);
expect(memberCountCall).toBeTruthy();
expect(memberCountCall![1]).toEqual({ count: 0 });
});
it("shows 0 in the stats row of the detail view when memberCount is undefined", async () => {
const { fetchClanDetail, fetchClanStats } = await import(
"../../../src/client/ClanApi"
);
(fetchClanDetail as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
makeClan({ memberCount: undefined }),
);
(fetchClanStats as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
clanTag: "TST",
games: 0,
wins: 0,
losses: 0,
teamTypeWL: {},
teamCountWL: {},
});
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "detail" as never);
await waitForSubComponent(modal, "clan-detail-view");
expect(modal.textContent).not.toContain("undefined");
// The stat box should contain "0" (from `clan.memberCount ?? 0`)
expect(modal.textContent).toContain("0");
});
it("shows 0 in the manage members header when memberCount is undefined", async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 0,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ memberCount: undefined }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "manage" as never);
await waitForSubComponent(modal, "clan-manage-view");
expect(modal.textContent).not.toContain("undefined");
});
});
// ── 4. Toggle switch ARIA attributes ───────────────────────────────────
describe("Open/Closed toggle ARIA attributes in manage view", () => {
beforeEach(async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 0,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(
modal,
"selectedClan" as keyof ClanModal,
makeClan({ isOpen: true }) as never,
);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "manage" as never);
await waitForSubComponent(modal, "clan-manage-view");
});
it("toggle button has role='switch'", () => {
const toggle = modal.querySelector("[role='switch']");
expect(toggle).toBeTruthy();
});
it("toggle button has aria-checked='true' when manageIsOpen is true", () => {
const toggle = modal.querySelector("[role='switch']");
expect(toggle?.getAttribute("aria-checked")).toBe("true");
});
it("toggle button has aria-checked='false' when manageIsOpen is false", async () => {
const manageView = modal.querySelector("clan-manage-view")!;
(manageView as unknown as { manageIsOpen: boolean }).manageIsOpen = false;
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const toggle = modal.querySelector("[role='switch']");
expect(toggle?.getAttribute("aria-checked")).toBe("false");
});
it("toggle button has an aria-label", () => {
const toggle = modal.querySelector("[role='switch']");
const label = toggle?.getAttribute("aria-label");
expect(label).toBeTruthy();
expect(label!.length).toBeGreaterThan(0);
});
it("clicking the toggle flips manageIsOpen", async () => {
const manageView = modal.querySelector("clan-manage-view")!;
const toggle = modal.querySelector<HTMLButtonElement>("[role='switch']");
expect(toggle).toBeTruthy();
const before = getElState<boolean>(manageView, "manageIsOpen");
toggle!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const after = getElState<boolean>(manageView, "manageIsOpen");
expect(after).toBe(!before);
});
it("aria-checked reflects toggled state after click", async () => {
const manageView = modal.querySelector("clan-manage-view")!;
const toggle = modal.querySelector<HTMLButtonElement>("[role='switch']");
expect(toggle?.getAttribute("aria-checked")).toBe("true");
toggle!.click();
await (manageView as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
const updatedToggle = modal.querySelector("[role='switch']");
expect(updatedToggle?.getAttribute("aria-checked")).toBe("false");
});
});
// ── 5. Ban list rendering ──────────────────────────────────────────────
describe("Ban feature — bans view", () => {
it("renders Banned Players button in manage view", async () => {
const { fetchClanMembers } = await import("../../../src/client/ClanApi");
(fetchClanMembers as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 0,
page: 1,
limit: 10,
pendingRequests: 0,
});
setState(modal, "selectedClan" as keyof ClanModal, makeClan() as never);
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "myRole" as keyof ClanModal, "leader" as never);
setState(modal, "view" as keyof ClanModal, "manage" as never);
await waitForSubComponent(modal, "clan-manage-view");
const text = modal.textContent ?? "";
expect(text).toContain("clan_modal.banned_players");
});
it("renders ban list with unban button in bans view", async () => {
const { fetchClanBans } = await import("../../../src/client/ClanApi");
(fetchClanBans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [
{
publicId: "banned-1",
bannedBy: "officer-1",
reason: "toxic behavior",
createdAt: "2024-06-01T00:00:00.000Z",
},
],
total: 1,
page: 1,
limit: 20,
});
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "bans" as never);
await waitForSubComponent(modal, "clan-bans-view");
const text = modal.textContent ?? "";
expect(text).toContain("banned-1");
expect(text).toContain("officer-1");
expect(text).toContain("clan_modal.unban");
expect(text).toContain("clan_modal.ban_reason");
});
it("renders empty state when no bans", async () => {
const { fetchClanBans } = await import("../../../src/client/ClanApi");
(fetchClanBans as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
results: [],
total: 0,
page: 1,
limit: 20,
});
setState(modal, "selectedClanTag" as keyof ClanModal, "TST" as never);
setState(modal, "view" as keyof ClanModal, "bans" as never);
await waitForSubComponent(modal, "clan-bans-view");
const text = modal.textContent ?? "";
expect(text).toContain("clan_modal.no_bans");
});
});
describe("Component basics", () => {
it("is registered as a custom element", () => {
expect(modal).toBeInstanceOf(ClanModal);
expect(modal.tagName.toLowerCase()).toBe("clan-modal");
});
it("renders without shadow DOM (createRenderRoot returns this)", () => {
// BaseModal.createRenderRoot returns `this`, so shadowRoot should be null
expect(modal.shadowRoot).toBeNull();
});
it("opens and closes via public API", () => {
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
false,
);
modal.open();
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
true,
);
modal.close();
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
false,
);
});
});
});
+217
View File
@@ -0,0 +1,217 @@
import { vi } from "vitest";
import type { ClanInfo } from "../../../src/client/ClanApi";
import type { ClanModal } from "../../../src/client/ClanModal";
// ─── Mock factories ─────────────────────────────────────────────────────────
// Each factory returns a fresh object of vi.fn()s. Test files pass these to
// vi.mock() so Vitest invokes them when the mocked module is first imported.
// The factory pattern keeps the mock surface DRY across test files while
// preserving per-file module isolation.
export function clanApiMockFactory() {
return {
fetchClanDetail: vi.fn(async () => ({
name: "Test Clan",
tag: "TST",
description: "A test clan",
isOpen: true,
createdAt: "2024-01-01T00:00:00Z",
memberCount: 5,
})),
fetchClanMembers: vi.fn(async () => ({
results: [
{
role: "leader",
joinedAt: "2024-01-01T00:00:00Z",
publicId: "test-player",
},
],
total: 1,
page: 1,
limit: 10,
pendingRequests: 0,
})),
fetchClanStats: vi.fn(async () => ({
clanTag: "TST",
games: 10,
wins: 7,
losses: 3,
stats: {
total: { wins: 7, losses: 3 },
ffa: { wins: 3, losses: 2 },
team: { wins: 2, losses: 1 },
hvn: { wins: 1, losses: 0 },
ranked: { wins: 1, losses: 0 },
"1v1": { wins: 1, losses: 0 },
},
teamTypeWL: {},
teamCountWL: {},
})),
fetchClans: vi.fn(async () => ({
results: [],
total: 0,
page: 1,
limit: 20,
})),
joinClan: vi.fn(),
leaveClan: vi.fn(),
updateClan: vi.fn(),
disbandClan: vi.fn(),
kickMember: vi.fn(),
promoteMember: vi.fn(),
demoteMember: vi.fn(),
transferLeadership: vi.fn(),
fetchClanRequests: vi.fn(async () => ({
results: [],
total: 0,
page: 1,
limit: 20,
})),
approveClanRequest: vi.fn(async () => true),
denyClanRequest: vi.fn(),
withdrawClanRequest: vi.fn(),
fetchClanLeaderboard: vi.fn(),
banClanMember: vi.fn(async () => true),
unbanClanMember: vi.fn(async () => true),
fetchClanBans: vi.fn(async () => ({
results: [],
total: 0,
page: 1,
limit: 20,
})),
};
}
export function apiMockFactory() {
return {
getUserMe: vi.fn(async () => ({
player: {
publicId: "test-player",
clans: [
{
tag: "TST",
name: "Test Clan",
role: "leader",
joinedAt: "2024-01-01T00:00:00Z",
memberCount: 5,
},
],
clanRequests: [],
achievements: { singleplayerMap: [] },
},
user: { email: "test@test.com" },
})),
invalidateUserMe: vi.fn(),
};
}
export function utilsMockFactory() {
return {
translateText: vi.fn((key: string) => key),
showToast: vi.fn(),
};
}
export function authMockFactory() {
return {
getAuthHeader: vi.fn(async () => "Bearer test-token"),
userAuth: vi.fn(async () => ({ jwt: "test-token", claims: {} })),
};
}
export function configLoaderMockFactory() {
return {
getRuntimeClientServerConfig: vi.fn(() => ({})),
};
}
export function crazyGamesSdkMockFactory() {
return {
crazyGamesSDK: { isAvailable: false },
};
}
export async function virtualizerMockFactory() {
const { html } = await import("lit");
return {
virtualize: vi.fn(() => html``),
};
}
export function stubLocalStorage() {
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
}
// ─── Test helpers ───────────────────────────────────────────────────────────
/**
* Drain pending microtasks and Lit's update scheduler.
* Replaces bare `await new Promise(r => setTimeout(r, 0))` which only drains
* a single microtask tick and can miss batched Lit updates.
*/
export async function flushAsync(
...els: (Element | null | undefined)[]
): Promise<void> {
// Two ticks to drain chained microtasks (e.g. async handler → state update → re-render).
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
for (const el of els) {
if (el && "updateComplete" in el) {
await (el as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
}
}
}
/** Force-set a Lit @state property and trigger re-render. */
export function setState<K extends keyof ClanModal>(
modal: ClanModal,
key: K,
value: ClanModal[K],
) {
(modal as unknown as Record<string, unknown>)[key] = value;
}
/** Force-set a property on any element (sub-components etc.). */
export function setElState(el: Element, key: string, value: unknown) {
(el as unknown as Record<string, unknown>)[key] = value;
}
/** Get a property from any element. */
export function getElState<T = unknown>(el: Element, key: string): T {
return (el as unknown as Record<string, unknown>)[key] as T;
}
/**
* Wait for a sub-component to mount and finish its initial async load.
* Call after setting ClanModal state that causes the sub-component to render.
*/
export async function waitForSubComponent(
modal: ClanModal,
selector: string,
): Promise<Element> {
await flushAsync(modal);
const el = modal.querySelector(selector)!;
if (el && "updateComplete" in el) {
await (el as HTMLElement & { updateComplete: Promise<boolean> })
.updateComplete;
}
return el;
}
export function makeClan(overrides: Partial<ClanInfo> = {}): ClanInfo {
return {
name: "Test Clan",
tag: "TST",
description: "A test clan",
isOpen: true,
createdAt: "2024-01-01T00:00:00Z",
memberCount: 5,
...overrides,
};
}
+143
View File
@@ -0,0 +1,143 @@
import { render } from "lit";
import { describe, expect, it } from "vitest";
import type {
ClanJoinRequest,
ClanMember,
ClanMemberStats,
} from "../../../src/client/ClanApi";
import {
filterMembersBySearch,
filterRequestsBySearch,
renderMemberStats,
} from "../../../src/client/components/clan/ClanShared";
const members: ClanMember[] = [
{ publicId: "Alice123", role: "leader", joinedAt: "2024-01-01T00:00:00Z" },
{ publicId: "Bob456", role: "officer", joinedAt: "2024-02-01T00:00:00Z" },
{ publicId: "Charlie789", role: "member", joinedAt: "2024-03-01T00:00:00Z" },
];
const requests: ClanJoinRequest[] = [
{ publicId: "Dave111", createdAt: "2024-04-01T00:00:00Z" },
{ publicId: "Eve222", createdAt: "2024-05-01T00:00:00Z" },
];
describe("filterMembersBySearch", () => {
it("returns all members when search is empty", () => {
expect(filterMembersBySearch(members, "")).toEqual(members);
});
it("matches by publicId (case-insensitive)", () => {
const result = filterMembersBySearch(members, "alice");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Alice123");
});
it("matches by role", () => {
const result = filterMembersBySearch(members, "officer");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Bob456");
});
it("matches partial publicId", () => {
const result = filterMembersBySearch(members, "456");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Bob456");
});
it("returns empty array when nothing matches", () => {
expect(filterMembersBySearch(members, "zzz")).toEqual([]);
});
it("matches 'member' role without matching 'leader' or 'officer'", () => {
const result = filterMembersBySearch(members, "member");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Charlie789");
});
});
describe("renderMemberStats", () => {
const stats: ClanMemberStats = {
total: { wins: 7, losses: 5 },
ffa: { wins: 2, losses: 4 },
team: { wins: 5, losses: 1 },
hvn: { wins: 0, losses: 0 },
ranked: { wins: 0, losses: 0 },
"1v1": { wins: 0, losses: 0 },
};
function renderTo(result: ReturnType<typeof renderMemberStats>): HTMLElement {
const host = document.createElement("div");
render(result, host);
return host;
}
it("renders nothing when stats is undefined", () => {
const host = renderTo(renderMemberStats(undefined));
expect(host.textContent?.trim()).toBe("");
});
it("renders W/L labels inside bar segments and the win-rate per bucket", () => {
const host = renderTo(renderMemberStats(stats));
const text = host.textContent?.replace(/\s+/g, " ") ?? "";
// Each bucket with games shows `{wins}W` and `{losses}L` inside segments
expect(text).toContain("2W");
expect(text).toContain("4L");
expect(text).toContain("5W");
expect(text).toContain("1L");
// Win-rate, and em-dash placeholder for empty bucket
expect(text).toContain("33%");
expect(text).toContain("83%");
expect(text).toContain("—");
});
it("renders a proportional win-loss bar when there are games", () => {
const host = renderTo(renderMemberStats(stats));
const bars = host.querySelectorAll<HTMLDivElement>("[style*='width']");
// Two segments per bucket with games (total: 2, ffa: 2, team: 2). Ranked
// and 1v1 have 0 games → no segments.
expect(bars.length).toBe(6);
const widths = Array.from(bars).map((b) =>
(b.getAttribute("style") ?? "").replace(/\s+/g, ""),
);
// total: 7/12 ≈ 58.3% wins, 41.7% losses
expect(widths[0]).toContain("width:58.33");
expect(widths[1]).toContain("width:41.66");
// ffa: 2/6 ≈ 33.3% wins, 66.7% losses
expect(widths[2]).toContain("width:33.33");
expect(widths[3]).toContain("width:66.66");
});
it("includes all six translated bucket labels", () => {
const host = renderTo(renderMemberStats(stats));
const text = host.textContent ?? "";
expect(text).toContain("clan_modal.stats_total");
expect(text).toContain("clan_modal.stats_ffa");
expect(text).toContain("clan_modal.stats_team");
expect(text).toContain("clan_modal.stats_hvn");
expect(text).toContain("clan_modal.stats_ranked");
expect(text).toContain("clan_modal.stats_1v1");
});
});
describe("filterRequestsBySearch", () => {
it("returns all requests when search is empty", () => {
expect(filterRequestsBySearch(requests, "")).toEqual(requests);
});
it("matches by publicId (case-insensitive)", () => {
const result = filterRequestsBySearch(requests, "dave");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Dave111");
});
it("matches partial publicId", () => {
const result = filterRequestsBySearch(requests, "222");
expect(result).toHaveLength(1);
expect(result[0]!.publicId).toBe("Eve222");
});
it("returns empty array when nothing matches", () => {
expect(filterRequestsBySearch(requests, "zzz")).toEqual([]);
});
});