PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / resources / js / components / Layout.tsx

Layout.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.15, at resources/js/components/Layout.tsx

1,677 lines 69.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, {
2 useState,
3 useMemo,
4 useEffect,
5 useRef,
6 useCallback,
7 } from "react";
8 import { useMutation } from "@tanstack/react-query";
9 import {
10 LayoutDashboard,
11 MapPin,
12 Calendar,
13 CalendarClock,
14 CalendarDays,
15 Star,
16 BarChart3,
17 Settings,
18 Wrench,
19 Moon,
20 FileText,
21 CreditCard,
22 Package,
23 UserCircle,
24 FolderTree,
25 Tag,
26 TrendingUp,
27 List,
28 Activity,
29 Crown,
30 Sparkles,
31 ChevronDown,
32 ChevronRight,
33 Mail,
34 Key,
35 FileSignature,
36 Route,
37 BadgePercent,
38 Plane,
39 MessageSquare,
40 MessageCircle,
41 Network,
42 Webhook,
43 Users,
44 Puzzle,
45 ArrowLeft,
46 Gift,
47 Loader2,
48 RotateCcw,
49 Sun,
50 User,
51 } from "lucide-react";
52 import { __ } from "../lib/i18n";
53 import { Button } from "../components/ui/button";
54 import { Tooltip } from "../components/ui/tooltip";
55 import { useToast } from "../components/ui/toast";
56 import { postFlushRewriteRules } from "../api/settings-api";
57
58 // Helper function to extract Gravatar URL from WordPress get_avatar HTML
59 function extractGravatarUrl(avatarHtml: string, size: number): string {
60 if (!avatarHtml) {
61 return `https://www.gravatar.com/avatar/00000000000000000000000000000000?s=${size}&d=identicon&r=pg`;
62 }
63
64 // Extract src attribute from img tag
65 const imgMatch = avatarHtml.match(/<img[^>]+src=["']([^"']+)["']/i);
66 if (imgMatch && imgMatch[1]) {
67 // Replace size parameter if needed
68 return imgMatch[1].replace(/s=\d+/, `s=${size}`);
69 }
70
71 // Fallback to default
72 return `https://www.gravatar.com/avatar/00000000000000000000000000000000?s=${size}&d=identicon&r=pg`;
73 }
74
75 // Helper function to get Gravatar URL using WordPress data
76 function getGravatarUrl(size: number): string {
77 const avatarHtml = (window as any)?.yatraAdmin?.currentUserAvatar || "";
78 return extractGravatarUrl(avatarHtml, size);
79 }
80
81 import { ConditionalRender } from "../components/ui/conditional-render";
82 import {
83 readMenuOverrides,
84 readMenuOrder,
85 readUiChrome,
86 DEFAULT_MENU_ITEMS,
87 type MenuOverrides,
88 } from "../lib/sidebar-menu-defaults";
89 import { MenuIcon } from "../lib/menu-icon";
90 import {
91 Card,
92 CardContent,
93 CardHeader,
94 CardTitle,
95 CardDescription,
96 } from "../components/ui/card";
97 import { Badge } from "../components/ui/badge";
98 import {
99 useModulesQuery,
100 useToggleModule,
101 type ModuleDefinition,
102 } from "../hooks/useModules";
103 import { isProPluginActive, isModuleActive } from "../lib/plugin-utils";
104 import { navigateMenu } from "../hooks/useNavigate";
105 import { canCap } from "../hooks/useCapabilities";
106 import {
107 useNotificationCounts,
108 SUBPAGE_TO_SECTION,
109 } from "../hooks/useNotificationCounts";
110 import { InlineNotices } from "./notices/InlineNotices";
111
112 interface LayoutProps {
113 children: React.ReactNode;
114 }
115
116 const Layout: React.FC<LayoutProps> = ({ children }) => {
117 // Load dark mode preference from localStorage
118 const [darkMode, setDarkMode] = useState(() => {
119 const saved = localStorage.getItem("yatra-dark-mode");
120 return saved === "true";
121 });
122
123 // Apply dark mode to document on mount and when it changes
124 useEffect(() => {
125 const root = document.documentElement;
126 if (darkMode) {
127 root.classList.add("dark");
128 localStorage.setItem("yatra-dark-mode", "true");
129 } else {
130 root.classList.remove("dark");
131 localStorage.setItem("yatra-dark-mode", "false");
132 }
133 }, [darkMode]);
134
135 const [isModulesPanelOpen, setIsModulesPanelOpen] = useState(false);
136 const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
137 const modulesPanelRef = useRef<HTMLDivElement | null>(null);
138 const userDropdownRef = useRef<HTMLDivElement | null>(null);
139
140 // License status state for real-time updates
141 const [licenseStatus, setLicenseStatus] = useState<string | null>(
142 (window as any).yatraAdmin?.licenseStatus || null,
143 );
144
145 const { showToast } = useToast();
146 const flushRewriteRulesMutation = useMutation({
147 mutationFn: async () => postFlushRewriteRules(),
148 onSuccess: () => {
149 showToast(__("Rewrite rules flushed successfully", "yatra"), "success");
150 },
151 onError: (error: any) => {
152 showToast(
153 error?.message || __("Failed to flush rewrite rules", "yatra"),
154 "error",
155 );
156 },
157 });
158 const { data: modulesData, isLoading: isLoadingModules } = useModulesQuery({
159 enabled: isModulesPanelOpen,
160 });
161 const toggleModuleMutation = useToggleModule();
162 // Ensure modulesData is always an array before slicing
163 // eslint-disable-next-line react-hooks/exhaustive-deps
164 const safeModulesData = Array.isArray(modulesData) ? modulesData : [];
165 const modulesPreview = useMemo<ModuleDefinition[]>(
166 () => safeModulesData.slice(0, 3),
167 [safeModulesData],
168 );
169 const handleQuickToggle = (module: ModuleDefinition, enabled: boolean) => {
170 toggleModuleMutation.mutate({
171 slug: module.slug,
172 enabled,
173 name: module.name,
174 });
175 };
176
177 // Track URL changes to update menu state
178 const [urlKey, setUrlKey] = useState(0);
179 const [navRefreshKey, setNavRefreshKey] = useState(0);
180
181 /** SPA: avoid full reload — keeps PHP boot splash from showing on every sidebar click */
182 const handleMenuNavClick = useCallback(
183 (e: React.MouseEvent<HTMLAnchorElement>, subpage: string, tab?: string) => {
184 if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || e.button !== 0) {
185 return;
186 }
187 e.preventDefault();
188 navigateMenu(subpage, tab);
189 },
190 [],
191 );
192
193 // Close dropdowns when clicking outside
194 useEffect(() => {
195 const handleClickOutside = (event: MouseEvent) => {
196 if (
197 modulesPanelRef.current &&
198 !modulesPanelRef.current.contains(event.target as Node)
199 ) {
200 setIsModulesPanelOpen(false);
201 }
202 if (
203 userDropdownRef.current &&
204 !userDropdownRef.current.contains(event.target as Node)
205 ) {
206 setIsUserDropdownOpen(false);
207 }
208 };
209
210 document.addEventListener("mousedown", handleClickOutside);
211 return () => document.removeEventListener("mousedown", handleClickOutside);
212 }, []);
213
214 useEffect(() => {
215 const handleLocationChange = () => {
216 setUrlKey((prev) => prev + 1);
217 };
218
219 // Listen for popstate (back/forward button)
220 window.addEventListener("popstate", handleLocationChange);
221
222 // Also check periodically (fallback for direct navigation)
223 const interval = setInterval(() => {
224 const currentSearch = window.location.search;
225 if (currentSearch !== (window as any).__lastSearch) {
226 (window as any).__lastSearch = currentSearch;
227 handleLocationChange();
228 }
229 }, 100);
230
231 return () => {
232 window.removeEventListener("popstate", handleLocationChange);
233 clearInterval(interval);
234 };
235 }, []);
236
237 // Listen for module updates to refresh navigation
238 useEffect(() => {
239 const handleModuleUpdate = () => {
240 // Force re-render of menu items by updating navRefreshKey
241 setNavRefreshKey((prev) => prev + 1);
242 // Also update urlKey to ensure all memoized values refresh
243 setUrlKey((prev) => prev + 1);
244 };
245
246 const handleForceRefresh = () => {
247 setNavRefreshKey((prev) => prev + 1);
248 setUrlKey((prev) => prev + 1);
249 };
250
251 const handleLicenseStatusUpdate = (event: any) => {
252 const newStatus = event.detail?.status;
253 if (newStatus) {
254 setLicenseStatus(newStatus);
255 }
256 };
257
258 window.addEventListener("yatra-modules-updated", handleModuleUpdate);
259 window.addEventListener("yatra-force-nav-refresh", handleForceRefresh);
260 window.addEventListener(
261 "yatra-license-status-updated",
262 handleLicenseStatusUpdate,
263 );
264
265 return () => {
266 window.removeEventListener("yatra-modules-updated", handleModuleUpdate);
267 window.removeEventListener("yatra-force-nav-refresh", handleForceRefresh);
268 window.removeEventListener(
269 "yatra-license-status-updated",
270 handleLicenseStatusUpdate,
271 );
272 };
273 }, []);
274
275 useEffect(() => {
276 if (!isModulesPanelOpen) return;
277
278 const handleClickOutside = (event: MouseEvent) => {
279 if (
280 modulesPanelRef.current &&
281 !modulesPanelRef.current.contains(event.target as Node)
282 ) {
283 setIsModulesPanelOpen(false);
284 }
285 };
286
287 document.addEventListener("mousedown", handleClickOutside);
288 return () => document.removeEventListener("mousedown", handleClickOutside);
289 }, [isModulesPanelOpen]);
290
291 // Get current subpage and tab from URL
292 const currentSubpage = useMemo(() => {
293 const params = new URLSearchParams(window.location.search);
294 return params.get("subpage") || "dashboard";
295 // eslint-disable-next-line react-hooks/exhaustive-deps
296 }, [urlKey]);
297
298 // "New since last seen" counts for the sidebar badges (Bookings / Payments /
299 // Abandoned Recovery). Read-only — if the request fails, no badge is shown.
300 const { data: newCounts } = useNotificationCounts();
301 const getNewCount = (subpage: string): number => {
302 const section = SUBPAGE_TO_SECTION[subpage];
303 if (!section) return 0;
304 const value = newCounts?.[section];
305 return typeof value === "number" && value > 0 ? value : 0;
306 };
307
308 const currentTab = useMemo(() => {
309 const params = new URLSearchParams(window.location.search);
310 return params.get("tab") || "all";
311 // eslint-disable-next-line react-hooks/exhaustive-deps
312 }, [urlKey]);
313
314 const currentAction = useMemo(() => {
315 const params = new URLSearchParams(window.location.search);
316 return params.get("action");
317 // eslint-disable-next-line react-hooks/exhaustive-deps
318 }, [urlKey]);
319
320 // Check if we're on the trip form page
321 const isTripFormPage = useMemo(() => {
322 return (
323 currentSubpage === "trips" &&
324 (currentTab === "all" || !currentTab) &&
325 (currentAction === "create" || currentAction === "edit")
326 );
327 // eslint-disable-next-line react-hooks/exhaustive-deps
328 }, [currentSubpage, currentTab, currentAction, urlKey]);
329
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.
337 const [expandedMenus, setExpandedMenus] = useState<string[]>(() => {
338 const params = new URLSearchParams(window.location.search);
339 const subpage = params.get("subpage") || "dashboard";
340
341 return DEFAULT_MENU_ITEMS.some(
342 (item) => item.slug === subpage && (item.submenu?.length ?? 0) > 0,
343 )
344 ? [subpage]
345 : [];
346 });
347
348 // Get base admin URL
349 const baseUrl = useMemo(() => {
350 return window.yatraAdmin?.siteUrl
351 ? `${window.yatraAdmin.siteUrl}/wp-admin/admin.php?page=yatra`
352 : "/wp-admin/admin.php?page=yatra";
353 }, []);
354
355 const menuItems = useMemo(
356 // Every menu label is wrapped in __() with a literal string so
357 // gettext extraction (scripts/extract-js-pot.mjs) picks them up
358 // and Loco / Poedit / wp i18n can translate them. Previously most
359 // were raw English literals — translations existed in the .po but
360 // never reached the UI because there was no translation lookup at
361 // render time. Wrapping is the WordPress-canonical way; the
362 // pre_load_script_translations filter then delivers the locale
363 // data to wp.i18n on every admin page load.
364 () =>
365 [
366 {
367 subpage: "dashboard",
368 label: __("Dashboard", "yatra"),
369 icon: LayoutDashboard,
370 cap: "yatra_access_admin",
371 },
372 {
373 subpage: "trips",
374 label: __("Trips", "yatra"),
375 icon: MapPin,
376 cap: "yatra_view_trips",
377 submenu: [
378 { tab: "all", label: __("All Trips", "yatra"), icon: List },
379 {
380 tab: "activities",
381 label: __("Activities", "yatra"),
382 icon: Activity,
383 },
384 {
385 tab: "destinations",
386 label: __("Destinations", "yatra"),
387 icon: Route,
388 },
389 {
390 tab: "categories",
391 label: __("Categories", "yatra"),
392 icon: FolderTree,
393 },
394 {
395 tab: "difficulty-levels",
396 label: __("Difficulty Levels", "yatra"),
397 icon: TrendingUp,
398 },
399 // Availability - FREE feature, always show
400 {
401 tab: "availability",
402 label: __("Availability", "yatra"),
403 icon: CalendarDays,
404 },
405 // Attributes - FREE feature, always show
406 { tab: "attributes", label: __("Attributes", "yatra"), icon: Tag },
407 // Additional Services - show only if Pro plugin is active and module is enabled
408 ...(isProPluginActive() && isModuleActive("additional_services")
409 ? [
410 {
411 tab: "additional-services",
412 label: __("Additional Services", "yatra"),
413 icon: Package,
414 isPremium: true,
415 },
416 ]
417 : []),
418 // Trip Consent - show only if Pro plugin is active and module is enabled
419 ...(isProPluginActive() && isModuleActive("trip_consent")
420 ? [
421 {
422 tab: "trip-consent",
423 label: __("Trip Consent", "yatra"),
424 icon: FileSignature,
425 isPremium: true,
426 },
427 ]
428 : []),
429 ],
430 },
431 {
432 subpage: "traveler-categories",
433 label: __("Traveler Categories", "yatra"),
434 icon: UserCircle,
435 cap: "yatra_manage_trip_taxonomies",
436 },
437 {
438 subpage: "itinerary",
439 label: __("Itinerary", "yatra"),
440 icon: FileText,
441 cap: "yatra_edit_trips",
442 submenu: [
443 { tab: "item-types", label: __("Item Types", "yatra"), icon: Tag },
444 { tab: "items", label: __("Items", "yatra"), icon: Route },
445 {
446 tab: "itinerary",
447 label: __("Itinerary", "yatra"),
448 icon: FileText,
449 },
450 ],
451 },
452 // Departures - FREE feature, always show
453 {
454 subpage: "departures",
455 label: __("Departures", "yatra"),
456 icon: Calendar,
457 cap: "yatra_view_departures",
458 },
459 {
460 subpage: "discounts",
461 label: __("Discounts", "yatra"),
462 icon: BadgePercent,
463 cap: "yatra_manage_discounts",
464 },
465 {
466 subpage: "payments",
467 label: __("Payments", "yatra"),
468 icon: CreditCard,
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,
498 },
499 {
500 subpage: "bookings",
501 label: __("Bookings", "yatra"),
502 icon: Calendar,
503 cap: "yatra_view_bookings",
504 },
505 {
506 subpage: "customers",
507 label: __("Customers", "yatra"),
508 icon: UserCircle,
509 cap: "yatra_view_customers",
510 },
511 {
512 subpage: "travelers",
513 label: __("Travelers", "yatra"),
514 icon: Plane,
515 cap: "yatra_view_customers",
516 },
517 {
518 subpage: "enquiries",
519 label: __("Enquiries", "yatra"),
520 icon: MessageSquare,
521 cap: "yatra_view_enquiries",
522 },
523 {
524 subpage: "reviews",
525 label: __("Reviews", "yatra"),
526 icon: Star,
527 cap: "yatra_view_reviews",
528 },
529 {
530 subpage: "reports",
531 label: __("Reports", "yatra"),
532 icon: BarChart3,
533 cap: "yatra_view_operational_reports",
534 },
535 // Email — SMTP & transactional for all; Pro adds automation tabs on the same screen
536 {
537 subpage: "email-automation",
538 label: __("Email", "yatra"),
539 icon: Mail,
540 isPremium: false,
541 cap: "yatra_manage_emails",
542 },
543 // Abandoned Booking Recovery - show only if Pro plugin is active and module is enabled
544 ...(isProPluginActive() && isModuleActive("abandoned_booking_recovery")
545 ? [
546 {
547 subpage: "abandoned-recovery",
548 label: __("Abandoned Recovery", "yatra"),
549 icon: RotateCcw,
550 isPremium: true,
551 cap: "yatra_manage_email_automation",
552 },
553 ]
554 : []),
555 // Dynamic Pricing - show only if Pro plugin is active and module is enabled
556 ...(isProPluginActive() && isModuleActive("dynamic_pricing")
557 ? [
558 {
559 subpage: "dynamic-pricing",
560 label: __("Dynamic Pricing", "yatra"),
561 icon: TrendingUp,
562 isPremium: true,
563 cap: "yatra_edit_trips",
564 },
565 ]
566 : []),
567 {
568 subpage: "modules",
569 label: __("Modules", "yatra"),
570 icon: Puzzle,
571 cap: "yatra_manage_modules",
572 },
573 // White Label — only when the Agency-tier module is actually enabled.
574 // (Agency license alone is not enough; the module toggle must be on,
575 // otherwise users would see a link to a disabled feature. To get
576 // here for the first time, an Agency admin enables the module from
577 // Yatra → Modules.) The `whiteLabelEnabled` flag from
578 // AdminAssetsProvider already implies Agency-active.
579 // White Label — needs Agency-tier license AND module toggle on.
580 // Mirrors the AI Assistant gate below so both menus appear /
581 // disappear instantly when their module toggles, while staying
582 // license-safe if the client-side `whiteLabelEnabled` flag is
583 // ever stale or over-eager. AdminAssetsProvider seeds both
584 // `isAgency` and `whiteLabelEnabled`; useModules.ts updates the
585 // latter on toggle and fires `yatra-modules-updated` which bumps
586 // navRefreshKey → this memo recomputes → menu appears.
587 ...((window as any).yatraAdmin?.isAgency &&
588 (window as any).yatraAdmin?.whiteLabelEnabled
589 ? [
590 {
591 subpage: "white-label",
592 label: __("White Label", "yatra"),
593 icon: Crown,
594 isPremium: true,
595 cap: "yatra_manage_white_label",
596 },
597 ]
598 : []),
599 // AI Assistant — only when BOTH the license tier qualifies
600 // (Growth or Agency) AND the module toggle is on. Operators who
601 // disable the module shouldn't keep seeing the menu item — it
602 // would lead to a settings page they've explicitly opted out of.
603 // The toggle handler in useModules.ts updates
604 // window.yatraAdmin.aiAssistantEnabled and fires
605 // `yatra-modules-updated`, which bumps navRefreshKey so this
606 // memo recomputes immediately without a page reload.
607 ...((window as any).yatraAdmin?.isAiEligible &&
608 (window as any).yatraAdmin?.aiAssistantEnabled
609 ? [
610 {
611 subpage: "ai-assistant",
612 label: __("AI Assistant", "yatra"),
613 icon: Sparkles,
614 isPremium: true,
615 cap: "yatra_manage_ai",
616 },
617 ]
618 : []),
619 // WhatsApp Notifications — Growth/Agency module. Same gate
620 // pattern as AI Assistant: needs the AI-eligibility flag (which
621 // is the Growth-or-Agency tier check) AND the module toggle on.
622 // useModules.ts updates `whatsappEnabled` on toggle + fires
623 // `yatra-modules-updated` so this menu appears instantly without
624 // a page reload.
625 ...((window as any).yatraAdmin?.isAiEligible &&
626 (window as any).yatraAdmin?.whatsappEnabled
627 ? [
628 {
629 subpage: "whatsapp",
630 label: __("WhatsApp", "yatra"),
631 icon: MessageCircle,
632 isPremium: true,
633 cap: "yatra_manage_whatsapp",
634 },
635 ]
636 : []),
637 // Channel Manager — Agency-tier OTA distribution hub. Same gate
638 // pattern as White Label: needs the Agency license AND the module
639 // toggle on. useModules.ts updates `channelManagerEnabled` on
640 // toggle + fires `yatra-modules-updated` so this menu appears
641 // instantly without a page reload.
642 ...((window as any).yatraAdmin?.isAgency &&
643 (window as any).yatraAdmin?.channelManagerEnabled
644 ? [
645 {
646 subpage: "channel-manager",
647 label: __("Channel Manager", "yatra"),
648 icon: Network,
649 isPremium: true,
650 cap: "yatra_manage_channel_manager",
651 },
652 ]
653 : []),
654 // Webhooks — Agency-tier outbound integration hub. Same gate
655 // as Channel Manager / White Label: Agency license + module on.
656 ...((window as any).yatraAdmin?.isAgency &&
657 (window as any).yatraAdmin?.webhooksEnabled
658 ? [
659 {
660 subpage: "webhooks",
661 label: __("Webhooks", "yatra"),
662 icon: Webhook,
663 isPremium: true,
664 cap: "yatra_manage_webhooks",
665 },
666 ]
667 : []),
668 // Team & Access — Agency-tier granular roles + capability-level
669 // access + audit log. Gate matches Webhooks: Agency license +
670 // module on. The page itself is also cap-gated server-side; the
671 // sidebar entry is just UX.
672 ...((window as any).yatraAdmin?.isAgency &&
673 (window as any).yatraAdmin?.teamEnabled
674 ? [
675 {
676 subpage: "team",
677 label: __("Team & Access", "yatra"),
678 icon: Users,
679 isPremium: true,
680 cap: "yatra_manage_team",
681 },
682 ]
683 : []),
684 {
685 subpage: "license",
686 label: __("License", "yatra"),
687 icon: Key,
688 cap: "yatra_manage_settings",
689 },
690 {
691 subpage: "settings",
692 label: __("Settings", "yatra"),
693 icon: Settings,
694 cap: "yatra_manage_settings",
695 },
696 ]
697 // Cap-gate every entry. canCap() returns true for WP admins and
698 // when the Team module isn't enabled (= admin-only mode), so this
699 // is a no-op on every site without team management. With team
700 // management active, non-admin team members only see the menus
701 // their role's caps permit.
702 //
703 // Items without a `cap` field are always shown (default-allow).
704 .filter((item: any) => !item.cap || canCap(item.cap)),
705 // eslint-disable-next-line react-hooks/exhaustive-deps
706 [navRefreshKey],
707 ); // Re-calculate when navRefreshKey changes
708
709 /**
710 * Apply White Label customizations on top of the base menuItems.
711 *
712 * Implementation: factor menuItems into a flat atom registry, apply
713 * label/icon/hidden overrides, then rebuild the menu tree honoring
714 * the saved parent override (cross-parent moves) and per-parent
715 * order map. URLs always come from the atom's intrinsic identity —
716 * promoting a submenu to top-level does NOT change its route, so
717 * App.tsx routing keeps working unchanged.
718 */
719 const brandedMenuItems = useMemo(() => {
720 const overrides: MenuOverrides = readMenuOverrides();
721 const orderMap = readMenuOrder();
722
723 interface Atom {
724 key: string; // "trips" or "trips.activities"
725 defaultParent: string; // "" for top-level, parent slug for submenu
726 label: string;
727 iconOverride?: unknown;
728 icon: any;
729 // Bag of routing/render props copied from the original menu item.
730 subpage: string;
731 tab?: string;
732 isPremium?: boolean;
733 }
734
735 const atoms: Atom[] = [];
736 const atomByKey = new Map<string, Atom>();
737
738 const apply = (
739 key: string,
740 defaultParent: string,
741 base: any,
742 fallbackLabel: string,
743 ): Atom | null => {
744 const ov = overrides[key];
745 if (ov?.hidden) return null;
746 const labelOverride =
747 ov?.label && typeof ov.label === "string" && ov.label.trim() !== ""
748 ? ov.label
749 : null;
750 const atom: Atom = {
751 key,
752 defaultParent,
753 label: labelOverride ?? fallbackLabel,
754 iconOverride: ov?.icon,
755 icon: base.icon,
756 subpage: base.subpage ?? defaultParent ?? key,
757 tab: base.tab,
758 isPremium: base.isPremium,
759 };
760 atoms.push(atom);
761 atomByKey.set(key, atom);
762 return atom;
763 };
764
765 for (const item of menuItems) {
766 apply(item.subpage, "", item, item.label);
767 if (item.submenu) {
768 for (const sub of item.submenu as any[]) {
769 apply(
770 `${item.subpage}.${sub.tab}`,
771 item.subpage,
772 { icon: sub.icon, subpage: item.subpage, tab: sub.tab },
773 sub.label,
774 );
775 }
776 }
777 }
778
779 // Resolve each atom's effective parent (override wins; "" = top-level).
780 const effectiveParent = (atom: Atom): string => {
781 const ov = overrides[atom.key];
782 if (ov && typeof ov.parent === "string") {
783 // Only accept the override if it points at a known top-level atom
784 // (or empty for top-level). Otherwise fall back to the default.
785 if (ov.parent === "") return "";
786 if (atomByKey.get(ov.parent)?.defaultParent === "") return ov.parent;
787 }
788 return atom.defaultParent;
789 };
790
791 // Group atoms by effective parent and order each group.
792 const groups = new Map<string, Atom[]>();
793 for (const atom of atoms) {
794 const parent = effectiveParent(atom);
795 // Self-parenting guard — should never happen but stay defensive.
796 if (parent === atom.key) continue;
797 const list = groups.get(parent) ?? [];
798 list.push(atom);
799 groups.set(parent, list);
800 }
801
802 const orderGroup = (parent: string, group: Atom[]): Atom[] => {
803 const saved = orderMap[parent] ?? [];
804 if (saved.length === 0) return group;
805 const byKey = new Map(group.map((a) => [a.key, a]));
806 const seen = new Set<string>();
807 const ordered: Atom[] = [];
808 for (const k of saved) {
809 const atom = byKey.get(k);
810 if (atom && !seen.has(k)) {
811 ordered.push(atom);
812 seen.add(k);
813 }
814 }
815 for (const atom of group) {
816 if (!seen.has(atom.key)) ordered.push(atom);
817 }
818 return ordered;
819 };
820
821 const topLevel = orderGroup("", groups.get("") ?? []);
822 return topLevel.map((atom) => {
823 const childAtoms = orderGroup(atom.key, groups.get(atom.key) ?? []);
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,
830 subpage: atom.subpage,
831 label: atom.label,
832 icon: atom.icon,
833 iconOverride: atom.iconOverride,
834 isPremium: atom.isPremium,
835 submenu: childAtoms.length
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,
840 // Submenu rendering supports both legacy intra-parent items
841 // (matching parent's subpage) and promoted/demoted items
842 // that point at their own subpage.
843 tab: child.defaultParent === atom.subpage ? child.tab : undefined,
844 label: child.label,
845 icon: child.icon,
846 iconOverride: child.iconOverride,
847 // Promoted top-level items now-as-children get an absolute href.
848 href:
849 child.defaultParent === atom.subpage
850 ? undefined
851 : `${baseUrl}&subpage=${child.subpage}${child.tab ? `&tab=${child.tab}` : ""}`,
852 subpage: child.subpage,
853 tab_orig: child.tab,
854 }))
855 : undefined,
856 };
857 });
858 }, [menuItems, baseUrl]);
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
880 /** UI chrome visibility flags (version, Back to WP, Join Community). */
881 const uiChrome = useMemo(() => readUiChrome(), []);
882
883 const isActive = (subpage: string, tab?: string) => {
884 // The Google Calendar dashboard is reached from Settings → Integration, so
885 // keep the Settings menu item highlighted while that page is open.
886 if (subpage === "settings" && currentSubpage === "google-calendar") {
887 return true;
888 }
889 if (tab) {
890 return currentSubpage === subpage && currentTab === tab;
891 }
892 // For parent menu items, check if current subpage matches
893 // or if any submenu item is active
894 if (currentSubpage === subpage) {
895 const menuItem = menuItems.find((item) => item.subpage === subpage);
896 if (menuItem?.submenu) {
897 // If it has submenu, check if any submenu item is active
898 return menuItem.submenu.some((sub) => sub.tab === currentTab);
899 }
900 return true;
901 }
902 return false;
903 };
904
905 const isMenuExpanded = (subpage: string) => {
906 return expandedMenus.includes(subpage);
907 };
908
909 const toggleMenu = (subpage: string) => {
910 setExpandedMenus((prev) =>
911 prev.includes(subpage)
912 ? prev.filter((m) => m !== subpage)
913 : [...prev, subpage],
914 );
915 };
916
917 const getUrl = (subpage: string, tab?: string) => {
918 if (subpage === "dashboard") {
919 return baseUrl;
920 }
921 if (tab) {
922 return `${baseUrl}&subpage=${subpage}&tab=${tab}`;
923 }
924 return `${baseUrl}&subpage=${subpage}`;
925 };
926
927 return (
928 <div
929 className={`min-h-screen ${darkMode ? "dark bg-gray-900" : "bg-gray-50"}`}
930 >
931 <div className="flex h-screen overflow-hidden">
932 {/* Sidebar */}
933 <aside className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
934 {/* Logo */}
935 <div className="h-16 px-6 flex items-center justify-center border-b border-gray-200 dark:border-gray-700">
936 <div className="flex flex-col gap-1">
937 <div className="flex items-center gap-3">
938 {(() => {
939 const brandName =
940 (window as any).yatraAdmin?.brandName || "Yatra";
941 const brandInitial = brandName.charAt(0).toUpperCase() || "Y";
942 return (window as any).yatraAdmin?.brandLogoUrl ? (
943 <img
944 src={(window as any).yatraAdmin.brandLogoUrl}
945 alt={brandName}
946 width={32}
947 height={32}
948 className="w-8 h-8 rounded-lg object-contain shrink-0 bg-blue-600 p-1 border border-blue-700 dark:border-blue-500"
949 />
950 ) : (
951 <div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center shrink-0">
952 <span className="text-white font-bold text-lg">
953 {brandInitial}
954 </span>
955 </div>
956 );
957 })()}
958 <div className="flex flex-col">
959 <span className="text-xl font-bold text-gray-900 dark:text-white">
960 {(window as any).yatraAdmin?.brandName || "Yatra"}
961 </span>
962 {!uiChrome.hideVersion && (
963 <div className="flex items-center gap-2 text-[10px] text-gray-500 dark:text-gray-400">
964 <span>v{window.yatraAdmin?.version || "1.0.0"}</span>
965 {(window as any).yatraAdmin?.proVersion && (
966 <span className="px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 rounded font-medium">
967 Pro v{(window as any).yatraAdmin?.proVersion}
968 </span>
969 )}
970 </div>
971 )}
972 </div>
973 </div>
974 </div>
975 </div>
976
977 {/* Navigation + Bottom Actions */}
978 <nav className="flex-1 p-4 space-y-1 overflow-y-auto">
979 {brandedMenuItems.map((item) => {
980 const Icon = item.icon;
981 const hasSubmenu = item.submenu && item.submenu.length > 0;
982 const isExpanded = hasSubmenu && isMenuExpanded(item.subpage);
983 const active = isActive(item.subpage);
984
985 return (
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 >
996 {hasSubmenu ? (
997 <>
998 <button
999 onClick={() => toggleMenu(item.subpage)}
1000 className={`w-full flex items-center justify-between gap-3 px-4 py-3 rounded-lg text-sm transition-colors ${
1001 active
1002 ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium"
1003 : "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
1004 }`}
1005 >
1006 <div className="flex items-center gap-3">
1007 <MenuIcon
1008 icon={(item as any).iconOverride}
1009 fallback={Icon}
1010 className="w-5 h-5"
1011 />
1012 <span>{item.label}</span>
1013 </div>
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>
1034 </button>
1035 {isExpanded && item.submenu && (
1036 <div className="ml-4 mt-1 space-y-1">
1037 {item.submenu.map((subItem: any) => {
1038 // Promoted / demoted child carries its own
1039 // routing target via subpage + tab_orig; the
1040 // default in-parent child uses the parent's
1041 // subpage with its tab.
1042 const navSubpage = subItem.subpage ?? item.subpage;
1043 const navTab = subItem.tab ?? subItem.tab_orig;
1044 const subActive = navTab
1045 ? isActive(navSubpage, navTab)
1046 : isActive(navSubpage);
1047 const SubIcon = subItem.icon;
1048 return (
1049 <a
1050 key={
1051 subItem.menuKey ??
1052 `${navSubpage}.${navTab ?? ""}`
1053 }
1054 href={getUrl(navSubpage, navTab)}
1055 onClick={(e) =>
1056 handleMenuNavClick(e, navSubpage, navTab)
1057 }
1058 className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors relative ${
1059 subActive
1060 ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium"
1061 : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
1062 }`}
1063 >
1064 <div className="flex items-center gap-3">
1065 {SubIcon && (
1066 <div className="w-4 h-4">
1067 <MenuIcon
1068 icon={(subItem as any).iconOverride}
1069 fallback={SubIcon}
1070 className="w-4 h-4"
1071 />
1072 </div>
1073 )}
1074 <span className="text-sm">
1075 {subItem.label}
1076 </span>
1077 </div>
1078 {subItem.isPremium && !isProPluginActive() && (
1079 <div className="absolute inset-y-0 right-2 flex items-center justify-center">
1080 <div className="w-4 h-4 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white flex items-center justify-center">
1081 <Crown className="w-2.5 h-2.5" />
1082 </div>
1083 </div>
1084 )}
1085 </a>
1086 );
1087 })}
1088 </div>
1089 )}
1090 </>
1091 ) : (
1092 <a
1093 href={getUrl(item.subpage)}
1094 onClick={(e) => handleMenuNavClick(e, item.subpage)}
1095 className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors relative ${
1096 active
1097 ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium"
1098 : "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
1099 }`}
1100 >
1101 <div className="flex items-center gap-3">
1102 <MenuIcon
1103 icon={(item as any).iconOverride}
1104 fallback={Icon}
1105 className="w-5 h-5"
1106 />
1107 <span>{item.label}</span>
1108 </div>
1109 {/* New-since-last-seen badge (Bookings / Payments /
1110 Abandoned Recovery). Hidden while viewing that page. */}
1111 {!active && getNewCount(item.subpage) > 0 && (
1112 <span
1113 className="ml-auto 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"
1114 aria-label={`${getNewCount(item.subpage)} ${__("new", "yatra")}`}
1115 >
1116 {getNewCount(item.subpage) > 99
1117 ? "99+"
1118 : getNewCount(item.subpage)}
1119 </span>
1120 )}
1121 {item.isPremium && !isProPluginActive() && (
1122 <div className="absolute inset-y-0 right-2 flex items-center justify-center">
1123 <div className="w-4 h-4 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 text-white flex items-center justify-center">
1124 <Crown className="w-2.5 h-2.5" />
1125 </div>
1126 </div>
1127 )}
1128 {item.subpage === "license" &&
1129 isProPluginActive() &&
1130 licenseStatus && (
1131 <Badge
1132 variant={
1133 licenseStatus === "active"
1134 ? "success"
1135 : licenseStatus === "expired"
1136 ? "error"
1137 : licenseStatus === "invalid"
1138 ? "error"
1139 : "error"
1140 }
1141 className="text-[10px] px-2 py-0.5"
1142 >
1143 {licenseStatus === "active"
1144 ? "Active"
1145 : licenseStatus === "expired"
1146 ? "Expired"
1147 : licenseStatus === "invalid"
1148 ? "Invalid"
1149 : "Inactive"}
1150 </Badge>
1151 )}
1152 </a>
1153 )}
1154 </div>
1155 );
1156 })}
1157 </nav>
1158
1159 {!isProPluginActive() && (
1160 <div className="shrink-0 px-4 pb-3">
1161 <a
1162 href="https://wpyatra.com/pricing/"
1163 target="_blank"
1164 rel="noopener noreferrer"
1165 className="group flex flex-col gap-0.5 rounded-lg border border-amber-200/90 dark:border-amber-800/40 bg-amber-50/60 dark:bg-amber-950/25 px-3 py-2.5 text-left transition-colors hover:bg-amber-50 dark:hover:bg-amber-950/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-800"
1166 >
1167 <span className="inline-flex items-center gap-1.5 text-xs font-semibold text-amber-900 dark:text-amber-200">
1168 <Crown
1169 className="w-3.5 h-3.5 shrink-0 text-amber-600 dark:text-amber-400"
1170 aria-hidden
1171 />
1172 {__("Upgrade to Pro", "yatra")}
1173 </span>
1174 <span className="text-[11px] leading-snug text-amber-800/75 dark:text-amber-300/70">
1175 {__("Premium features & integrations — view plans.", "yatra")}
1176 </span>
1177 </a>
1178 </div>
1179 )}
1180
1181 {/* Sticky bottom back link */}
1182 {!uiChrome.hideBackToWp && (
1183 <div className="border-t border-gray-200 dark:border-gray-700 p-4">
1184 <a
1185 href={
1186 window.yatraAdmin?.siteUrl
1187 ? `${window.yatraAdmin.siteUrl}/wp-admin/`
1188 : "/wp-admin/"
1189 }
1190 className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg text-xs font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
1191 >
1192 <span className="inline-flex items-center gap-2">
1193 <ArrowLeft className="w-3 h-3" />
1194 <span>{__("Back to WordPress", "yatra")}</span>
1195 </span>
1196 </a>
1197 </div>
1198 )}
1199 </aside>
1200
1201 {/* Main Content */}
1202 <div
1203 className={`flex-1 flex flex-col ${isTripFormPage ? "overflow-hidden" : "overflow-y-auto"}`}
1204 >
1205 {/* Top Bar */}
1206 <header className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 flex items-center">
1207 <div className="flex items-center justify-between w-full">
1208 <div className="flex items-center gap-3 flex-wrap min-w-0">
1209 <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
1210 {(() => {
1211 // Show specific text for trip form page
1212 if (isTripFormPage) {
1213 return currentAction === "create"
1214 ? "Create Trip"
1215 : "Edit Trip";
1216 }
1217
1218 const activeItem = brandedMenuItems.find((item) =>
1219 isActive(item.subpage),
1220 );
1221 if (activeItem?.submenu && currentTab) {
1222 const activeSubItem = activeItem.submenu.find(
1223 (sub) => sub.tab === currentTab,
1224 );
1225 return activeSubItem?.label || activeItem.label;
1226 }
1227 return activeItem?.label || "Dashboard";
1228 })()}
1229 </h1>
1230
1231 {/* Conversion pill — visible only on free-plugin installs. */}
1232 {/* Hidden as soon as the Pro plugin is active. Single edit */}
1233 {/* in this top-bar surfaces it on every admin page. */}
1234 {/* Gift icon leads on the "free" promise (operators read */}
1235 {/* the icon before the text). Real Tooltip component */}
1236 {/* instead of the browser `title` attribute so the hint */}
1237 {/* matches the rest of the admin's hover-help style. */}
1238 {!isProPluginActive() && (
1239 <Tooltip
1240 side="bottom"
1241 content={__(
1242 "Spin up a free Pro trial site in one click — no credit card required.",
1243 "yatra",
1244 )}
1245 >
1246 <a
1247 href="https://try.wpyatra.com/try-yatra-pro/"
1248 target="_blank"
1249 rel="noopener noreferrer"
1250 className="inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-900 ring-1 ring-amber-200 hover:bg-amber-100 hover:ring-amber-300 dark:bg-amber-950/40 dark:text-amber-100 dark:ring-amber-800 dark:hover:bg-amber-900/50 transition-colors"
1251 >
1252 <Gift className="h-3.5 w-3.5" />
1253 {__("Try Yatra Pro for free", "yatra")}
1254 </a>
1255 </Tooltip>
1256 )}
1257 </div>
1258
1259 <div className="flex items-center gap-4">
1260 {/* Back to WordPress button */}
1261 {!uiChrome.hideBackToWp && (
1262 <a
1263 href={
1264 window.yatraAdmin?.siteUrl
1265 ? `${window.yatraAdmin.siteUrl}/wp-admin/`
1266 : "/wp-admin/"
1267 }
1268 target="_blank"
1269 rel="noopener noreferrer"
1270 className="inline-flex items-center gap-2 px-3 py-2 text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
1271 >
1272 <ArrowLeft className="w-4 h-4" />
1273 {__("Back to WordPress", "yatra")}
1274 </a>
1275 )}
1276
1277 {!uiChrome.hideJoinCommunity && (
1278 <a
1279 href="https://www.facebook.com/groups/yatrawordpressplugin"
1280 target="_blank"
1281 rel="noopener noreferrer"
1282 className="inline-flex items-center gap-2 px-3 py-2 text-sm text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
1283 >
1284 <MessageSquare className="w-4 h-4 shrink-0" aria-hidden />
1285 {__("Join Community", "yatra")}
1286 </a>
1287 )}
1288
1289 {/* Flush permalink (same API as Settings → Permalink); icon + native title for tooltip */}
1290 <Button
1291 type="button"
1292 variant="outline"
1293 size="icon"
1294 onClick={() => flushRewriteRulesMutation.mutate()}
1295 disabled={flushRewriteRulesMutation.isPending}
1296 className="h-9 w-9 shrink-0 border-gray-300 dark:border-gray-600"
1297 title={
1298 flushRewriteRulesMutation.isPending
1299 ? __("Flushing...", "yatra")
1300 : __("Flush permalink", "yatra")
1301 }
1302 aria-label={
1303 flushRewriteRulesMutation.isPending
1304 ? __("Flushing rewrite rules", "yatra")
1305 : __("Flush permalink", "yatra")
1306 }
1307 >
1308 <RotateCcw
1309 className={`h-4 w-4 ${flushRewriteRulesMutation.isPending ? "animate-spin" : ""}`}
1310 aria-hidden
1311 />
1312 </Button>
1313
1314 <ConditionalRender capability="yatra_edit_trips">
1315 <Button
1316 variant={currentSubpage === "tools" ? "default" : "outline"}
1317 onClick={() => {
1318 navigateMenu("tools");
1319 }}
1320 className={`flex items-center gap-2 ${
1321 currentSubpage === "tools"
1322 ? "bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
1323 : ""
1324 }`}
1325 >
1326 <Wrench className="w-4 h-4" />
1327 {__("Tools", "yatra")}
1328 </Button>
1329 </ConditionalRender>
1330 <ConditionalRender capability="yatra_edit_trips">
1331 <div className="relative">
1332 <button
1333 onClick={() => setIsModulesPanelOpen((prev) => !prev)}
1334 className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400 ${isModulesPanelOpen ? "bg-gray-100 dark:bg-gray-700" : ""}`}
1335 aria-label={__("Toggle modules panel", "yatra")}
1336 >
1337 <Puzzle className="w-5 h-5" />
1338 </button>
1339 {isModulesPanelOpen && (
1340 <div
1341 ref={modulesPanelRef}
1342 className="absolute right-0 top-12 z-50 w-80"
1343 >
1344 <Card className="shadow-xl border border-gray-200 dark:border-gray-700">
1345 <CardHeader className="pb-3">
1346 <div className="flex items-center justify-between">
1347 <div>
1348 <CardTitle className="text-base">
1349 {__("Modules", "yatra")}
1350 </CardTitle>
1351 <CardDescription>
1352 {__(
1353 "Quickly enable or disable feature packs.",
1354 "yatra",
1355 )}
1356 </CardDescription>
1357 </div>
1358 <Button
1359 size="sm"
1360 variant="ghost"
1361 onClick={() => {
1362 setIsModulesPanelOpen(false);
1363 navigateMenu("modules");
1364 }}
1365 >
1366 {__("Open", "yatra")}
1367 </Button>
1368 </div>
1369 </CardHeader>
1370 <CardContent className="space-y-3">
1371 {isLoadingModules && (
1372 <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm">
1373 <Loader2 className="w-4 h-4 animate-spin" />
1374 {__("Loading modules…", "yatra")}
1375 </div>
1376 )}
1377 {!isLoadingModules &&
1378 modulesPreview.length === 0 && (
1379 <div className="text-sm text-gray-500 dark:text-gray-400">
1380 {__("No modules found.", "yatra")}
1381 </div>
1382 )}
1383 {!isLoadingModules && modulesPreview.length > 0 && (
1384 <div className="space-y-3">
1385 {modulesPreview.map((module) => (
1386 <div
1387 key={module.slug}
1388 className="flex items-center justify-between border border-gray-100 dark:border-gray-800 rounded-lg p-2"
1389 >
1390 <div>
1391 <p className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
1392 {module.name}
1393 {module.is_core && (
1394 <Badge
1395 variant="outline"
1396 className="text-[10px]"
1397 >
1398 {__("Core", "yatra")}
1399 </Badge>
1400 )}
1401 </p>
1402 <p className="text-xs text-gray-500 dark:text-gray-400">
1403 {module.enabled
1404 ? __("Enabled", "yatra")
1405 : __("Disabled", "yatra")}
1406 </p>
1407 </div>
1408 <button
1409 onClick={() =>
1410 handleQuickToggle(
1411 module,
1412 !module.enabled,
1413 )
1414 }
1415 disabled={
1416 module.is_core ||
1417 toggleModuleMutation.isPending
1418 }
1419 className={`relative inline-flex h-6 w-11 items-center rounded-full transition ${
1420 module.enabled
1421 ? "bg-blue-600"
1422 : "bg-gray-300 dark:bg-gray-600"
1423 } ${module.is_core ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
1424 aria-pressed={module.enabled}
1425 aria-label={
1426 module.enabled
1427 ? __("Disable module", "yatra")
1428 : __("Enable module", "yatra")
1429 }
1430 >
1431 <span
1432 className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${
1433 module.enabled
1434 ? "translate-x-5"
1435 : "translate-x-1"
1436 }`}
1437 />
1438 </button>
1439 </div>
1440 ))}
1441 </div>
1442 )}
1443 <div className="pt-2">
1444 <Button
1445 variant="outline"
1446 size="sm"
1447 className="w-full"
1448 onClick={() => {
1449 setIsModulesPanelOpen(false);
1450 navigateMenu("modules");
1451 }}
1452 >
1453 {__("Manage all modules", "yatra")}
1454 </Button>
1455 </div>
1456 </CardContent>
1457 </Card>
1458 </div>
1459 )}
1460 </div>
1461 </ConditionalRender>
1462 <button
1463 onClick={() => {
1464 setDarkMode(!darkMode);
1465 }}
1466 className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400"
1467 aria-label={
1468 darkMode ? "Switch to light mode" : "Switch to dark mode"
1469 }
1470 >
1471 {darkMode ? (
1472 <Sun className="w-5 h-5" />
1473 ) : (
1474 <Moon className="w-5 h-5" />
1475 )}
1476 </button>
1477
1478 <div className="relative">
1479 <button
1480 onClick={() => setIsUserDropdownOpen((prev) => !prev)}
1481 className="flex items-center gap-3 pl-4 border-l border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 p-2 transition-colors"
1482 aria-label={__("User menu", "yatra")}
1483 >
1484 <div className="w-8 h-8 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700">
1485 <img
1486 src={getGravatarUrl(32)}
1487 alt={__("User avatar", "yatra")}
1488 className="w-full h-full object-cover"
1489 onError={(e) => {
1490 e.currentTarget.style.display = "none";
1491 e.currentTarget.parentElement!.innerHTML =
1492 '<svg class="w-5 h-5 text-gray-600 dark:text-gray-400 m-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>';
1493 }}
1494 />
1495 </div>
1496 </button>
1497
1498 {isUserDropdownOpen && (
1499 <div
1500 ref={userDropdownRef}
1501 className="absolute right-0 top-12 z-50 w-64"
1502 >
1503 <Card className="shadow-xl border border-gray-200 dark:border-gray-700">
1504 <CardContent className="p-0">
1505 <div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
1506 <div className="flex items-center gap-3">
1507 <div className="w-10 h-10 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700">
1508 <img
1509 src={getGravatarUrl(40)}
1510 alt={__("User avatar", "yatra")}
1511 className="w-full h-full object-cover"
1512 onError={(e) => {
1513 e.currentTarget.style.display = "none";
1514 e.currentTarget.parentElement!.innerHTML =
1515 '<svg class="w-6 h-6 text-gray-600 dark:text-gray-400 m-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>';
1516 }}
1517 />
1518 </div>
1519 <div className="flex-1 min-w-0">
1520 <div className="text-sm font-medium text-gray-900 dark:text-white truncate">
1521 {(window as any)?.yatraAdmin
1522 ?.currentUserDisplayName ||
1523 (window as any)?.yatraAdmin
1524 ?.currentUserLogin ||
1525 "Admin"}
1526 </div>
1527 <div className="text-xs text-gray-500 dark:text-gray-400 truncate">
1528 {(window as any)?.yatraAdmin
1529 ?.currentUserEmail || ""}
1530 </div>
1531 </div>
1532 </div>
1533 </div>
1534
1535 <a
1536 href={`${(window as any)?.yatraAdmin?.siteUrl || ""}/wp-admin/profile.php`}
1537 target="_blank"
1538 rel="noopener noreferrer"
1539 className="w-full text-left px-4 py-3 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors"
1540 >
1541 <User className="w-4 h-4" />
1542 {__("Edit Profile", "yatra")}
1543 </a>
1544
1545 <a
1546 href={`${(window as any)?.yatraAdmin?.siteUrl || ""}/wp-admin/`}
1547 target="_blank"
1548 rel="noopener noreferrer"
1549 className="w-full text-left px-4 py-3 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3 transition-colors"
1550 >
1551 <svg
1552 className="w-4 h-4"
1553 fill="none"
1554 stroke="currentColor"
1555 viewBox="0 0 24 24"
1556 >
1557 <path
1558 strokeLinecap="round"
1559 strokeLinejoin="round"
1560 strokeWidth={2}
1561 d="M10 19l-7-7m0 0l7-7m-7 7h18"
1562 />
1563 </svg>
1564 {__("Back to WordPress", "yatra")}
1565 </a>
1566
1567 <button
1568 onClick={() => {
1569 const admin = (window as any)?.yatraAdmin;
1570 const siteUrl = admin?.siteUrl || "";
1571 window.location.href = `${siteUrl}/wp-login.php?action=logout`;
1572 }}
1573 className="w-full text-left px-4 py-3 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 flex items-center gap-3 transition-colors"
1574 >
1575 <svg
1576 className="w-4 h-4"
1577 fill="none"
1578 stroke="currentColor"
1579 viewBox="0 0 24 24"
1580 >
1581 <path
1582 strokeLinecap="round"
1583 strokeLinejoin="round"
1584 strokeWidth={2}
1585 d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
1586 />
1587 </svg>
1588 {__("Logout", "yatra")}
1589 </button>
1590 </CardContent>
1591 </Card>
1592 </div>
1593 )}
1594 </div>
1595 </div>
1596 </div>
1597 </header>
1598
1599 {/* Admin notices (Review / Buy Pro, etc.) */}
1600 <InlineNotices />
1601
1602 {/* License Warning Banner */}
1603 {isProPluginActive() &&
1604 licenseStatus &&
1605 licenseStatus !== "active" && (
1606 <div className="bg-red-50 dark:bg-red-950/30 border-l-4 border-b-2 border-red-500">
1607 <div className="px-6 py-3">
1608 <div className="flex items-center gap-4">
1609 <div className="flex-shrink-0">
1610 <div className="w-10 h-10 rounded-lg bg-red-500 flex items-center justify-center">
1611 <svg
1612 className="w-6 h-6 text-white"
1613 fill="currentColor"
1614 viewBox="0 0 20 20"
1615 >
1616 <path
1617 fillRule="evenodd"
1618 d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
1619 clipRule="evenodd"
1620 />
1621 </svg>
1622 </div>
1623 </div>
1624 <div className="flex-1 min-w-0">
1625 <p className="text-sm font-medium text-red-800 dark:text-red-200">
1626 <span className="font-semibold">
1627 {licenseStatus === "expired"
1628 ? "License Expired: "
1629 : licenseStatus === "invalid"
1630 ? "Invalid License: "
1631 : "License Not Activated: "}
1632 </span>
1633 {licenseStatus === "expired"
1634 ? "Renew your license to continue receiving updates and support."
1635 : licenseStatus === "invalid"
1636 ? "Please check your license key."
1637 : "Activate your license to receive updates and support."}
1638 </p>
1639 </div>
1640 <a
1641 href={getUrl("license")}
1642 onClick={(e) => handleMenuNavClick(e, "license")}
1643 className="flex-shrink-0 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-md transition-colors"
1644 >
1645 {licenseStatus === "expired"
1646 ? "Renew Now"
1647 : "Activate Now"}
1648 </a>
1649 </div>
1650 </div>
1651 </div>
1652 )}
1653
1654 {/* Page Content */}
1655 <main
1656 className={`flex-1 ${isTripFormPage ? "p-0 overflow-hidden flex flex-col" : "p-6 overflow-y-auto"}`}
1657 >
1658 <div
1659 className={
1660 isTripFormPage
1661 ? "flex-1 min-h-0 overflow-hidden flex flex-col"
1662 : currentSubpage === "tools"
1663 ? ""
1664 : "space-y-6"
1665 }
1666 >
1667 {children}
1668 </div>
1669 </main>
1670 </div>
1671 </div>
1672 </div>
1673 );
1674 };
1675
1676 export default Layout;
1677