diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index d757f5136..cfc17f5ff 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -4,8 +4,13 @@ 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 } from "./Api"; -import { discordLogin, logOut, sendMagicLink } from "./Auth"; +import { fetchPlayerById, getUserMe, invalidateUserMe } from "./Api"; +import { + discordLogin, + logOut, + reauthAfterCrazyGamesChange, + sendMagicLink, +} from "./Auth"; import "./components/baseComponents/stats/DiscordUserHeader"; import "./components/baseComponents/stats/PlayerGameHistoryView"; import type { PlayerGameHistoryCache } from "./components/baseComponents/stats/PlayerGameHistoryView"; @@ -19,6 +24,7 @@ import "./components/FriendsList"; import "./components/SubscriptionPanel"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics, SUBSCRIPTIONS_ENABLED } from "./Cosmetics"; +import { crazyGamesSDK, type CrazyGamesUser } from "./CrazyGamesSDK"; import { translateText } from "./Utils"; @customElement("account-modal") @@ -27,6 +33,9 @@ export class AccountModal extends BaseModal { @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; private userMeResponse: UserMeResponse | null = null; private cosmetics: Cosmetics | null = null; @@ -38,6 +47,9 @@ export class AccountModal extends BaseModal { 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; @@ -58,6 +70,16 @@ export class AccountModal extends BaseModal { }); } + // 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 @@ -99,7 +121,13 @@ export class AccountModal extends BaseModal { private isLinkedAccount(): boolean { const me = this.userMeResponse?.user; - return !!(me?.discord ?? me?.email); + // 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?.email) || + (!!this.crazyGamesUser && this.userMeResponse !== null) + ); } protected modalConfig() { @@ -124,7 +152,9 @@ export class AccountModal extends BaseModal { } if (!this.isLinkedAccount()) { return html`
- ${this.renderLoginOptions()} + ${crazyGamesSDK.isOnCrazyGames() + ? this.renderCrazyGamesSignIn() + : this.renderLoginOptions()}
`; } return html` @@ -153,6 +183,9 @@ export class AccountModal extends BaseModal { } private renderAccountTab(): TemplateResult { + if (this.crazyGamesUser) { + return this.renderCrazyGamesAccount(this.crazyGamesUser); + } return html`
@@ -175,6 +208,59 @@ export class AccountModal extends BaseModal { `; } + // 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.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( @@ -418,6 +504,20 @@ export class AccountModal extends BaseModal { } } + // 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(); } @@ -425,6 +525,8 @@ export class AccountModal extends BaseModal { protected onOpen(): void { this.isLoadingUser = true; + this.refreshCrazyGamesUser(); + if (SUBSCRIPTIONS_ENABLED) { void fetchCosmetics().then((cosmetics) => { this.cosmetics = cosmetics; diff --git a/src/client/Auth.ts b/src/client/Auth.ts index be0899146..5f2fd8632 100644 --- a/src/client/Auth.ts +++ b/src/client/Auth.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas"; import { base64urlToUuid } from "../core/Base64"; import { getApiBase, getAudience } from "./Api"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; import { generateCryptoRandomUUID } from "./Utils"; export type UserAuth = { jwt: string; claims: TokenPayload } | false; @@ -154,6 +155,15 @@ async function refreshJwt(): Promise { } async function doRefreshJwt(): Promise { + if (crazyGamesSDK.isOnCrazyGames()) { + const token = await crazyGamesSDK.getUserToken(); + if (token) { + // Signed-in CrazyGames account: exchange their token for our session. + // No CrazyGames account / not signed in falls through to the guest flow + // below. + return doCrazyGamesLogin(token); + } + } try { console.log("Refreshing jwt"); const response = await fetch(getApiBase() + "/auth/refresh", { @@ -178,6 +188,56 @@ async function doRefreshJwt(): Promise { } } +// Exchange a CrazyGames user token for our session. On CrazyGames the refresh +// cookie isn't usable (SameSite=Lax, cross-site iframe), so we re-exchange on +// expiry instead of hitting /auth/refresh. +async function doCrazyGamesLogin(token: string): Promise { + try { + console.log("Logging in with CrazyGames"); + const response = await fetch(getApiBase() + "/auth/crazygames", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + if (response.status !== 200) { + console.error("CrazyGames login failed", response); + __jwt = null; + return; + } + const json = await response.json(); + const { jwt, expiresIn } = json; + __expiresAt = Date.now() + expiresIn * 1000; + console.log("CrazyGames login succeeded"); + __jwt = jwt; + } catch (e) { + console.error("CrazyGames login failed", e); + __jwt = null; + } +} + +// Called when the CrazyGames auth state changes mid-session (e.g. the player +// signs in): drop the cached session so userAuth() re-exchanges the new token. +// Single-flight: Main's auth listener and the account modal's sign-in handler +// can both react to the same sign-in; sharing one exchange keeps them from +// racing on __jwt. Any refresh already in flight is allowed to settle first so +// its stale result can't satisfy the reauth. +let __reauthPromise: Promise | null = null; +export async function reauthAfterCrazyGamesChange(): Promise { + __reauthPromise ??= (async () => { + try { + if (__refreshPromise) { + await __refreshPromise.catch(() => {}); + } + __jwt = null; + __expiresAt = 0; + return await userAuth(); + } finally { + __reauthPromise = null; + } + })(); + return __reauthPromise; +} + export async function sendMagicLink(email: string): Promise { try { const apiBase = getApiBase(); diff --git a/src/client/CrazyGamesAccountButton.ts b/src/client/CrazyGamesAccountButton.ts new file mode 100644 index 000000000..60a9c68ce --- /dev/null +++ b/src/client/CrazyGamesAccountButton.ts @@ -0,0 +1,102 @@ +import { crazyGamesSDK } from "./CrazyGamesSDK"; +import { closeMobileSidebar } from "./Navigation"; +import { translateText } from "./Utils"; + +// On CrazyGames the player's identity comes from the CrazyGames SDK, not our +// backend user object. Show their avatar + username when signed in (clicking +// opens the account modal), or a "Sign in" affordance that opens CrazyGames' +// own auth prompt when they're a guest. Applies to every account entry point: +// the desktop nav pill, the mobile hamburger item, and the homepage top bar +// (which layout is visible depends on viewport width). +export async function updateCrazyGamesNavButton() { + if (!crazyGamesSDK.isOnCrazyGames()) return; + const profile = await crazyGamesSDK.getUserProfile(); + const signInText = translateText("main.sign_in"); + + // Bypass the data-page router (which would open the account modal) and hand + // off to CrazyGames' own sign-in prompt instead. stopPropagation also skips + // the router's sidebar cleanup, so close it ourselves. + const promptSignIn = (e: Event) => { + e.stopPropagation(); + e.preventDefault(); + closeMobileSidebar(); + void crazyGamesSDK.showAuthPrompt(); + }; + + // Desktop nav pill: avatar + person icon + text. + const desktopButton = document.getElementById( + "nav-account-button", + ) as HTMLButtonElement | null; + const avatarEl = document.getElementById( + "nav-account-avatar", + ) as HTMLImageElement | null; + const personIconEl = document.getElementById("nav-account-person-icon"); + // CrazyGames accounts have no email, so the email badge is always hidden. + document.getElementById("nav-account-email-badge")?.classList.add("hidden"); + const signInTextEl = document.getElementById("nav-account-signin-text"); + if (profile) { + if (avatarEl) { + avatarEl.alt = profile.username; + avatarEl.src = profile.profilePictureUrl; + avatarEl.classList.remove("hidden"); + } + personIconEl?.classList.add("hidden"); + if (signInTextEl) { + // The translation pass rewrites every [data-i18n] element's text, which + // would clobber the username — drop the attribute while it holds one. + signInTextEl.removeAttribute("data-i18n"); + signInTextEl.textContent = profile.username; + signInTextEl.classList.remove("hidden"); + } + desktopButton?.classList.remove("border", "border-white/20"); + if (desktopButton) desktopButton.onclick = null; + } else { + avatarEl?.classList.add("hidden"); + personIconEl?.classList.remove("hidden"); + if (signInTextEl) { + // Restore so language changes keep the label translated. + signInTextEl.setAttribute("data-i18n", "main.sign_in"); + signInTextEl.textContent = signInText; + signInTextEl.classList.remove("hidden"); + } + desktopButton?.classList.add("border", "border-white/20"); + if (desktopButton) desktopButton.onclick = promptSignIn; + } + + // Mobile hamburger menu item: text only. Same data-i18n handling as above. + const mobileButton = document.getElementById( + "mobile-nav-account-button", + ) as HTMLButtonElement | null; + if (mobileButton) { + if (profile) { + mobileButton.removeAttribute("data-i18n"); + mobileButton.textContent = profile.username; + } else { + mobileButton.setAttribute("data-i18n", "main.sign_in"); + mobileButton.textContent = signInText; + } + mobileButton.onclick = profile ? null : promptSignIn; + } + + // Homepage top bar (narrow layout): avatar or person icon only. + const topBarButton = document.getElementById( + "crazygames-account-btn", + ) as HTMLButtonElement | null; + const topBarAvatar = document.getElementById( + "crazygames-account-avatar", + ) as HTMLImageElement | null; + const topBarIcon = document.getElementById("crazygames-account-icon"); + if (profile) { + if (topBarAvatar) { + topBarAvatar.alt = profile.username; + topBarAvatar.src = profile.profilePictureUrl; + topBarAvatar.classList.remove("hidden"); + } + topBarIcon?.classList.add("hidden"); + if (topBarButton) topBarButton.onclick = null; + } else { + topBarAvatar?.classList.add("hidden"); + topBarIcon?.classList.remove("hidden"); + if (topBarButton) topBarButton.onclick = promptSignIn; + } +} diff --git a/src/client/CrazyGamesSDK.ts b/src/client/CrazyGamesSDK.ts index ae2d5bf1d..5c938fcdd 100644 --- a/src/client/CrazyGamesSDK.ts +++ b/src/client/CrazyGamesSDK.ts @@ -1,18 +1,20 @@ +export interface CrazyGamesUser { + username: string; + profilePictureUrl: string; +} + declare global { interface Window { CrazyGames?: { SDK: { init: () => Promise; user: { - getUser(): Promise<{ - username: string; - } | null>; + isUserAccountAvailable: boolean; + getUser(): Promise; + getUserToken(): Promise; + showAuthPrompt(): Promise; addAuthListener: ( - listener: ( - user: { - username: string; - } | null, - ) => void, + listener: (user: CrazyGamesUser | null) => void, ) => void; }; ad: { @@ -58,8 +60,10 @@ declare global { export class CrazyGamesSDK { private initialized = false; private isGameplayActive = false; - private readyPromise: Promise; - private resolveReady!: () => void; + // Resolves true once the SDK initialized, false once init definitively + // failed (not on CrazyGames, SDK never loaded, init threw). + private readyPromise: Promise; + private resolveReady!: (ready: boolean) => void; constructor() { this.readyPromise = new Promise((resolve) => { @@ -72,9 +76,17 @@ export class CrazyGamesSDK { setTimeout(() => resolve(false), 3000); }); - const ready = this.readyPromise.then(() => true); + return Promise.race([this.readyPromise, timeout]); + } - return Promise.race([ready, timeout]); + // Like ready() but without the 3s cap: waits for maybeInit() to actually + // finish (SDK load alone can take ~10s on a slow network). Use this for + // auth-critical calls, where a premature false logs the player out. + private whenReady(): Promise { + if (!this.isOnCrazyGames()) { + return Promise.resolve(false); + } + return this.readyPromise; } isOnCrazyGames(): boolean { @@ -110,6 +122,7 @@ export class CrazyGamesSDK { if (!this.isOnCrazyGames()) { console.log("Not running on CrazyGames platform, not initializing SDK"); + this.resolveReady(false); return; } @@ -122,16 +135,18 @@ export class CrazyGamesSDK { if (typeof window.CrazyGames === "undefined") { console.warn("CrazyGames SDK not available"); + this.resolveReady(false); return; } try { await window.CrazyGames.SDK.init(); this.initialized = true; - this.resolveReady(); + this.resolveReady(true); console.log("CrazyGames SDK initialized"); } catch (error) { console.error("Failed to initialize CrazyGames SDK:", error); + this.resolveReady(false); } } @@ -148,14 +163,55 @@ export class CrazyGamesSDK { } } + // Returns a fresh CrazyGames-signed user token to exchange with our backend, + // or null if accounts aren't available here or no user is signed in. + // CrazyGames recommends fetching this fresh each time rather than caching it. + async getUserToken(): Promise { + if (!(await this.whenReady())) { + return null; + } + try { + if (!window.CrazyGames!.SDK.user.isUserAccountAvailable) { + return null; + } + return await window.CrazyGames!.SDK.user.getUserToken(); + } catch (e) { + console.log("error getting CrazyGames user token: ", e); + return null; + } + } + + // Returns the signed-in CrazyGames user (username + avatar), or null if + // accounts aren't available here or no user is signed in. + async getUserProfile(): Promise { + if (!(await this.whenReady())) { + return null; + } + try { + return await window.CrazyGames!.SDK.user.getUser(); + } catch (e) { + console.log("error getting CrazyGames user: ", e); + return null; + } + } + + // Opens CrazyGames' own sign-in prompt. On success the auth listener fires, + // which drives our re-auth. Resolves regardless of outcome (e.g. cancelled). + async showAuthPrompt(): Promise { + if (!(await this.whenReady())) { + return; + } + try { + await window.CrazyGames!.SDK.user.showAuthPrompt(); + } catch (e) { + console.log("CrazyGames auth prompt dismissed: ", e); + } + } + async addAuthListener( - listener: ( - user: { - username: string; - } | null, - ) => void, + listener: (user: CrazyGamesUser | null) => void, ): Promise { - if (!(await this.ready())) { + if (!(await this.whenReady())) { return; } diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index b1dc74cb0..66dca4536 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -11,7 +11,6 @@ import { } from "../core/game/Game"; import { PublicGameInfo, PublicGames } from "../core/Schemas"; import "./components/IOSAddToHomeScreenBanner"; -import { crazyGamesSDK } from "./CrazyGamesSDK"; import { HostLobbyModal } from "./HostLobbyModal"; import { JoinLobbyModal } from "./JoinLobbyModal"; import { PublicLobbySocket } from "./LobbySocket"; @@ -145,13 +144,11 @@ export class GameModeSelector extends LitElement { this.openHostLobby, "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", )} - ${!crazyGamesSDK.isOnCrazyGames() - ? this.renderSmallActionCard( - translateText("mode_selector.ranked_title"), - this.openRankedMenu, - "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", - ) - : html``} + ${this.renderSmallActionCard( + translateText("mode_selector.ranked_title"), + this.openRankedMenu, + "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", + )} ${this.renderSmallActionCard( translateText("main.join"), this.openJoinLobby, @@ -159,7 +156,9 @@ export class GameModeSelector extends LitElement { )}
- + ${this.lobbies === null @@ -225,13 +224,11 @@ export class GameModeSelector extends LitElement { this.openHostLobby, "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", )} - ${!crazyGamesSDK.isOnCrazyGames() - ? this.renderSmallActionCard( - translateText("mode_selector.ranked_title"), - this.openRankedMenu, - "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", - ) - : html``} + ${this.renderSmallActionCard( + translateText("mode_selector.ranked_title"), + this.openRankedMenu, + "bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]", + )} ${this.renderSmallActionCard( translateText("main.join"), this.openJoinLobby, diff --git a/src/client/Main.ts b/src/client/Main.ts index 9601ea3b2..352510500 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -15,10 +15,11 @@ import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; import { getUserMe, invalidateUserMe } from "./Api"; -import { userAuth } from "./Auth"; +import { reauthAfterCrazyGamesChange, userAuth } from "./Auth"; import "./ClanModal"; import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner"; import { getPlayerCosmeticsRefs } from "./Cosmetics"; +import { updateCrazyGamesNavButton } from "./CrazyGamesAccountButton"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import "./FlagInput"; import { FlagInput } from "./FlagInput"; @@ -477,7 +478,11 @@ class Client { } const onUserMe = async (userMeResponse: UserMeResponse | false) => { - updateAccountNavButton(userMeResponse); + if (crazyGamesSDK.isOnCrazyGames()) { + void updateCrazyGamesNavButton(); + } else { + updateAccountNavButton(userMeResponse); + } const isAdFree = userMeResponse !== false && userMeResponse.player?.adfree === true; window.adsEnabled = !isAdFree && !crazyGamesSDK.isOnCrazyGames(); @@ -507,6 +512,15 @@ class Client { getUserMe().then(onUserMe); } + // Re-run auth when the player signs into CrazyGames mid-session. Logout + // reloads the page, so only login needs handling here. + crazyGamesSDK.addAuthListener(() => { + invalidateUserMe(); + reauthAfterCrazyGamesChange().then((result) => + result === false ? onUserMe(false) : getUserMe().then(onUserMe), + ); + }); + const settingsModal = document.querySelector( "user-setting", ) as UserSettingModal; @@ -1065,6 +1079,10 @@ const bootstrap = () => { // Also hide elements after a short delay to catch late-rendered components setTimeout(hideCrazyGamesElements, 100); setTimeout(hideCrazyGamesElements, 500); + + // Populate the CrazyGames account buttons once the nav/top-bar have rendered + // (onUserMe also refreshes them after auth and on mid-session sign-in). + setTimeout(() => void updateCrazyGamesNavButton(), 500); }; if (document.readyState === "loading") { diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts index 146a6cf1e..0447df5f5 100644 --- a/src/client/Matchmaking.ts +++ b/src/client/Matchmaking.ts @@ -7,6 +7,7 @@ import { getPlayToken } from "./Auth"; import { BaseModal } from "./components/BaseModal"; import "./components/Difficulties"; import { modalHeader } from "./components/ui/ModalHeader"; +import { crazyGamesSDK } from "./CrazyGamesSDK"; import { JoinLobbyEvent } from "./Main"; import { translateText } from "./Utils"; @@ -119,11 +120,20 @@ export class MatchmakingModal extends BaseModal { return; } - const isLoggedIn = - userMe && - userMe.user && - (userMe.user.discord !== undefined || userMe.user.email !== undefined); - if (!isLoggedIn) { + // CrazyGames players authenticate through the SDK rather than a linked + // Discord/email account, so a signed-in CrazyGames user counts as + // logged in for ranked. + const crazyGamesSignedIn = + crazyGamesSDK.isOnCrazyGames() && + (await crazyGamesSDK.getUserProfile()) !== null; + if (!this.isModalOpen) { + return; + } + + if ( + userMe === false || + (!hasLinkedAccount(userMe) && !crazyGamesSignedIn) + ) { window.dispatchEvent( new CustomEvent("show-message", { detail: { diff --git a/src/client/Navigation.ts b/src/client/Navigation.ts index 7878155dd..c4185db53 100644 --- a/src/client/Navigation.ts +++ b/src/client/Navigation.ts @@ -1,18 +1,18 @@ -export function initNavigation() { - const closeMobileSidebar = () => { - const sidebar = document.getElementById("sidebar-menu"); - const backdrop = document.getElementById("mobile-menu-backdrop"); - if (sidebar?.classList.contains("open")) { - sidebar.classList.remove("open"); - backdrop?.classList.remove("open"); - document.documentElement.classList.remove("overflow-hidden"); - sidebar.setAttribute("aria-hidden", "true"); - backdrop?.setAttribute("aria-hidden", "true"); - const hb = document.getElementById("hamburger-btn"); - if (hb) hb.setAttribute("aria-expanded", "false"); - } - }; +export function closeMobileSidebar() { + const sidebar = document.getElementById("sidebar-menu"); + const backdrop = document.getElementById("mobile-menu-backdrop"); + if (sidebar?.classList.contains("open")) { + sidebar.classList.remove("open"); + backdrop?.classList.remove("open"); + document.documentElement.classList.remove("overflow-hidden"); + sidebar.setAttribute("aria-hidden", "true"); + backdrop?.setAttribute("aria-hidden", "true"); + const hb = document.getElementById("hamburger-btn"); + if (hb) hb.setAttribute("aria-expanded", "false"); + } +} +export function initNavigation() { const showPage = (pageId: string) => { window.currentPageId = pageId; diff --git a/src/client/components/DesktopNavBar.ts b/src/client/components/DesktopNavBar.ts index 5786c8057..69dc271c1 100644 --- a/src/client/components/DesktopNavBar.ts +++ b/src/client/components/DesktopNavBar.ts @@ -148,14 +148,14 @@ export class DesktopNavBar extends LitElement {
diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts index ef33aa100..f758a3ece 100644 --- a/src/client/components/PlayPage.ts +++ b/src/client/components/PlayPage.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; import { assetUrl } from "../../core/AssetUrls"; +import { crazyGamesSDK } from "../CrazyGamesSDK"; import "./NewsBox"; @customElement("play-page") @@ -59,10 +60,44 @@ export class PlayPage extends LitElement { /> - + ${crazyGamesSDK.isOnCrazyGames() + ? html` + + ` + : html` + + `} diff --git a/src/client/components/RankedModal.ts b/src/client/components/RankedModal.ts index bfb831890..94cf987b1 100644 --- a/src/client/components/RankedModal.ts +++ b/src/client/components/RankedModal.ts @@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators.js"; import { UserMeResponse } from "../../core/ApiSchemas"; import { getUserMe, hasLinkedAccount } from "../Api"; import { userAuth } from "../Auth"; +import { crazyGamesSDK } from "../CrazyGamesSDK"; import { translateText } from "../Utils"; import { BaseModal } from "./BaseModal"; import { modalHeader } from "./ui/ModalHeader"; @@ -14,6 +15,14 @@ export class RankedModal extends BaseModal { @state() private elo: number | string = "..."; @state() private userMeResponse: UserMeResponse | false = false; @state() private errorMessage: string | null = null; + // CrazyGames players authenticate through the SDK, not a linked + // Discord/Google/email account, so track that separately for ranked. + @state() private crazyGamesSignedIn = false; + + // Eligible to see/play ranked: a linked account or a signed-in CrazyGames one. + private isRankedEligible(): boolean { + return hasLinkedAccount(this.userMeResponse) || this.crazyGamesSignedIn; + } constructor() { super(); @@ -50,7 +59,7 @@ export class RankedModal extends BaseModal { return; } - if (hasLinkedAccount(this.userMeResponse)) { + if (this.isRankedEligible()) { this.elo = this.userMeResponse && this.userMeResponse.player.leaderboard?.oneVone?.elo @@ -66,6 +75,9 @@ export class RankedModal extends BaseModal { try { const userMe = await getUserMe(); this.userMeResponse = userMe; + this.crazyGamesSignedIn = + crazyGamesSDK.isOnCrazyGames() && + (await crazyGamesSDK.getUserProfile()) !== null; } catch (error) { console.error("Failed to fetch user profile for ranked modal", error); this.userMeResponse = false; @@ -95,7 +107,7 @@ export class RankedModal extends BaseModal { ${this.renderCard( translateText("mode_selector.ranked_1v1_title"), this.errorMessage ?? - (hasLinkedAccount(this.userMeResponse) + (this.isRankedEligible() ? translateText("matchmaking_modal.elo", { elo: this.elo }) : translateText("mode_selector.ranked_title")), () => this.handleRanked(),