import { html, nothing, TemplateResult } from "lit"; import { customElement, state } from "lit/decorators.js"; import { ClientEnv } from "src/client/ClientEnv"; import { PlayerStatsTree, UserMeResponse } from "../core/ApiSchemas"; import { assetUrl } from "../core/AssetUrls"; import { Cosmetics } from "../core/CosmeticSchemas"; import { fetchPlayerById, getUserMe, invalidateUserMe, setMarketingConsent, } from "./Api"; import { discordLogin, googleLogin, linkGoogle, logOut, reauthAfterCrazyGamesChange, sendMagicLink, } from "./Auth"; import "./components/baseComponents/stats/DiscordUserHeader"; import "./components/baseComponents/stats/PlayerGameHistoryView"; import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; import "./components/baseComponents/stats/PlayerStatsTable"; import "./components/baseComponents/stats/PlayerStatsTree"; import { BaseModal } from "./components/BaseModal"; import "./components/CopyButton"; import "./components/CurrencyDisplay"; import "./components/Difficulties"; import "./components/FriendsList"; import "./components/RewardsPanel"; import type { RewardsChangedDetail } from "./components/RewardsPanel"; import "./components/SubscriptionPanel"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics } from "./Cosmetics"; import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK"; import { translateText } from "./Utils"; @customElement("account-modal") export class AccountModal extends BaseModal { protected routerName = "account"; @state() private email: string = ""; @state() private isLoadingUser: boolean = false; // Set on CrazyGames when a CrazyGames user is signed in. Their identity comes // from the SDK, not our backend user object. @state() private crazyGamesUser: CrazyGamesUser | null = null; @state() private consentBusy: boolean = false; private userMeResponse: UserMeResponse | null = null; private statsTree: PlayerStatsTree | null = null; // Preserves the Games tab's accumulated list + cursor across tab switches. private gameHistoryCache: PlayerGameHistoryCache | null = null; private cosmetics: Cosmetics | null = null; constructor() { super(); document.addEventListener("userMeResponse", (event: Event) => { // A CrazyGames sign-in fires userMeResponse (via Main's auth listener); // re-fetch the SDK profile so the modal leaves the sign-in screen. this.refreshCrazyGamesUser(); const customEvent = event as CustomEvent; if (customEvent.detail) { const previousPublicId = this.userMeResponse?.player?.publicId; this.userMeResponse = customEvent.detail as UserMeResponse; // Reset whenever the player identity changes (login, or switching to a // different account) so stats/history from the previous player don't // linger. if (this.userMeResponse?.player?.publicId !== previousPublicId) { this.statsTree = null; this.gameHistoryCache = null; this.requestUpdate(); } } else { this.statsTree = null; this.gameHistoryCache = null; this.requestUpdate(); } }); } // Refresh the signed-in CrazyGames identity from the SDK. No-op off // CrazyGames; drives isLinkedAccount() so the modal shows the profile. private refreshCrazyGamesUser() { if (!crazyGamesSDK.isOnCrazyGames()) return; void crazyGamesSDK.getUserProfile().then((user) => { this.crazyGamesUser = user; this.requestUpdate(); }); } private hasAnyStats(): boolean { if (!this.statsTree) return false; // Check if statsTree has any data return ( Object.keys(this.statsTree).length > 0 && Object.values(this.statsTree).some( (gameTypeStats) => gameTypeStats && Object.keys(gameTypeStats).length > 0, ) ); } protected renderHeaderSlot() { const isLoggedIn = !!this.userMeResponse?.user; const publicId = this.userMeResponse?.player?.publicId ?? ""; const displayId = publicId || translateText("account_modal.not_found"); return modalHeader({ title: translateText("account_modal.title"), onBack: () => this.close(), ariaLabel: translateText("common.back"), rightContent: isLoggedIn && !this.isLoadingUser ? html`
${translateText("account_modal.public_player_id")}
` : undefined, }); } private isLinkedAccount(): boolean { const me = this.userMeResponse?.user; // The CrazyGames identity only counts once the backend token exchange // produced a session — otherwise a failed exchange would show a dead // "connected as" view with no way to retry. return ( !!(me?.discord ?? me?.google ?? me?.email) || (!!this.crazyGamesUser && this.userMeResponse !== null) ); } protected modalConfig() { if (this.isLoadingUser || !this.isLinkedAccount()) { return {}; } return { tabs: [ { key: "account", label: translateText("account_modal.tab_account") }, { key: "stats", label: translateText("account_modal.tab_stats") }, { key: "games", label: translateText("account_modal.tab_games") }, { key: "friends", label: translateText("account_modal.tab_friends") }, { key: "settings", label: translateText("account_modal.tab_settings") }, ], }; } protected renderBody(tab: string) { if (this.isLoadingUser) { return this.renderLoadingSpinner( translateText("account_modal.fetching_account"), ); } if (!this.isLinkedAccount()) { return html`
${crazyGamesSDK.isOnCrazyGames() ? this.renderCrazyGamesSignIn() : this.renderLoginOptions()}
`; } return html`
${this.renderTab(tab)}
`; } private renderTab(tab: string): TemplateResult { switch (tab) { case "stats": return this.renderStatsTab(); case "games": return this.renderGamesTab(); case "friends": return this.renderFriendsTab(); case "settings": return this.renderSettingsTab(); default: return this.renderAccountTab(); } } // Persistent marketing-consent control (client-driven consent). Mirrors the // post-login toast: a player can turn email updates on/off any time here, or // — when there's no verified email on the account — is told to link one. private renderSettingsTab(): TemplateResult { const consent = this.userMeResponse?.player?.marketingConsent; // The API didn't return consent state (older backend). The tab is always // shown, but with nothing to configure it stays empty rather than showing // a misleading "link an email" prompt. if (!consent) return html``; const hasEmail = consent.hasEmail; const on = consent.consented === "approved"; return html`
${translateText("account_modal.marketing_title")}
${hasEmail ? translateText("account_modal.marketing_desc") : translateText("account_modal.marketing_no_email")}
${hasEmail ? html`` : nothing}
${hasEmail ? nothing : this.renderEmailBinding()}
`; } // No verified email on the account yet. Offer both ways to attach one: // a magic link to a plain email (the backend associates a not-yet-registered // email with the current session — the "new-association" path), or linking a // Google account. Reuses the login form's email field/handlers. private renderEmailBinding(): TemplateResult { return html`
${this.renderEmailField()}
${translateText("account_modal.or")}
${this.renderLinkGoogleButton()}
`; } // Shared email input + "get magic link" button, used by both the sign-in form // and the Account Settings bind-an-email state so their styling and handlers // stay in sync. private renderEmailField(): TemplateResult { return html` `; } private async setConsent(consented: boolean): Promise { const consent = this.userMeResponse?.player?.marketingConsent; if (!consent || this.consentBusy) return; const previous = consent.consented; const next = consented ? "approved" : "denied"; if (previous === next) return; // Optimistic: reflect the new state immediately, revert if the request fails. this.consentBusy = true; consent.consented = next; this.requestUpdate(); const ok = await setMarketingConsent(consented); if (!ok) { consent.consented = previous; } this.consentBusy = false; this.requestUpdate(); } private renderFriendsTab(): TemplateResult { const myPublicId = this.userMeResponse?.player?.publicId ?? ""; return html``; } private renderAccountTab(): TemplateResult { if (this.crazyGamesUser) { return this.renderCrazyGamesAccount(this.crazyGamesUser); } return html`
${translateText("account_modal.connected_as")}
${this.renderLoggedInAs()}
${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()}
`; } // CrazyGames "connected as" view: avatar + username from the SDK, plus // currency/subscription. No Discord/Google/email link or logout (CrazyGames // owns the account and its logout). private renderCrazyGamesAccount(user: CrazyGamesUser): TemplateResult { return html`
${translateText("account_modal.connected_as")}
${user.username}
${user.username}
${this.renderCurrency()}
${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()}
`; } // Shown when a CrazyGames guest opens the modal: hand off to CrazyGames' own // sign-in prompt (no Discord/Google/email on CrazyGames). private renderCrazyGamesSignIn(): TemplateResult { return html`

