PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.1
Yatra – Travel Booking & Tour Operator Software v3.0.14.1
3.0.16 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 All 84 releases
yatra / resources / js / components / Layout.tsx

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

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