Add History API routing so browser/Android back navigates screens

- ui-store syncs screens to history (push on go/goGame, hash URLs like #/profile),
  back() = history.back(), initHistory anchors a home base + restores deep links
- page.tsx listens to popstate; resolveScreen() guards transient screens
  (game/room/chat/matchmaking fall back to home when their state is gone)
- ScreenHeader + ChatScreen back buttons use history back; chat cleans up on unmount

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 12:56:14 +03:30
parent 0a3ffa6314
commit a3b797c8a3
4 changed files with 106 additions and 11 deletions
+27 -1
View File
@@ -14,7 +14,25 @@ import { ChatScreen } from "@/components/screens/ChatScreen";
import { AuthScreen } from "@/components/screens/AuthScreen"; import { AuthScreen } from "@/components/screens/AuthScreen";
import { DailyRewardModal } from "@/components/online/DailyRewardModal"; import { DailyRewardModal } from "@/components/online/DailyRewardModal";
import { useSessionStore } from "@/lib/session-store"; import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store"; import { useGameStore } from "@/lib/game-store";
import { useOnlineStore } from "@/lib/online-store";
import { screenFromHash, useUIStore, type Screen } from "@/lib/ui-store";
/** Transient screens can't be restored without their state — fall back to home. */
function resolveScreen(s: Screen): Screen {
switch (s) {
case "game":
return useGameStore.getState().started ? s : "home";
case "room":
return useOnlineStore.getState().room ? s : "home";
case "chat":
return useOnlineStore.getState().activeChatFriend ? s : "home";
case "matchmaking":
return useOnlineStore.getState().matchmaking.phase !== "idle" ? s : "home";
default:
return s;
}
}
export default function Page() { export default function Page() {
const screen = useUIStore((s) => s.screen); const screen = useUIStore((s) => s.screen);
@@ -23,6 +41,14 @@ export default function Page() {
useEffect(() => { useEffect(() => {
init(); init();
useUIStore.getState().initHistory();
const onPop = (e: PopStateEvent) => {
const raw = ((e.state?.screen as Screen) ?? screenFromHash());
useUIStore.getState().syncFromPop(resolveScreen(raw));
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, [init]); }, [init]);
return ( return (
+2 -2
View File
@@ -13,13 +13,13 @@ export function ScreenHeader({
back?: Screen; back?: Screen;
right?: React.ReactNode; right?: React.ReactNode;
}) { }) {
const go = useUIStore((s) => s.go); const navBack = useUIStore((s) => s.back);
const { locale } = useI18n(); const { locale } = useI18n();
const Chevron = locale === "fa" ? ChevronRight : ChevronLeft; const Chevron = locale === "fa" ? ChevronRight : ChevronLeft;
return ( return (
<div className="flex items-center justify-between gap-3 mb-5"> <div className="flex items-center justify-between gap-3 mb-5">
<button <button
onClick={() => go(back)} onClick={() => navBack(back)}
className="glass rounded-full p-2.5 hover:bg-navy-800/80 transition" className="glass rounded-full p-2.5 hover:bg-navy-800/80 transition"
> >
<Chevron className="size-5 text-cream/80" /> <Chevron className="size-5 text-cream/80" />
+5 -5
View File
@@ -15,7 +15,7 @@ export function ChatScreen() {
const messages = useOnlineStore((s) => s.chatMessages); const messages = useOnlineStore((s) => s.chatMessages);
const sendChat = useOnlineStore((s) => s.sendChat); const sendChat = useOnlineStore((s) => s.sendChat);
const closeChat = useOnlineStore((s) => s.closeChat); const closeChat = useOnlineStore((s) => s.closeChat);
const go = useUIStore((s) => s.go); const navBack = useUIStore((s) => s.back);
const [text, setText] = useState(""); const [text, setText] = useState("");
const endRef = useRef<HTMLDivElement>(null); const endRef = useRef<HTMLDivElement>(null);
const prevLen = useRef(0); const prevLen = useRef(0);
@@ -30,14 +30,14 @@ export function ChatScreen() {
prevLen.current = messages.length; prevLen.current = messages.length;
}, [messages]); }, [messages]);
// Clean up the chat subscription whenever we leave (header back OR hardware back).
useEffect(() => () => closeChat(), [closeChat]);
if (!friend) { if (!friend) {
return null; return null;
} }
const back = () => { const back = () => navBack("friends");
closeChat();
go("friends");
};
const send = async () => { const send = async () => {
const v = text.trim(); const v = text.trim();
+72 -3
View File
@@ -15,23 +15,92 @@ export type Screen =
| "chat" | "chat"
| "game"; // the table (used for both ai + online) | "game"; // the table (used for both ai + online)
const ALL_SCREENS: Screen[] = [
"home", "auth", "profile", "friends", "online",
"room", "matchmaking", "leaderboard", "shop", "chat", "game",
];
/** Screens safe to restore from a URL on a cold load (no transient state needed). */
export const STATIC_SCREENS: Screen[] = [
"home", "auth", "profile", "friends", "online", "leaderboard", "shop",
];
export function screenFromHash(): Screen {
if (typeof window === "undefined") return "home";
const h = window.location.hash.replace(/^#\/?/, "");
return (ALL_SCREENS.includes(h as Screen) ? (h as Screen) : "home");
}
function urlFor(screen: Screen): string {
if (typeof window === "undefined") return "/";
const base = window.location.pathname + window.location.search;
return screen === "home" ? base : `${base}#/${screen}`;
}
function pushHistory(screen: Screen, replace = false) {
if (typeof window === "undefined") return;
const state = { screen };
if (replace) window.history.replaceState(state, "", urlFor(screen));
else window.history.pushState(state, "", urlFor(screen));
}
interface UIStore { interface UIStore {
screen: Screen; screen: Screen;
/** screen to return to from the game table */ /** screen to return to from the game table */
returnTo: Screen; returnTo: Screen;
dailyModalOpen: boolean; dailyModalOpen: boolean;
go: (screen: Screen) => void; go: (screen: Screen) => void;
goGame: (returnTo?: Screen) => void; goGame: (returnTo?: Screen) => void;
/** Browser/hardware back. */
back: (fallback?: Screen) => void;
/** Sync from a popstate event (does not touch history). */
syncFromPop: (screen: Screen) => void;
/** Establish a home base entry + restore a deep-linked screen on load. */
initHistory: () => void;
openDaily: () => void; openDaily: () => void;
closeDaily: () => void; closeDaily: () => void;
} }
export const useUIStore = create<UIStore>((set) => ({ export const useUIStore = create<UIStore>((set, get) => ({
screen: "home", screen: "home",
returnTo: "home", returnTo: "home",
dailyModalOpen: false, dailyModalOpen: false,
go: (screen) => set({ screen }),
goGame: (returnTo = "home") => set({ screen: "game", returnTo }), go: (screen) => {
if (get().screen === screen) return;
pushHistory(screen);
set({ screen });
},
goGame: (returnTo = "home") => {
pushHistory("game");
set({ screen: "game", returnTo });
},
back: (fallback) => {
if (typeof window !== "undefined") {
window.history.back();
return;
}
if (fallback) set({ screen: fallback });
},
syncFromPop: (screen) => set({ screen }),
initHistory: () => {
// Always anchor a "home" base entry so back() has somewhere to land.
pushHistory("home", true);
const target = screenFromHash();
if (target !== "home" && STATIC_SCREENS.includes(target)) {
pushHistory(target);
set({ screen: target });
} else {
set({ screen: "home" });
}
},
openDaily: () => set({ dailyModalOpen: true }), openDaily: () => set({ dailyModalOpen: true }),
closeDaily: () => set({ dailyModalOpen: false }), closeDaily: () => set({ dailyModalOpen: false }),
})); }));