${translateText("account_modal.sign_in_desc")}

`; } private renderStatsTab(): TemplateResult { if (!this.hasAnyStats()) { return this.renderEmptyState( "📊", translateText("account_modal.no_stats"), ); } return html`

📊 ${translateText("account_modal.stats_overview")}

`; } private renderGamesTab(): TemplateResult { const publicId = this.userMeResponse?.player?.publicId ?? ""; if (!publicId) { return this.renderEmptyState( "🎮", translateText("account_modal.no_games"), ); } return html` ) => { this.gameHistoryCache = e.detail; }} @view-game=${(e: CustomEvent<{ gameId: string }>) => void this.viewGame(e.detail.gameId)} > `; } private renderEmptyState(icon: string, message: string): TemplateResult { return html`
${icon}

${message}

`; } private renderRewardsPanel(): TemplateResult | "" { const rewards = this.userMeResponse?.player?.rewards ?? []; if (rewards.length === 0) return ""; return html``; } // A claim moved unclaimed rewards into the balances; both were returned by // the claim endpoint, so update in place instead of re-fetching /users/@me. private handleRewardsChanged = ( event: CustomEvent, ): void => { if (!this.userMeResponse) return; this.userMeResponse.player.rewards = event.detail.rewards; if (event.detail.currency) { this.userMeResponse.player.currency = event.detail.currency; } this.requestUpdate(); }; private renderSubscriptionPanel(): TemplateResult | "" { const sub = this.userMeResponse?.player?.subscription; if (!sub) return ""; const cosmetic = this.cosmetics?.subscriptions?.[sub.tier] ?? null; return html``; } private renderCurrency(): TemplateResult { const currency = this.userMeResponse?.player?.currency; if (!currency) return html``; return html` `; } private renderLoggedInAs(): TemplateResult { const me = this.userMeResponse?.user; if (me?.discord) { return html`
${this.renderCurrency()} ${this.renderGoogleLink()} ${this.renderLogoutButton()}
`; } else if (me?.google) { return html`
${translateText("account_modal.linked_account", { account_name: me.google.email, })}
${this.renderCurrency()} ${this.renderLogoutButton()}
`; } else if (me?.email) { return html`
${translateText("account_modal.linked_account", { account_name: me.email, })}
${this.renderCurrency()} ${this.renderGoogleLink()} ${this.renderLogoutButton()}
`; } return html``; } // Show the Google link state: a confirmation line when a Google account is // already linked, otherwise the button to link one. private renderGoogleLink(): TemplateResult { const google = this.userMeResponse?.user?.google; if (google) { const label = google.email ? translateText("account_modal.linked_to_google_email", { email: google.email, }) : translateText("account_modal.linked_to_google"); return html`
${translateText("account_modal.google_alt")} ${label}
`; } return this.renderLinkGoogleButton(); } // Shown when logged in without a Google identity yet. Lets the user attach // Google to their existing account (we never auto-merge by email). private renderLinkGoogleButton(): TemplateResult { if (this.userMeResponse?.user?.google) return html``; return html` `; } private async viewGame(gameId: string): Promise { this.close(); const encodedGameId = encodeURIComponent(gameId); const newUrl = `/${ClientEnv.workerPath(gameId)}/game/${encodedGameId}`; history.pushState({ join: gameId }, "", newUrl); window.dispatchEvent( new CustomEvent("join-changed", { detail: { gameId: encodedGameId } }), ); } private renderLogoutButton(): TemplateResult { return html` `; } private renderLoginOptions() { return html`

