Files
OpenFrontIO/src/client/AccountModal.ts
T
bf61f5847b feat(rewards): claimable subscription rewards UI (#4566)
## Summary

Subscription perks (signup bonus + daily soft/hard currency) now land as
**unclaimed rewards** the player must explicitly claim, instead of
crediting balances directly (API side: openfrontio/infra#409). This PR
adds the client UI:

- **`RewardsPanel`** — shared Lit element listing pending rewards
(currency icon, `+amount`, server `note` with translated fallbacks for
known reasons), with per-reward **Claim** and **Claim All**. Both claim
endpoints return post-claim balances, so the wallet updates without
re-fetching `/users/@me`.
- **Account modal** — renders the panel above the subscription panel
(rewards survive subscription lapse, so it doesn't depend on an active
sub).
- **`RewardsModal`** — popup at login when rewards are pending. Reuses
`RewardsPanel`, auto-closes when the last reward is claimed. Only opens
on a clean homepage load (`pathname === "/"`, empty hash) so it never
pops over join links, `#modal=...` deep links, or the Stripe
`#purchase-completed` return — that flow reloads clean, so the signup
bonus popup appears right after purchase.
- **Schemas/API** — `RewardSchema` + `rewards[]` on `/users/@me`
(optional for older API versions), `claimReward` / `claimAllRewards` in
`Api.ts`. Reward `id`/`amount` stay opaque bigint strings; a 404 on
single claim means "already claimed elsewhere" (double-click / second
device) and re-syncs instead of erroring.

## Screenshots

Login popup (also embedded in the account modal):

- Panel: currency icon + amount + note per row, Claim / Claim All
buttons
- Amounts formatted via `BigInt` (can exceed `Number.MAX_SAFE_INTEGER`)

## Test plan

- [x] `npm test` — 161 tests pass, including new coverage for
`RewardSchema`, `/users/@me` with/without `rewards`, and both claim
response shapes
- [x] `tsc --noEmit`, ESLint, Prettier clean
- [x] Verified in the running app (Playwright): panel renders in the
account-modal style; single claim against a stubbed endpoint credits
balances and removes the row; Claim All empties the list and auto-closes
the popup; body scroll restored

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:21:52 -07:00

834 lines
29 KiB
TypeScript

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`
<div class="flex items-center gap-2">
<span
class="text-xs text-blue-400 font-bold uppercase tracking-wider"
>${translateText("account_modal.public_player_id")}</span
>
<copy-button
.lobbyId=${publicId}
.copyText=${publicId}
.displayText=${displayId}
></copy-button>
</div>
`
: 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`<div class="custom-scrollbar mr-1">
${crazyGamesSDK.isOnCrazyGames()
? this.renderCrazyGamesSignIn()
: this.renderLoginOptions()}
</div>`;
}
return html`
<div class="custom-scrollbar mr-1">
<div class="p-6">${this.renderTab(tab)}</div>
</div>
`;
}
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`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="text-white font-medium">
${translateText("account_modal.marketing_title")}
</div>
<div class="text-white/50 text-sm mt-1">
${hasEmail
? translateText("account_modal.marketing_desc")
: translateText("account_modal.marketing_no_email")}
</div>
</div>
${hasEmail
? html`<button
role="switch"
aria-checked=${on ? "true" : "false"}
aria-label=${translateText("account_modal.marketing_title")}
?disabled=${this.consentBusy}
@click=${() => this.setConsent(!on)}
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 disabled:opacity-60 ${on
? "bg-malibu-blue shadow-[var(--shadow-malibu-blue-pill)]"
: "bg-white/15"}"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ${on
? "translate-x-6"
: "translate-x-1"}"
></span>
</button>`
: nothing}
</div>
${hasEmail ? nothing : this.renderEmailBinding()}
</div>
`;
}
// 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`
<div class="mt-4 space-y-3">
${this.renderEmailField()}
<div class="flex items-center gap-4 py-1">
<div class="h-px bg-white/10 flex-1"></div>
<span
class="text-[10px] uppercase tracking-widest text-white/30 font-bold"
>
${translateText("account_modal.or")}
</span>
<div class="h-px bg-white/10 flex-1"></div>
</div>
${this.renderLinkGoogleButton()}
</div>
`;
}
// 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`
<input
type="email"
.value=${this.email}
@input=${this.handleEmailInput}
placeholder=${translateText("account_modal.email_placeholder")}
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"
/>
<o-button
variant="primary"
width="block"
size="md"
translationKey="account_modal.get_magic_link"
@click=${this.handleSubmit}
></o-button>
`;
}
private async setConsent(consented: boolean): Promise<void> {
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`<friends-list .myPublicId=${myPublicId}></friends-list>`;
}
private renderAccountTab(): TemplateResult {
if (this.crazyGamesUser) {
return this.renderCrazyGamesAccount(this.crazyGamesUser);
}
return html`
<div class="flex flex-col gap-6">
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<div class="flex flex-col items-center gap-4">
<div
class="text-xs text-white/40 uppercase tracking-widest font-bold border-b border-white/5 pb-2 px-8"
>
${translateText("account_modal.connected_as")}
</div>
<div class="flex items-center gap-8 justify-center flex-wrap">
<discord-user-header
.data=${this.userMeResponse?.user?.discord ?? null}
></discord-user-header>
${this.renderLoggedInAs()}
</div>
</div>
</div>
${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()}
</div>
`;
}
// 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`
<div class="flex flex-col gap-6">
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<div class="flex flex-col items-center gap-4">
<div
class="text-xs text-white/40 uppercase tracking-widest font-bold border-b border-white/5 pb-2 px-8"
>
${translateText("account_modal.connected_as")}
</div>
<div class="flex flex-col items-center gap-3">
<img
src=${user.profilePictureUrl}
alt=${user.username}
class="w-16 h-16 rounded-full object-cover"
referrerpolicy="no-referrer"
/>
<div class="text-white text-lg font-medium">${user.username}</div>
${this.renderCurrency()}
</div>
</div>
</div>
${this.renderRewardsPanel()} ${this.renderSubscriptionPanel()}
</div>
`;
}
// 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`
<div class="flex items-center justify-center p-6 min-h-full">
<div
class="w-full max-w-md bg-white/5 rounded-2xl border border-white/10 p-8 text-center"
>
<p class="text-white/50 text-sm font-medium mb-6">
${translateText("account_modal.sign_in_desc")}
</p>
<o-button
variant="primary"
width="block"
size="md"
translationKey="main.sign_in"
@click=${this.handleCrazyGamesSignIn}
></o-button>
</div>
</div>
`;
}
private renderStatsTab(): TemplateResult {
if (!this.hasAnyStats()) {
return this.renderEmptyState(
"📊",
translateText("account_modal.no_stats"),
);
}
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<h3 class="text-lg font-bold text-white mb-4 flex items-center gap-2">
<span class="text-blue-400">📊</span>
${translateText("account_modal.stats_overview")}
</h3>
<player-stats-tree-view
.statsTree=${this.statsTree}
></player-stats-tree-view>
</div>
`;
}
private renderGamesTab(): TemplateResult {
const publicId = this.userMeResponse?.player?.publicId ?? "";
if (!publicId) {
return this.renderEmptyState(
"🎮",
translateText("account_modal.no_games"),
);
}
return html`
<player-game-history-view
.publicId=${publicId}
.cachedState=${this.gameHistoryCache?.publicId === publicId
? this.gameHistoryCache
: null}
@history-updated=${(e: CustomEvent<PlayerGameHistoryCache>) => {
this.gameHistoryCache = e.detail;
}}
@view-game=${(e: CustomEvent<{ gameId: string }>) =>
void this.viewGame(e.detail.gameId)}
></player-game-history-view>
`;
}
private renderEmptyState(icon: string, message: string): TemplateResult {
return html`
<div
class="bg-white/5 rounded-xl border border-white/10 p-12 flex flex-col items-center justify-center text-center"
>
<div class="text-4xl mb-3">${icon}</div>
<p class="text-white/60 text-sm">${message}</p>
</div>
`;
}
private renderRewardsPanel(): TemplateResult | "" {
const rewards = this.userMeResponse?.player?.rewards ?? [];
if (rewards.length === 0) return "";
return html`<rewards-panel
.rewards=${rewards}
@rewards-changed=${this.handleRewardsChanged}
></rewards-panel>`;
}
// 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<RewardsChangedDetail>,
): 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`<subscription-panel
.sub=${sub}
.cosmetic=${cosmetic}
></subscription-panel>`;
}
private renderCurrency(): TemplateResult {
const currency = this.userMeResponse?.player?.currency;
if (!currency) return html``;
return html`
<currency-display
.hard=${currency.hard}
.soft=${currency.soft}
></currency-display>
`;
}
private renderLoggedInAs(): TemplateResult {
const me = this.userMeResponse?.user;
if (me?.discord) {
return html`
<div class="flex flex-col items-center gap-3 w-full">
${this.renderCurrency()} ${this.renderGoogleLink()}
${this.renderLogoutButton()}
</div>
`;
} else if (me?.google) {
return html`
<div class="flex flex-col items-center gap-3 w-full">
<div class="text-white text-lg font-medium">
${translateText("account_modal.linked_account", {
account_name: me.google.email,
})}
</div>
${this.renderCurrency()} ${this.renderLogoutButton()}
</div>
`;
} else if (me?.email) {
return html`
<div class="flex flex-col items-center gap-3 w-full">
<div class="text-white text-lg font-medium">
${translateText("account_modal.linked_account", {
account_name: me.email,
})}
</div>
${this.renderCurrency()} ${this.renderGoogleLink()}
${this.renderLogoutButton()}
</div>
`;
}
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`
<div class="flex items-center gap-2 text-white/70 text-sm">
<img
src=${assetUrl("images/GoogleLogo.svg")}
alt=${translateText("account_modal.google_alt")}
class="w-4 h-4"
/>
<span>${label}</span>
</div>
`;
}
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`
<button
@click=${this.handleLinkGoogle}
class="w-full px-6 py-3 text-[#1f1f1f] bg-white hover:bg-[#f7f8f8] border border-[#dadce0] rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4285F4] transition-colors duration-200 flex items-center justify-center gap-3 shadow-lg"
>
<img
src=${assetUrl("images/GoogleLogo.svg")}
alt=${translateText("account_modal.google_alt")}
class="w-5 h-5"
/>
<span class="font-bold tracking-wide"
>${translateText("account_modal.link_google")}</span
>
</button>
`;
}
private async viewGame(gameId: string): Promise<void> {
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`
<o-button
variant="danger"
size="md"
translationKey="account_modal.log_out"
@click=${this.handleLogout}
></o-button>
`;
}
private renderLoginOptions() {
return html`
<div class="flex items-center justify-center p-6 min-h-full">
<div
class="w-full max-w-md bg-white/5 rounded-2xl border border-white/10 p-8"
>
<div class="text-center mb-8">
<div
class="w-16 h-16 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-2xl flex items-center justify-center mx-auto mb-6 border border-white/10 shadow-inner"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-8 h-8 text-blue-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10 17 15 12 10 7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
</div>
<p class="text-white/50 text-sm font-medium">
${translateText("account_modal.sign_in_desc")}
</p>
${this.renderCurrency()}
</div>
<div class="space-y-6">
<!-- Discord Login Button -->
<button
@click="${this.handleDiscordLogin}"
class="w-full px-6 py-4 text-white bg-[#5865F2] hover:bg-[#4752C4] border border-transparent rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-colors duration-200 flex items-center justify-center gap-3 group relative overflow-hidden shadow-lg hover:shadow-[#5865F2]/20"
>
<img
src=${assetUrl("images/DiscordLogo.svg")}
alt="Discord"
class="w-6 h-6 relative z-10"
/>
<span class="font-bold relative z-10 tracking-wide"
>${translateText("main.login_discord") ||
translateText("account_modal.link_discord")}</span
>
</button>
<!-- Google Login Button (Google brand guidelines: white surface,
dark text, the multicolor "G" mark) -->
<button
@click="${this.handleGoogleLogin}"
class="w-full px-6 py-4 text-[#1f1f1f] bg-white hover:bg-[#f7f8f8] border border-[#dadce0] rounded-xl focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4285F4] transition-colors duration-200 flex items-center justify-center gap-3 group relative overflow-hidden shadow-lg"
>
<img
src=${assetUrl("images/GoogleLogo.svg")}
alt=${translateText("account_modal.google_alt")}
class="w-6 h-6 relative z-10"
/>
<span class="font-bold relative z-10 tracking-wide"
>${translateText("main.login_google")}</span
>
</button>
<!-- Divider -->
<div class="flex items-center gap-4 py-2">
<div class="h-px bg-white/10 flex-1"></div>
<span
class="text-[10px] uppercase tracking-widest text-white/30 font-bold"
>
${translateText("account_modal.or")}
</span>
<div class="h-px bg-white/10 flex-1"></div>
</div>
<!-- Email Recovery -->
<div class="space-y-3">${this.renderEmailField()}</div>
</div>
<div class="mt-8 text-center border-t border-white/10 pt-6">
<button
@click="${this.handleLogout}"
class="text-[10px] font-bold text-white/20 hover:text-red-400 transition-colors uppercase tracking-widest pb-0.5"
>
${translateText("account_modal.clear_session")}
</button>
</div>
</div>
</div>
`;
}
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<void> {
// 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=<result>, 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<string, unknown>): 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<string, unknown>): 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<void> {
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();
}
}
}