diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index 8f5b05dda3..2bfae22b34 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -25,6 +25,8 @@ import { translateText } from "./Utils"; @customElement("account-modal") export class AccountModal extends BaseModal { + protected routerName = "account"; + @state() private email: string = ""; @state() private isLoadingUser: boolean = false; diff --git a/src/client/ClanModal.ts b/src/client/ClanModal.ts index 66f7361a00..975367c2b7 100644 --- a/src/client/ClanModal.ts +++ b/src/client/ClanModal.ts @@ -29,6 +29,8 @@ type View = @customElement("clan-modal") export class ClanModal extends BaseModal { + protected routerName = "clan"; + @state() private view: View = "list"; @state() private loading = false; diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 42aff508be..5cc5f040dc 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -89,6 +89,10 @@ export async function purchaseCosmetic( : (userMe.player.currency?.soft ?? 0); if (balance < price) { alert(translateText("store.not_enough_currency")); + if (method === "hard") { + // Send the user to the packs tab so they can top up plutonium. + window.location.hash = "#modal=store&tab=packs"; + } return; } diff --git a/src/client/FlagInputModal.ts b/src/client/FlagInputModal.ts index 8c7210cc69..6400f13c16 100644 --- a/src/client/FlagInputModal.ts +++ b/src/client/FlagInputModal.ts @@ -29,6 +29,8 @@ function countryFlag(name: string, code: string): Flag { @customElement("flag-input-modal") export class FlagInputModal extends BaseModal { + protected routerName = "flag-input"; + @state() private search = ""; @state() private cosmetics: Cosmetics | null = null; @state() private userMe: UserMeResponse | false = false; diff --git a/src/client/HelpModal.ts b/src/client/HelpModal.ts index f6af03dbe7..acba57635f 100644 --- a/src/client/HelpModal.ts +++ b/src/client/HelpModal.ts @@ -11,6 +11,8 @@ import { TroubleshootingModal } from "./TroubleshootingModal"; @customElement("help-modal") export class HelpModal extends BaseModal { + protected routerName = "help"; + @state() private keybinds: Record = this.getKeybinds(); @query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement; diff --git a/src/client/LanguageModal.ts b/src/client/LanguageModal.ts index 9e963c092b..1ed315ee12 100644 --- a/src/client/LanguageModal.ts +++ b/src/client/LanguageModal.ts @@ -14,6 +14,8 @@ interface LanguageOption { @customElement("language-modal") export class LanguageModal extends BaseModal { + protected routerName = "language"; + @property({ type: Array }) languageList: LanguageOption[] = []; @property({ type: String }) currentLang = "en"; diff --git a/src/client/LeaderboardModal.ts b/src/client/LeaderboardModal.ts index 511400602e..a8f5a7fe3d 100644 --- a/src/client/LeaderboardModal.ts +++ b/src/client/LeaderboardModal.ts @@ -10,6 +10,8 @@ import { translateText } from "./Utils"; @customElement("leaderboard-modal") export class LeaderboardModal extends BaseModal { + protected routerName = "leaderboard"; + @state() private clanDateRange: { start: string; end: string } | null = null; diff --git a/src/client/Main.ts b/src/client/Main.ts index 2be193451a..dc3fb83321 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -43,6 +43,7 @@ import { initLayout } from "./Layout"; import "./LeaderboardModal"; import "./Matchmaking"; import { MatchmakingModal } from "./Matchmaking"; +import { modalRouter } from "./ModalRouter"; import { initNavigation } from "./Navigation"; import "./NewsModal"; import "./PatternInput"; @@ -268,6 +269,50 @@ class Client { async initialize(): Promise { crazyGamesSDK.maybeInit(); + + // Register modals with the URL router. Lobby modals (join/host) and + // matchmaking are intentionally omitted — they own their own URL state + // (path-based) or none at all. + modalRouter.register("store", { + tag: "store-modal", + pageId: "page-item-store", + }); + modalRouter.register("settings", { + tag: "user-setting", + pageId: "page-settings", + }); + modalRouter.register("leaderboard", { + tag: "leaderboard-modal", + pageId: "page-leaderboard", + }); + modalRouter.register("clan", { tag: "clan-modal", pageId: "page-clan" }); + modalRouter.register("account", { + tag: "account-modal", + pageId: "page-account", + }); + modalRouter.register("help", { tag: "help-modal", pageId: "page-help" }); + modalRouter.register("news", { tag: "news-modal", pageId: "page-news" }); + modalRouter.register("language", { + tag: "language-modal", + pageId: "page-language", + }); + modalRouter.register("single-player", { + tag: "single-player-modal", + pageId: "page-single-player", + }); + modalRouter.register("ranked", { + tag: "ranked-modal", + pageId: "page-ranked", + }); + modalRouter.register("troubleshooting", { + tag: "troubleshooting-modal", + pageId: "page-troubleshooting", + }); + modalRouter.register("territory-patterns", { + tag: "territory-patterns-modal", + }); + modalRouter.register("flag-input", { tag: "flag-input-modal" }); + // Prefetch turnstile token so it is available when // the user joins a lobby. this.turnstileTokenPromise = getTurnstileToken(); @@ -525,6 +570,13 @@ class Client { } const onHashUpdate = () => { + // Router-managed hash changes (#modal=...) are handled by the router + // syncing in/out; we don't need to tear down the lobby state for them. + if (modalRouter.isHashRouted()) { + modalRouter.routeFromHash(); + return; + } + // Reset the UI to its initial state this.joinModal?.close(); @@ -725,6 +777,9 @@ class Client { console.log(`joining lobby ${lobbyId}`); return; } + if (modalRouter.routeFromHash()) { + return; + } if (decodedHash.startsWith("#affiliate=")) { const affiliateCode = decodedHash.replace("#affiliate=", ""); strip(); diff --git a/src/client/ModalRouter.ts b/src/client/ModalRouter.ts new file mode 100644 index 0000000000..a2224f86e4 --- /dev/null +++ b/src/client/ModalRouter.ts @@ -0,0 +1,159 @@ +/** + * ModalRouter — two-way sync between `#modal=&tab=&...` and modals. + * + * URL → modal: parse hash, find registered modal, call `modal.open(args)`. + * Modal → URL: when a router-managed modal opens, closes, or switches tabs, + * update the URL via `history.replaceState` (no history entries). + * + * Lobby modals (join/host) and matchmaking are intentionally not registered: + * they have their own URL state (path-based) or none at all. + */ + +interface RegistryEntry { + /** Custom element tag, e.g. "store-modal". */ + tag: string; + /** + * Optional page-content element id (e.g. "page-item-store"). When set, the + * router calls `window.showPage(pageId)` for inline modals so the page-content + * container becomes visible. For popup-style modals, omit. + */ + pageId?: string; +} + +/** Modals that the router can drive via the URL. */ +interface RoutableModal extends HTMLElement { + open(args?: Record): void; + close(args?: Record): void; +} + +class ModalRouter { + private registry = new Map(); + /** Name of the modal currently reflected in the URL, if any. */ + private currentName: string | null = null; + /** True while we're routing from the URL (suppress modal→URL sync). */ + private routingFromUrl = false; + + register(name: string, entry: RegistryEntry): void { + this.registry.set(name, entry); + } + + /** + * Parse `window.location.hash` for `#modal=&...`. If present and + * registered, open the modal with the remaining keys as args. Returns true + * if the hash was a recognized modal route (the caller can skip other + * hash handlers). The open itself happens asynchronously after the custom + * element is upgraded. + */ + routeFromHash(): boolean { + const hash = window.location.hash; + if (!hash.startsWith("#")) return false; + const params = new URLSearchParams(hash.slice(1)); + const name = params.get("modal"); + if (!name) return false; + + const entry = this.registry.get(name); + if (!entry) { + // Unknown modal — strip the hash silently. + this.replaceHash(""); + return true; + } + + params.delete("modal"); + const args: Record = {}; + params.forEach((value, key) => { + args[key] = value; + }); + + void this.openRegistered(name, entry, args); + return true; + } + + private async openRegistered( + name: string, + entry: RegistryEntry, + args: Record, + ): Promise { + // The custom element may not be upgraded yet (e.g. routed on initial load + // before its module has finished evaluating). Wait so el.open is defined. + await customElements.whenDefined(entry.tag); + + this.routingFromUrl = true; + try { + this.currentName = name; + if (entry.pageId) { + // Inline modal: showPage reveals the page-content container and calls + // .open() on the inline modal element automatically. We then call + // .open(args) so the args reach onOpen. + window.showPage?.(entry.pageId); + } + const el = document.querySelector(entry.tag) as RoutableModal | null; + el?.open(args); + } finally { + this.routingFromUrl = false; + } + } + + /** Called by BaseModal.open() when a router-managed modal opens. */ + syncOpened(name: string, args?: Record): void { + if (this.routingFromUrl) return; // we're driving the modal from the URL; don't loop + if (!this.registry.has(name)) return; + this.currentName = name; + this.writeHash(name, args); + } + + /** Called by BaseModal.close() when a router-managed modal closes. */ + syncClosed(name: string): void { + if (this.routingFromUrl) return; + if (this.currentName !== name) return; // not the active routed modal + this.currentName = null; + this.replaceHash(""); + } + + /** Called by BaseModal.setActiveTab() when a router-managed modal switches tabs. */ + syncTab(name: string, tab: string): void { + if (this.routingFromUrl) return; + if (this.currentName !== name) return; + const params = this.currentHashParams(); + params.set("modal", name); + if (tab) { + params.set("tab", tab); + } else { + params.delete("tab"); + } + this.replaceHash("#" + params.toString()); + } + + /** True if the current hash is `#modal=...`. */ + isHashRouted(): boolean { + const hash = window.location.hash; + if (!hash.startsWith("#")) return false; + return new URLSearchParams(hash.slice(1)).has("modal"); + } + + private currentHashParams(): URLSearchParams { + const hash = window.location.hash; + if (!hash.startsWith("#")) return new URLSearchParams(); + return new URLSearchParams(hash.slice(1)); + } + + private writeHash(name: string, args?: Record): void { + const params = new URLSearchParams(); + params.set("modal", name); + if (args) { + for (const [key, value] of Object.entries(args)) { + if (key === "modal") continue; + if (value === undefined || value === null) continue; + if (typeof value === "object") continue; + params.set(key, String(value)); + } + } + this.replaceHash("#" + params.toString()); + } + + private replaceHash(hash: string): void { + const url = window.location.pathname + window.location.search + hash; + history.replaceState(history.state, "", url); + } +} + +export const modalRouter = new ModalRouter(); diff --git a/src/client/NewsModal.ts b/src/client/NewsModal.ts index 1607af3627..a1e2e2c676 100644 --- a/src/client/NewsModal.ts +++ b/src/client/NewsModal.ts @@ -10,6 +10,8 @@ import { normalizeNewsMarkdown } from "./NewsMarkdown"; @customElement("news-modal") export class NewsModal extends BaseModal { + protected routerName = "news"; + @property({ type: String }) markdown = "Loading..."; private initialized: boolean = false; diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 97ef29c942..10df43b812 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -63,6 +63,8 @@ const DEFAULT_OPTIONS = { @customElement("single-player-modal") export class SinglePlayerModal extends BaseModal { + protected routerName = "single-player"; + @state() private selectedMap: GameMapType = DEFAULT_OPTIONS.selectedMap; @state() private selectedDifficulty: Difficulty = DEFAULT_OPTIONS.selectedDifficulty; diff --git a/src/client/Store.ts b/src/client/Store.ts index 9480450d87..9f3babf497 100644 --- a/src/client/Store.ts +++ b/src/client/Store.ts @@ -19,6 +19,7 @@ type StoreTab = "patterns" | "flags" | "packs" | "subscriptions"; @customElement("store-modal") export class StoreModal extends BaseModal { + protected routerName = "store"; private cosmetics: Cosmetics | null = null; private affiliateCode: string | null = null; private userMeResponse: UserMeResponse | false = false; diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 3a0e91a54a..3ace0297b2 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -24,6 +24,7 @@ import { translateText } from "./Utils"; @customElement("territory-patterns-modal") export class TerritoryPatternsModal extends BaseModal { + protected routerName = "territory-patterns"; public previewButton: HTMLElement | null = null; @state() private selectedPattern: PlayerPattern | null; diff --git a/src/client/TroubleshootingModal.ts b/src/client/TroubleshootingModal.ts index 7f433e4fbd..d52bb611b5 100644 --- a/src/client/TroubleshootingModal.ts +++ b/src/client/TroubleshootingModal.ts @@ -12,6 +12,8 @@ const infoIcon = assetUrl("images/InfoIcon.svg"); @customElement("troubleshooting-modal") export class TroubleshootingModal extends BaseModal { + protected routerName = "troubleshooting"; + @property({ type: String }) markdown = "Loading..."; @property({ type: Object }) diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index da9bb00844..e36444ee79 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -14,6 +14,7 @@ import { Platform } from "./Platform"; @customElement("user-setting") export class UserSettingModal extends BaseModal { + protected routerName = "settings"; private userSettings: UserSettings = new UserSettings(); private readonly defaultKeybinds = getDefaultKeybinds(Platform.isMac); diff --git a/src/client/components/BaseModal.ts b/src/client/components/BaseModal.ts index aba455fa4d..18a5efd558 100644 --- a/src/client/components/BaseModal.ts +++ b/src/client/components/BaseModal.ts @@ -1,5 +1,6 @@ import { html, LitElement, TemplateResult } from "lit"; import { property, query, state } from "lit/decorators.js"; +import { modalRouter } from "../ModalRouter"; import "./baseComponents/Modal"; import type { OModalTab } from "./baseComponents/Modal"; @@ -57,6 +58,13 @@ export abstract class BaseModal extends LitElement { return {}; } + /** + * Optional router name. When set, BaseModal syncs URL state on open/close/ + * tab change as `#modal=&tab=&...`. Modals that own their + * own URL state (e.g. lobby modals) should leave this undefined. + */ + protected routerName?: string; + /** Render slot="header" content. Default: no header slot. */ protected renderHeaderSlot(): TemplateResult | null { return null; @@ -149,19 +157,24 @@ export abstract class BaseModal extends LitElement { if (tabs.length && this.activeTab === "") { this.activeTab = tabs[0].key; } - if ( - typeof args?.tab === "string" && - tabs.some((t) => t.key === args.tab) - ) { - this.activeTab = args.tab; - } + const requestedTab = + typeof args?.tab === "string" && tabs.some((t) => t.key === args.tab) + ? args.tab + : null; const wasOpen = this.isModalOpen; if (!wasOpen) { + if (requestedTab) this.activeTab = requestedTab; this.registerEscapeHandler(); + this.onOpen(args); + if (this.activeTab) this.onTabEnter(this.activeTab); + } else { + this.onOpen(args); + // Already open: route tab changes through setActiveTab so URL syncs. + if (requestedTab && requestedTab !== this.activeTab) { + this.setActiveTab(requestedTab); + } } - this.onOpen(args); - if (this.activeTab) this.onTabEnter(this.activeTab); if (wasOpen) return; @@ -176,6 +189,10 @@ export abstract class BaseModal extends LitElement { } else { this.modalEl?.open(); } + + if (this.routerName) { + modalRouter.syncOpened(this.routerName, args); + } } finally { this.opening = false; } @@ -193,6 +210,10 @@ export abstract class BaseModal extends LitElement { } else { this.modalEl?.close(); } + + if (this.routerName) { + modalRouter.syncClosed(this.routerName); + } } // ---- Tab management ---- @@ -204,6 +225,9 @@ export abstract class BaseModal extends LitElement { if (this.activeTab === key) return; this.activeTab = key; this.onTabEnter(key); + if (this.routerName) { + modalRouter.syncTab(this.routerName, key); + } } // ---- Internals ---- diff --git a/src/client/components/RankedModal.ts b/src/client/components/RankedModal.ts index bebf8fbd50..bfb8318906 100644 --- a/src/client/components/RankedModal.ts +++ b/src/client/components/RankedModal.ts @@ -9,6 +9,8 @@ import { modalHeader } from "./ui/ModalHeader"; @customElement("ranked-modal") export class RankedModal extends BaseModal { + protected routerName = "ranked"; + @state() private elo: number | string = "..."; @state() private userMeResponse: UserMeResponse | false = false; @state() private errorMessage: string | null = null;