${translateText("account_modal.sign_in_desc")}

${this.renderCurrency()}
${translateText("account_modal.or")}
${this.renderEmailField()}
`; } private handleEmailInput(e: Event) { const target = e.target as HTMLInputElement; this.email = target.value; } private async handleSubmit() { if (!this.email) { alert(translateText("account_modal.enter_email_address")); return; } const success = await sendMagicLink(this.email); if (success) { alert( translateText("account_modal.recovery_email_sent", { email: this.email, }), ); } else { alert(translateText("account_modal.failed_to_send_recovery_email")); } } // CrazyGames sign-in: after their prompt completes, exchange the new token // for a session and refresh the modal so it shows the signed-in profile. private async handleCrazyGamesSignIn() { await crazyGamesSDK.showAuthPrompt(); const profile = await crazyGamesSDK.getUserProfile(); if (!profile) return; // prompt cancelled / still not signed in invalidateUserMe(); await reauthAfterCrazyGamesChange(); const userMe = await getUserMe(); if (userMe) this.userMeResponse = userMe; this.crazyGamesUser = profile; this.requestUpdate(); } private handleDiscordLogin() { discordLogin(); } private handleGoogleLogin() { googleLogin(); } private async handleLinkGoogle(): Promise { // On success linkGoogle navigates to Google; the result comes back as a // `link=...` router arg handled in handleLinkResult. A false return means we // couldn't start it. const started = await linkGoogle(); if (!started) { alert(translateText("account_modal.link_google_failed")); } } // The Google link callback returns us to #modal=account&link=, so the // router reopens this modal with a `link` arg. Surface the outcome, then strip // the one-shot param from the URL so a refresh/re-open doesn't replay it. private handleLinkResult(args?: Record): void { const link = typeof args?.link === "string" ? args.link : undefined; if (link === undefined) return; // replaceState doesn't fire hashchange, so removing the param won't re-route. const params = new URLSearchParams(window.location.hash.slice(1)); params.delete("link"); const rest = params.toString(); history.replaceState( null, "", rest ? `#${rest}` : window.location.pathname + window.location.search, ); // Defer so the modal paints before the (blocking) alert. "cancel" needs no // feedback — the user chose to back out. if (link === "google") { setTimeout( () => alert(translateText("account_modal.link_google_success")), 0, ); } else if (link === "already_linked") { setTimeout( () => alert(translateText("account_modal.link_google_already_linked")), 0, ); } else if (link === "error") { setTimeout( () => alert(translateText("account_modal.link_google_error")), 0, ); } } protected onOpen(args?: Record): void { this.isLoadingUser = true; this.handleLinkResult(args); this.refreshCrazyGamesUser(); void fetchCosmetics().then((cosmetics) => { this.cosmetics = cosmetics; this.requestUpdate(); }); void getUserMe() .then((userMe) => { if (userMe) { this.userMeResponse = userMe; if (this.userMeResponse?.player?.publicId) { this.loadPlayerProfile(this.userMeResponse.player.publicId); } } this.isLoadingUser = false; this.requestUpdate(); }) .catch((err) => { console.warn("Failed to fetch user info in AccountModal.open():", err); this.isLoadingUser = false; this.requestUpdate(); }); this.requestUpdate(); } protected onClose(): void { this.dispatchEvent( new CustomEvent("close", { bubbles: true, composed: true }), ); } private async handleLogout() { await logOut(); this.close(); // Refresh the page after logout to update the UI state window.location.reload(); } private async loadPlayerProfile(publicId: string): Promise { try { const data = await fetchPlayerById(publicId); if (!data) { this.requestUpdate(); return; } this.statsTree = data.stats; this.requestUpdate(); } catch (err) { console.warn("Failed to load player data:", err); this.requestUpdate(); } } }