mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
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:
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"summary_send": "Send",
|
||||
"summary_keep": "Keep",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"send": "Send",
|
||||
"cap_label": "Cap",
|
||||
"cap_tooltip": "Recipient’s 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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
@@ -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 "
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}"
|
||||
>
|
||||
<
|
||||
</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"}"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"}"
|
||||
>
|
||||
<<
|
||||
</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"}"
|
||||
>
|
||||
<
|
||||
</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"}"
|
||||
>
|
||||
>
|
||||
</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"}"
|
||||
>
|
||||
>>
|
||||
</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
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user