import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { generateCryptoRandomUUID, translateText } from "../client/Utils"; import { sanitizeClanTag } from "../core/Util"; import { MAX_CLAN_TAG_LENGTH, MAX_USERNAME_LENGTH, MIN_CLAN_TAG_LENGTH, MIN_USERNAME_LENGTH, validateClanTag, validateUsername, } from "../core/validations/username"; import { getUserMe } from "./Api"; import { fetchClanExists } from "./ClanApi"; import { crazyGamesSDK } from "./CrazyGamesSDK"; const CLAN_OWNERSHIP_DEBOUNCE_MS = 400; interface LangSelectorLike { currentLang?: string; translations?: Record; defaultTranslations?: Record; } const usernameKey: string = "username"; const clanTagKey: string = "clanTag"; @customElement("username-input") export class UsernameInput extends LitElement { @state() private baseUsername: string = ""; @state() private clanTag: string = ""; @property({ type: String }) validationError: string = ""; private _isValid: boolean = true; private _lastValidatedLang: string | null = null; private syncValidationError: string = ""; private syncIsValid: boolean = true; private clanTagAsyncError: string = ""; private clanTagCheckCounter: number = 0; private clanTagCheckTimer: ReturnType | null = null; // Remove static styles since we're using Tailwind createRenderRoot() { // Disable shadow DOM to allow Tailwind classes to work return this; } public getUsername(): string { return this.baseUsername.trim(); } public getClanTag(): string | null { return this.clanTag.length >= MIN_CLAN_TAG_LENGTH && this.clanTag.length <= MAX_CLAN_TAG_LENGTH && validateClanTag(this.clanTag).isValid ? this.clanTag : null; } connectedCallback() { super.connectedCallback(); this.loadStoredUsername(); crazyGamesSDK.getUsername().then((username) => { if (username) { this.baseUsername = username; this.validateAndStore(); } }); crazyGamesSDK.addAuthListener((user) => { if (user) { this.baseUsername = user.username; this.validateAndStore(); } }); } protected updated(): void { // Re-validate when translations become available or language changes, // since initial validation may run before translations are loaded. if (this.validationError) { const langSelector = document.querySelector( "lang-selector", ); const lang = langSelector?.currentLang; const hasTranslations = langSelector?.translations ?? langSelector?.defaultTranslations; if (hasTranslations && lang && lang !== this._lastValidatedLang) { this._lastValidatedLang = lang; this.validateAndStore(); } } } private loadStoredUsername() { const storedUsername = localStorage.getItem(usernameKey); if (storedUsername) { this.clanTag = localStorage.getItem(clanTagKey) ?? ""; this.baseUsername = storedUsername; this.validateAndStore(); } else { this.baseUsername = genAnonUsername(); this.validateAndStore(); } } render() { return html`
${this.validationError ? html`
${this.validationError}
` : null} `; } private handleClanTagChange(e: Event) { const input = e.target as HTMLInputElement; const originalValue = input.value; const val = sanitizeClanTag(originalValue); // Only show toast if characters were actually removed (not just uppercased) if (originalValue.toUpperCase() !== val) { input.value = val; // Show toast when invalid characters are removed window.dispatchEvent( new CustomEvent("show-message", { detail: { message: translateText("username.tag_invalid_chars"), color: "red", duration: 2000, }, }), ); } else if (originalValue !== val) { // Just update the input without toast if only case changed input.value = val; } this.clanTag = val; this.validateAndStore(); } private handleUsernameChange(e: Event) { const input = e.target as HTMLInputElement; const originalValue = input.value; const val = originalValue.replace(/[[\]]/g, ""); if (originalValue !== val) { input.value = val; // Show toast when brackets are removed window.dispatchEvent( new CustomEvent("show-message", { detail: { message: translateText("username.invalid_chars"), color: "red", duration: 2000, }, }), ); } this.baseUsername = val; this.validateAndStore(); } private validateAndStore() { const trimmedBase = this.getUsername(); const clanTagResult = validateClanTag(this.clanTag); if (!clanTagResult.isValid) { this.syncIsValid = false; this.syncValidationError = clanTagResult.error ?? ""; } else { const result = validateUsername(trimmedBase); this.syncIsValid = result.isValid; if (result.isValid) { localStorage.setItem(usernameKey, trimmedBase); // clanTag is persisted by scheduleClanTagOwnershipCheck (or its async // continuation) so we never store a tag the server would reject. this.syncValidationError = ""; } else { this.syncValidationError = result.error ?? ""; } } this.scheduleClanTagOwnershipCheck(); this.updateValidationState(); } private persistClanTag(tag: string) { if (this.syncIsValid) { localStorage.setItem(clanTagKey, tag); } } private updateValidationState() { if (!this.syncIsValid) { this._isValid = false; this.validationError = this.syncValidationError; return; } if (this.clanTagAsyncError) { this._isValid = false; this.validationError = this.clanTagAsyncError; return; } this._isValid = true; this.validationError = ""; } private scheduleClanTagOwnershipCheck() { if (this.clanTagCheckTimer !== null) { clearTimeout(this.clanTagCheckTimer); this.clanTagCheckTimer = null; } const tag = this.clanTag; if ( tag.length < MIN_CLAN_TAG_LENGTH || tag.length > MAX_CLAN_TAG_LENGTH || !validateClanTag(tag).isValid ) { // Bump the counter so any in-flight check is discarded. this.clanTagCheckCounter++; if (this.clanTagAsyncError) { this.clanTagAsyncError = ""; } // No async check needed — persist the (empty/short) value so clearing // the tag is remembered across reloads. this.persistClanTag(this.getClanTag() ?? ""); return; } this.clanTagCheckTimer = setTimeout(() => { this.clanTagCheckTimer = null; void this.runClanTagOwnershipCheck(tag); }, CLAN_OWNERSHIP_DEBOUNCE_MS); } private async runClanTagOwnershipCheck(expectedTag: string) { const checkId = ++this.clanTagCheckCounter; const stillCurrent = () => checkId === this.clanTagCheckCounter && this.clanTag === expectedTag; const me = await getUserMe(); if (!stillCurrent()) return; if (me) { const myTags = (me.player.clans ?? []).map((c) => c.tag.toUpperCase()); if (myTags.includes(expectedTag.toUpperCase())) { this.setClanTagAsyncError(""); this.persistClanTag(expectedTag); return; } } const exists = await fetchClanExists(expectedTag); if (!stillCurrent()) return; if (exists === true) { this.setClanTagAsyncError( translateText("username.tag_not_member", { tag: expectedTag }), ); } else { this.setClanTagAsyncError(""); this.persistClanTag(expectedTag); } } private setClanTagAsyncError(error: string) { if (this.clanTagAsyncError === error) return; this.clanTagAsyncError = error; this.updateValidationState(); this.requestUpdate(); } public isValid(): boolean { return this._isValid; } public showValidationFeedback(): void { const message = this.validationError || translateText("username.invalid_chars"); window.dispatchEvent( new CustomEvent("show-message", { detail: { message, color: "red", duration: 2500, }, }), ); } public validateOrShowError(): boolean { if (this.isValid()) { return true; } this.showValidationFeedback(); return false; } } export function genAnonUsername(): string { const uuid = generateCryptoRandomUUID(); const cleanUuid = uuid.replace(/-/g, "").toLowerCase(); const decimal = BigInt(`0x${cleanUuid}`); const threeDigits = decimal % 1000n; return "Anon" + threeDigits.toString().padStart(3, "0"); }