Files
OpenFrontIO/src/client/AccountModal.ts
T
Evan bcc453e8cf Add modal URL router (#modal=name&tab=key) (#3924)
## Description

Adds a modal URL router so modals can be opened, deep-linked, and
bookmarked via the hash. URLs of the form `#modal=<name>&tab=<key>&...`
open the named modal and pass remaining keys as args to `onOpen`. The
reverse direction also syncs: opening a modal via the UI updates the
URL, closing it clears the hash, and switching tabs updates `&tab=`.

Builds on the BaseModal refactor from #3923.

### What's new

**`ModalRouter.ts`** — small registry + two-way sync helper.
- `register(name, { tag, pageId? })` declares a modal as router-managed
- `routeFromHash()` parses `#modal=...` and dispatches to
`modal.open(args)`
- `syncOpened/syncClosed/syncTab` push state back into the URL via
`history.replaceState` (no history entries)
- A `routingFromUrl` flag prevents URL→modal→URL feedback loops
- Unknown modal names silently strip the hash

**`BaseModal`** — opt-in URL sync via a `routerName` property.
- When set, BaseModal calls into
`modalRouter.syncOpened/syncClosed/syncTab` from `open` / `close` /
`setActiveTab`
- Modals that own their own URL state (lobby modals) just leave
`routerName` undefined

**`Main.ts`** — registers all routable modals and wires the router.
- `handleUrl()`: adds a `modalRouter.routeFromHash()` branch after the
path-based lobby join
- `onHashUpdate`: when the hash is router-managed, routes via the router
instead of tearing down lobby state

### Routable modals

13 inline modals: store, settings, leaderboard, clan, account, help,
news, language, single-player, ranked, troubleshooting,
territory-patterns, flag-input.

Excluded by design: join-lobby, host-lobby (own URL state via
`/game/<id>`), matchmaking (no URL state).

### Example uses

- Deep link to store flags tab: `/#modal=store&tab=flags`
- Settings keybinds tab: `/#modal=settings&tab=keybinds`
- Cosmetics.ts now redirects to `#modal=store&tab=packs` when a
hard-currency purchase fails for insufficient plutonium (after the
alert), so users can top up directly

### URL behavior

- `replaceState` everywhere — no history entries added when modals open
/ close / switch tabs
- Browser back/forward still works for the existing path-based game flow
- `hashchange` events are router-aware so external hash changes (back
button, manual edit) correctly switch between routed modals without
tearing down lobby state

## Please complete the following:

- [x] I have added screenshots for all UI updates _(no visual changes;
smoke-tested in dev)_
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file _(no new user-visible strings)_
- [ ] I have added relevant tests to the test directory _(no test
coverage; manually tested URL load, UI open, tab switch, close,
hashchange, insufficient-plutonium redirect)_
- [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:

DISCORD_USERNAME
2026-05-14 16:49:44 -07:00

425 lines
14 KiB
TypeScript

import { html, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import {
PlayerGame,
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 "./components/baseComponents/stats/DiscordUserHeader";
import "./components/baseComponents/stats/GameList";
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/SubscriptionPanel";
import { modalHeader } from "./components/ui/ModalHeader";
import { fetchCosmetics } from "./Cosmetics";
import { translateText } from "./Utils";
@customElement("account-modal")
export class AccountModal extends BaseModal {
protected routerName = "account";
@state() private email: string = "";
@state() private isLoadingUser: boolean = false;
private userMeResponse: UserMeResponse | null = null;
private statsTree: PlayerStatsTree | null = null;
private recentGames: PlayerGame[] = [];
private cosmetics: Cosmetics | null = null;
constructor() {
super();
document.addEventListener("userMeResponse", (event: Event) => {
const customEvent = event as CustomEvent;
if (customEvent.detail) {
this.userMeResponse = customEvent.detail as UserMeResponse;
if (this.userMeResponse?.player?.publicId === undefined) {
this.statsTree = null;
this.recentGames = [];
}
} else {
this.statsTree = null;
this.recentGames = [];
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,
});
}
protected renderBody() {
if (this.isLoadingUser) {
return this.renderLoadingSpinner(
translateText("account_modal.fetching_account"),
);
}
const isLoggedIn = !!this.userMeResponse?.user;
return html`
<div class="custom-scrollbar mr-1">
${isLoggedIn ? this.renderAccountInfo() : this.renderLoginOptions()}
</div>
`;
}
private renderAccountInfo() {
const me = this.userMeResponse?.user;
const isLinked = me?.discord ?? me?.email;
if (!isLinked) {
return this.renderLoginOptions();
}
return html`
<div class="p-6">
<div class="flex flex-col gap-6">
<!-- Top Row: Connected As -->
<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.renderSubscriptionPanel()}
<!-- Middle Row: Stats Section -->
${this.hasAnyStats()
? 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>`
: ""}
<!-- Bottom Row: Recent Games Section -->
<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("game_list.recent_games")}
</h3>
<game-list
.games=${this.recentGames}
.onViewGame=${(id: string) => void this.viewGame(id)}
></game-list>
</div>
</div>
</div>
`;
}
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.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.renderLogoutButton()}
</div>
`;
}
return html``;
}
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>
<!-- 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">
<div class="relative group">
<input
type="email"
id="email"
name="email"
.value="${this.email}"
@input="${this.handleEmailInput}"
class="w-full pl-4 pr-12 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"
placeholder="${translateText(
"account_modal.email_placeholder",
)}"
required
/>
</div>
<o-button
variant="primary"
width="block"
size="md"
translationKey="account_modal.get_magic_link"
@click=${this.handleSubmit}
></o-button>
</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"));
}
}
private handleDiscordLogin() {
discordLogin();
}
protected onOpen(): void {
this.isLoadingUser = true;
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.recentGames = data.games;
this.statsTree = data.stats;
this.requestUpdate();
} catch (err) {
console.warn("Failed to load player data:", err);
this.requestUpdate();
}
}
}