Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/client/AccountModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions src/client/ClanModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 4 additions & 0 deletions src/client/Cosmetics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions src/client/FlagInputModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/client/HelpModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { TroubleshootingModal } from "./TroubleshootingModal";

@customElement("help-modal")
export class HelpModal extends BaseModal {
protected routerName = "help";

@state() private keybinds: Record<string, string> = this.getKeybinds();
@query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement;

Expand Down
2 changes: 2 additions & 0 deletions src/client/LanguageModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
2 changes: 2 additions & 0 deletions src/client/LeaderboardModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
55 changes: 55 additions & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -268,6 +269,50 @@ class Client {

async initialize(): Promise<void> {
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();
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand Down
159 changes: 159 additions & 0 deletions src/client/ModalRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* ModalRouter — two-way sync between `#modal=<name>&tab=<key>&...` 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<string, unknown>): void;
close(args?: Record<string, unknown>): void;
}

class ModalRouter {
private registry = new Map<string, RegistryEntry>();
/** 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=<name>&...`. 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<string, unknown> = {};
params.forEach((value, key) => {
args[key] = value;
});

void this.openRegistered(name, entry, args);
return true;
}

private async openRegistered(
name: string,
entry: RegistryEntry,
args: Record<string, unknown>,
): Promise<void> {
// 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;
}
Comment on lines +80 to +93
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Potential double-open fragility for inline modals.

For inline modals with pageId, Line 73 calls window.showPage(pageId), which (per BaseModal comments) "calls .open() on the inline modal element automatically." Then Line 76 explicitly calls el?.open(args) again.

BaseModal handles this by checking wasOpen and returning early at Line 174, but onOpen(args?) still runs twice—once with no args, once with args. This relies on BaseModal's specific wasOpen logic and assumes onOpen tolerates being called twice.

Consider a more explicit contract:

  • Either pass args directly to showPage if it supports them, or
  • Have showPage return a reference and skip the second open() call, or
  • Document this double-call requirement clearly in RoutableModal interface

This pattern is fragile if showPage or BaseModal internals change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/ModalRouter.ts` around lines 66 - 79, ModalRouter currently calls
window.showPage(entry.pageId) which already triggers the inline modal's open,
then calls el?.open(args) again causing onOpen to run twice; update ModalRouter
(around the routingFromUrl handling in ModalRouter) to avoid the duplicate open
by either passing args through to showPage (if showPage can accept them), or by
changing the flow to use the element returned/located by showPage and skip the
second el?.open(args) call; reference routines: ModalRouter, window.showPage,
RoutableModal, open, BaseModal.onOpen — implement the chosen option and ensure
routingFromUrl logic still sets/clears the flag as before.

}

/** Called by BaseModal.open() when a router-managed modal opens. */
syncOpened(name: string, args?: Record<string, unknown>): 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<string, unknown>): 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();
2 changes: 2 additions & 0 deletions src/client/NewsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/client/SinglePlayerModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/client/Store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/client/TerritoryPatternsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/client/TroubleshootingModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
1 change: 1 addition & 0 deletions src/client/UserSettingModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading