| @@ -9,8 +9,9 @@ | ||
| 9 | 9 | import { |
| 10 | 10 | LayoutDashboard, |
| 11 | 11 | MapPin, |
| 12 | 12 | Calendar, |
| 13 | + CalendarClock, | |
| 13 | 14 | CalendarDays, |
| 14 | 15 | Star, |
| 15 | 16 | BarChart3, |
| 16 | 17 | Settings, |
| @@ -81,8 +82,9 @@ | ||
| 81 | 82 | import { |
| 82 | 83 | readMenuOverrides, |
| 83 | 84 | readMenuOrder, |
| 84 | 85 | readUiChrome, |
| 86 | + DEFAULT_MENU_ITEMS, | |
| 85 | 87 | type MenuOverrides, |
| 86 | 88 | } from "../lib/sidebar-menu-defaults"; |
| 87 | 89 | import { MenuIcon } from "../lib/menu-icon"; |
| 88 | 90 | import { |
| @@ -289,9 +291,9 @@ | ||
| 289 | 291 | // Get current subpage and tab from URL |
| 290 | 292 | const currentSubpage = useMemo(() => { |
| 291 | 293 | const params = new URLSearchParams(window.location.search); |
| 292 | 294 | return params.get("subpage") || "dashboard"; |
| 293 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 295 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 294 | 296 | }, [urlKey]); |
| 295 | 297 | |
| 296 | 298 | // "New since last seen" counts for the sidebar badges (Bookings / Payments / |
| 297 | 299 | // Abandoned Recovery). Read-only — if the request fails, no badge is shown. |
| @@ -305,15 +307,15 @@ | ||
| 305 | 307 | |
| 306 | 308 | const currentTab = useMemo(() => { |
| 307 | 309 | const params = new URLSearchParams(window.location.search); |
| 308 | 310 | return params.get("tab") || "all"; |
| 309 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 311 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 310 | 312 | }, [urlKey]); |
| 311 | 313 | |
| 312 | 314 | const currentAction = useMemo(() => { |
| 313 | 315 | const params = new URLSearchParams(window.location.search); |
| 314 | 316 | return params.get("action"); |
| 315 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 317 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 316 | 318 | }, [urlKey]); |
| 317 | 319 | |
| 318 | 320 | // Check if we're on the trip form page |
| 319 | 321 | const isTripFormPage = useMemo(() => { |
| @@ -321,53 +323,29 @@ | ||
| 321 | 323 | currentSubpage === "trips" && |
| 322 | 324 | (currentTab === "all" || !currentTab) && |
| 323 | 325 | (currentAction === "create" || currentAction === "edit") |
| 324 | 326 | ); |
| 325 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 327 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 326 | 328 | }, [currentSubpage, currentTab, currentAction, urlKey]); |
| 327 | 329 | |
| 328 | - // Track expanded submenus - initialize based on current subpage | |
| 330 | + // Track expanded submenus — start with the parent of the page being loaded | |
| 331 | + // open, so a deep link or a refresh lands with the right submenu already | |
| 332 | + // expanded (no flash of a collapsed menu). | |
| 333 | + // | |
| 334 | + // Derived from the menu definition rather than a hardcoded list of parents: | |
| 335 | + // it used to name `trips` and `itinerary` explicitly, which meant every other | |
| 336 | + // parent (Payments, and anything added later) stayed collapsed on refresh. | |
| 329 | 337 | const [expandedMenus, setExpandedMenus] = useState<string[]>(() => { |
| 330 | 338 | const params = new URLSearchParams(window.location.search); |
| 331 | 339 | const subpage = params.get("subpage") || "dashboard"; |
| 332 | - const menus: string[] = []; | |
| 333 | 340 | |
| 334 | - if (subpage === "trips") { | |
| 335 | - menus.push("trips"); | |
| 336 | - } | |
| 337 | - | |
| 338 | - if (subpage === "itinerary") { | |
| 339 | - menus.push("itinerary"); | |
| 340 | - } | |
| 341 | - | |
| 342 | - return menus; | |
| 341 | + return DEFAULT_MENU_ITEMS.some( | |
| 342 | + (item) => item.slug === subpage && (item.submenu?.length ?? 0) > 0, | |
| 343 | + ) | |
| 344 | + ? [subpage] | |
| 345 | + : []; | |
| 343 | 346 | }); |
| 344 | 347 | |
| 345 | - // Auto-expand menu when on submenu pages | |
| 346 | - useEffect(() => { | |
| 347 | - const menusToExpand: string[] = []; | |
| 348 | - | |
| 349 | - if (currentSubpage === "trips") { | |
| 350 | - menusToExpand.push("trips"); | |
| 351 | - } | |
| 352 | - | |
| 353 | - if (currentSubpage === "itinerary") { | |
| 354 | - menusToExpand.push("itinerary"); | |
| 355 | - } | |
| 356 | - | |
| 357 | - setExpandedMenus((prev) => { | |
| 358 | - // Only update if the menus to expand are different | |
| 359 | - const newMenus = [...new Set([...prev, ...menusToExpand])]; | |
| 360 | - if ( | |
| 361 | - newMenus.length !== prev.length || | |
| 362 | - !newMenus.every((m) => prev.includes(m)) | |
| 363 | - ) { | |
| 364 | - return newMenus; | |
| 365 | - } | |
| 366 | - return prev; | |
| 367 | - }); | |
| 368 | - }, [currentSubpage, urlKey]); | |
| 369 | - | |
| 370 | 348 | // Get base admin URL |
| 371 | 349 | const baseUrl = useMemo(() => { |
| 372 | 350 | return window.yatraAdmin?.siteUrl |
| 373 | 351 | ? `${window.yatraAdmin.siteUrl}/wp-admin/admin.php?page=yatra` |
| @@ -488,8 +466,36 @@ | ||
| 488 | 466 | subpage: "payments", |
| 489 | 467 | label: __("Payments", "yatra"), |
| 490 | 468 | icon: CreditCard, |
| 491 | 469 | cap: "yatra_view_financial_reports", |
| 470 | + // Payments only becomes a parent when the Pro "Scheduled Payments" | |
| 471 | + // module is enabled — otherwise there is nothing to nest and it stays | |
| 472 | + // exactly as it was: a single item that navigates straight to the | |
| 473 | + // payments list. Children are only rendered for a visible parent, so | |
| 474 | + // the pair inherits the Payments capability gate. | |
| 475 | + // | |
| 476 | + // `undefined` rather than an empty array: the rest of this component | |
| 477 | + // treats "has a submenu" as truthiness, and an empty array would read | |
| 478 | + // as a parent with no matching child — which would stop the Payments | |
| 479 | + // item highlighting on its own page. | |
| 480 | + submenu: (window as any).yatraAdmin?.scheduledPaymentsEnabled | |
| 481 | + ? [ | |
| 482 | + { | |
| 483 | + tab: "all", | |
| 484 | + label: __("All Payments", "yatra"), | |
| 485 | + icon: CreditCard, | |
| 486 | + }, | |
| 487 | + { | |
| 488 | + // "Scheduled" alone — the parent already says Payments. This | |
| 489 | + // label is also the page's heading (the top bar reads it from | |
| 490 | + // the active submenu item). | |
| 491 | + tab: "scheduled", | |
| 492 | + label: __("Scheduled", "yatra"), | |
| 493 | + icon: CalendarClock, | |
| 494 | + isPremium: true, | |
| 495 | + }, | |
| 496 | + ] | |
| 497 | + : undefined, | |
| 492 | 498 | }, |
| 493 | 499 | { |
| 494 | 500 | subpage: "bookings", |
| 495 | 501 | label: __("Bookings", "yatra"), |
| @@ -815,8 +821,13 @@ | ||
| 815 | 821 | const topLevel = orderGroup("", groups.get("") ?? []); |
| 816 | 822 | return topLevel.map((atom) => { |
| 817 | 823 | const childAtoms = orderGroup(atom.key, groups.get(atom.key) ?? []); |
| 818 | 824 | return { |
| 825 | + // React key. NOT `subpage`: a child promoted to top-level keeps its | |
| 826 | + // parent's subpage (`payments.scheduled` → subpage `payments`), so two | |
| 827 | + // siblings could share it. Duplicate keys make React reconcile the | |
| 828 | + // wrong nodes and throw "removeChild ... not a child of this node". | |
| 829 | + menuKey: atom.key, | |
| 819 | 830 | subpage: atom.subpage, |
| 820 | 831 | label: atom.label, |
| 821 | 832 | icon: atom.icon, |
| 822 | 833 | iconOverride: atom.iconOverride, |
| @@ -822,8 +833,11 @@ | ||
| 822 | 833 | iconOverride: atom.iconOverride, |
| 823 | 834 | isPremium: atom.isPremium, |
| 824 | 835 | submenu: childAtoms.length |
| 825 | 836 | ? childAtoms.map((child) => ({ |
| 837 | + // Unique per child, for the same reason as `menuKey` above: | |
| 838 | + // subpage + tab can repeat once items are moved between parents. | |
| 839 | + menuKey: child.key, | |
| 826 | 840 | // Submenu rendering supports both legacy intra-parent items |
| 827 | 841 | // (matching parent's subpage) and promoted/demoted items |
| 828 | 842 | // that point at their own subpage. |
| 829 | 843 | tab: child.defaultParent === atom.subpage ? child.tab : undefined, |
| @@ -842,8 +856,28 @@ | ||
| 842 | 856 | }; |
| 843 | 857 | }); |
| 844 | 858 | }, [menuItems, baseUrl]); |
| 845 | 859 | |
| 860 | + // Keep the active page's parent expanded as the URL changes. Lives here | |
| 861 | + // (after `brandedMenuItems`) so it reads the menu actually being rendered — | |
| 862 | + // that includes parents the menu customizer created by demoting an item into | |
| 863 | + // one, which the static defaults above don't know about. | |
| 864 | + useEffect(() => { | |
| 865 | + const parentOfCurrentPage = brandedMenuItems.find( | |
| 866 | + (item: any) => | |
| 867 | + item.subpage === currentSubpage && (item.submenu?.length ?? 0) > 0, | |
| 868 | + ); | |
| 869 | + if (!parentOfCurrentPage) { | |
| 870 | + return; | |
| 871 | + } | |
| 872 | + | |
| 873 | + setExpandedMenus((prev) => | |
| 874 | + prev.includes(parentOfCurrentPage.subpage) | |
| 875 | + ? prev | |
| 876 | + : [...prev, parentOfCurrentPage.subpage], | |
| 877 | + ); | |
| 878 | + }, [currentSubpage, urlKey, brandedMenuItems]); | |
| 879 | + | |
| 846 | 880 | /** UI chrome visibility flags (version, Back to WP, Join Community). */ |
| 847 | 881 | const uiChrome = useMemo(() => readUiChrome(), []); |
| 848 | 882 | |
| 849 | 883 | const isActive = (subpage: string, tab?: string) => { |
| @@ -948,9 +982,18 @@ | ||
| 948 | 982 | const isExpanded = hasSubmenu && isMenuExpanded(item.subpage); |
| 949 | 983 | const active = isActive(item.subpage); |
| 950 | 984 | |
| 951 | 985 | return ( |
| 952 | - <div key={item.subpage}> | |
| 986 | + // Keyed by the unique menu key, and by the item's shape: when a | |
| 987 | + // module toggle turns a plain item into a parent (Payments → | |
| 988 | + // All Payments / Scheduled) the branch below swaps an <a> for a | |
| 989 | + // <button> + children, and a shape-aware key makes React remount | |
| 990 | + // that subtree cleanly instead of reusing mismatched nodes. | |
| 991 | + <div | |
| 992 | + key={`${(item as any).menuKey ?? item.subpage}:${ | |
| 993 | + hasSubmenu ? "parent" : "leaf" | |
| 994 | + }`} | |
| 995 | + > | |
| 953 | 996 | {hasSubmenu ? ( |
| 954 | 997 | <> |
| 955 | 998 | <button |
| 956 | 999 | onClick={() => toggleMenu(item.subpage)} |
| @@ -967,13 +1010,28 @@ | ||
| 967 | 1010 | className="w-5 h-5" |
| 968 | 1011 | /> |
| 969 | 1012 | <span>{item.label}</span> |
| 970 | 1013 | </div> |
| 971 | - {isExpanded ? ( | |
| 972 | - <ChevronDown className="w-4 h-4" /> | |
| 973 | - ) : ( | |
| 974 | - <ChevronRight className="w-4 h-4" /> | |
| 975 | - )} | |
| 1014 | + <div className="flex items-center gap-2"> | |
| 1015 | + {/* New-since-last-seen badge. Also rendered here, not | |
| 1016 | + only on flat items: a section that gains children | |
| 1017 | + (Payments) would otherwise lose its badge. */} | |
| 1018 | + {!active && getNewCount(item.subpage) > 0 && ( | |
| 1019 | + <span | |
| 1020 | + className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full bg-red-500 text-white text-[10px] font-semibold leading-none" | |
| 1021 | + aria-label={`${getNewCount(item.subpage)} ${__("new", "yatra")}`} | |
| 1022 | + > | |
| 1023 | + {getNewCount(item.subpage) > 99 | |
| 1024 | + ? "99+" | |
| 1025 | + : getNewCount(item.subpage)} | |
| 1026 | + </span> | |
| 1027 | + )} | |
| 1028 | + {isExpanded ? ( | |
| 1029 | + <ChevronDown className="w-4 h-4" /> | |
| 1030 | + ) : ( | |
| 1031 | + <ChevronRight className="w-4 h-4" /> | |
| 1032 | + )} | |
| 1033 | + </div> | |
| 976 | 1034 | </button> |
| 977 | 1035 | {isExpanded && item.submenu && ( |
| 978 | 1036 | <div className="ml-4 mt-1 space-y-1"> |
| 979 | 1037 | {item.submenu.map((subItem: any) => { |
| @@ -988,9 +1046,12 @@ | ||
| 988 | 1046 | : isActive(navSubpage); |
| 989 | 1047 | const SubIcon = subItem.icon; |
| 990 | 1048 | return ( |
| 991 | 1049 | <a |
| 992 | - key={`${navSubpage}.${navTab ?? ""}`} | |
| 1050 | + key={ | |
| 1051 | + subItem.menuKey ?? | |
| 1052 | + `${navSubpage}.${navTab ?? ""}` | |
| 1053 | + } | |
| 993 | 1054 | href={getUrl(navSubpage, navTab)} |
| 994 | 1055 | onClick={(e) => |
| 995 | 1056 | handleMenuNavClick(e, navSubpage, navTab) |
| 996 | 1057 | } |