PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.8
Yatra – Travel Booking & Tour Operator Software v3.0.2.8
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.2.8, at resources/js/components/Layout.tsx

1,125 lines 46.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 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 ChevronDown,
30 ChevronRight,
31 Mail,
32 Key,
33 FileSignature,
34 Route,
35 BadgePercent,
36 Plane,
37 MessageSquare,
38 Puzzle,
39 ArrowLeft,
40 Loader2,
41 RotateCcw,
42 Sun,
43 User,
44 } from "lucide-react";
45 import { __ } from "../lib/i18n";
46 import { Button } from "../components/ui/button";
47 import { useToast } from "../components/ui/toast";
48 import { postFlushRewriteRules } from "../api/settings-api";
49
50 // Helper function to extract Gravatar URL from WordPress get_avatar HTML
51 function extractGravatarUrl(avatarHtml: string, size: number): string {
52 if (!avatarHtml) {
53 return `https://www.gravatar.com/avatar/00000000000000000000000000000000?s=${size}&d=identicon&r=pg`;
54 }
55
56 // Extract src attribute from img tag
57 const imgMatch = avatarHtml.match(/<img[^>]+src=["']([^"']+)["']/i);
58 if (imgMatch && imgMatch[1]) {
59 // Replace size parameter if needed
60 return imgMatch[1].replace(/s=\d+/, `s=${size}`);
61 }
62
63 // Fallback to default
64 return `https://www.gravatar.com/avatar/00000000000000000000000000000000?s=${size}&d=identicon&r=pg`;
65 }
66
67 // Helper function to get Gravatar URL using WordPress data
68 function getGravatarUrl(size: number): string {
69 const avatarHtml = (window as any)?.yatraAdmin?.currentUserAvatar || "";
70 return extractGravatarUrl(avatarHtml, size);
71 }
72
73 import { ConditionalRender } from "../components/ui/conditional-render";
74 import {
75 Card,
76 CardContent,
77 CardHeader,
78 CardTitle,
79 CardDescription,
80 } from "../components/ui/card";
81 import { Badge } from "../components/ui/badge";
82 import {
83 useModulesQuery,
84 useToggleModule,
85 type ModuleDefinition,
86 } from "../hooks/useModules";
87 import { isProPluginActive, isModuleActive } from "../lib/plugin-utils";
88 import { navigateMenu } from "../hooks/useNavigate";
89 import { InlineNotices } from "./notices/InlineNotices";
90
91 interface LayoutProps {
92 children: React.ReactNode;
93 }
94
95 const Layout: React.FC<LayoutProps> = ({ children }) => {
96 // Load dark mode preference from localStorage
97 const [darkMode, setDarkMode] = useState(() => {
98 const saved = localStorage.getItem("yatra-dark-mode");
99 return saved === "true";
100 });
101
102 // Apply dark mode to document on mount and when it changes
103 useEffect(() => {
104 const root = document.documentElement;
105 if (darkMode) {
106 root.classList.add("dark");
107 localStorage.setItem("yatra-dark-mode", "true");
108 } else {
109 root.classList.remove("dark");
110 localStorage.setItem("yatra-dark-mode", "false");
111 }
112 }, [darkMode]);
113
114 const [isModulesPanelOpen, setIsModulesPanelOpen] = useState(false);
115 const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
116 const modulesPanelRef = useRef<HTMLDivElement | null>(null);
117 const userDropdownRef = useRef<HTMLDivElement | null>(null);
118
119 // License status state for real-time updates
120 const [licenseStatus, setLicenseStatus] = useState<string | null>(
121 (window as any).yatraAdmin?.licenseStatus || null,
122 );
123
124 const { showToast } = useToast();
125 const flushRewriteRulesMutation = useMutation({
126 mutationFn: async () => postFlushRewriteRules(),
127 onSuccess: () => {
128 showToast(__("Rewrite rules flushed successfully", "yatra"), "success");
129 },
130 onError: (error: any) => {
131 showToast(
132 error?.message || __("Failed to flush rewrite rules", "yatra"),
133 "error",
134 );
135 },
136 });
137 const { data: modulesData, isLoading: isLoadingModules } = useModulesQuery({
138 enabled: isModulesPanelOpen,
139 });
140 const toggleModuleMutation = useToggleModule();
141 // Ensure modulesData is always an array before slicing
142 const safeModulesData = Array.isArray(modulesData) ? modulesData : [];
143 const modulesPreview = useMemo<ModuleDefinition[]>(
144 () => safeModulesData.slice(0, 3),
145 [safeModulesData],
146 );
147 const handleQuickToggle = (module: ModuleDefinition, enabled: boolean) => {
148 toggleModuleMutation.mutate({
149 slug: module.slug,
150 enabled,
151 name: module.name,
152 });
153 };
154
155 // Track URL changes to update menu state
156 const [urlKey, setUrlKey] = useState(0);
157 const [navRefreshKey, setNavRefreshKey] = useState(0);
158
159 /** SPA: avoid full reload — keeps PHP boot splash from showing on every sidebar click */
160 const handleMenuNavClick = useCallback(
161 (e: React.MouseEvent<HTMLAnchorElement>, subpage: string, tab?: string) => {
162 if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || e.button !== 0) {
163 return;
164 }
165 e.preventDefault();
166 navigateMenu(subpage, tab);
167 },
168 [],
169 );
170
171 // Close dropdowns when clicking outside
172 useEffect(() => {
173 const handleClickOutside = (event: MouseEvent) => {
174 if (
175 modulesPanelRef.current &&
176 !modulesPanelRef.current.contains(event.target as Node)
177 ) {
178 setIsModulesPanelOpen(false);
179 }
180 if (
181 userDropdownRef.current &&
182 !userDropdownRef.current.contains(event.target as Node)
183 ) {
184 setIsUserDropdownOpen(false);
185 }
186 };
187
188 document.addEventListener("mousedown", handleClickOutside);
189 return () => document.removeEventListener("mousedown", handleClickOutside);
190 }, []);
191
192 useEffect(() => {
193 const handleLocationChange = () => {
194 setUrlKey((prev) => prev + 1);
195 };
196
197 // Listen for popstate (back/forward button)
198 window.addEventListener("popstate", handleLocationChange);
199
200 // Also check periodically (fallback for direct navigation)
201 const interval = setInterval(() => {
202 const currentSearch = window.location.search;
203 if (currentSearch !== (window as any).__lastSearch) {
204 (window as any).__lastSearch = currentSearch;
205 handleLocationChange();
206 }
207 }, 100);
208
209 return () => {
210 window.removeEventListener("popstate", handleLocationChange);
211 clearInterval(interval);
212 };
213 }, []);
214
215 // Listen for module updates to refresh navigation
216 useEffect(() => {
217 const handleModuleUpdate = () => {
218 // Force re-render of menu items by updating navRefreshKey
219 setNavRefreshKey((prev) => prev + 1);
220 // Also update urlKey to ensure all memoized values refresh
221 setUrlKey((prev) => prev + 1);
222 };
223
224 const handleForceRefresh = () => {
225 setNavRefreshKey((prev) => prev + 1);
226 setUrlKey((prev) => prev + 1);
227 };
228
229 const handleLicenseStatusUpdate = (event: any) => {
230 const newStatus = event.detail?.status;
231 if (newStatus) {
232 setLicenseStatus(newStatus);
233 }
234 };
235
236 window.addEventListener("yatra-modules-updated", handleModuleUpdate);
237 window.addEventListener("yatra-force-nav-refresh", handleForceRefresh);
238 window.addEventListener(
239 "yatra-license-status-updated",
240 handleLicenseStatusUpdate,
241 );
242
243 return () => {
244 window.removeEventListener("yatra-modules-updated", handleModuleUpdate);
245 window.removeEventListener("yatra-force-nav-refresh", handleForceRefresh);
246 window.removeEventListener(
247 "yatra-license-status-updated",
248 handleLicenseStatusUpdate,
249 );
250 };
251 }, []);
252
253 useEffect(() => {
254 if (!isModulesPanelOpen) return;
255
256 const handleClickOutside = (event: MouseEvent) => {
257 if (
258 modulesPanelRef.current &&
259 !modulesPanelRef.current.contains(event.target as Node)
260 ) {
261 setIsModulesPanelOpen(false);
262 }
263 };
264
265 document.addEventListener("mousedown", handleClickOutside);
266 return () => document.removeEventListener("mousedown", handleClickOutside);
267 }, [isModulesPanelOpen]);
268
269 // Get current subpage and tab from URL
270 const currentSubpage = useMemo(() => {
271 const params = new URLSearchParams(window.location.search);
272 return params.get("subpage") || "dashboard";
273 }, [urlKey]);
274
275 const currentTab = useMemo(() => {
276 const params = new URLSearchParams(window.location.search);
277 return params.get("tab") || "all";
278 }, [urlKey]);
279
280 const currentAction = useMemo(() => {
281 const params = new URLSearchParams(window.location.search);
282 return params.get("action");
283 }, [urlKey]);
284
285 // Check if we're on the trip form page
286 const isTripFormPage = useMemo(() => {
287 return (
288 currentSubpage === "trips" &&
289 (currentTab === "all" || !currentTab) &&
290 (currentAction === "create" || currentAction === "edit")
291 );
292 }, [currentSubpage, currentTab, currentAction, urlKey]);
293
294 // Track expanded submenus - initialize based on current subpage
295 const [expandedMenus, setExpandedMenus] = useState<string[]>(() => {
296 const params = new URLSearchParams(window.location.search);
297 const subpage = params.get("subpage") || "dashboard";
298 const menus: string[] = [];
299
300 if (subpage === "trips") {
301 menus.push("trips");
302 }
303
304 if (subpage === "itinerary") {
305 menus.push("itinerary");
306 }
307
308 return menus;
309 });
310
311 // Auto-expand menu when on submenu pages
312 useEffect(() => {
313 const menusToExpand: string[] = [];
314
315 if (currentSubpage === "trips") {
316 menusToExpand.push("trips");
317 }
318
319 if (currentSubpage === "itinerary") {
320 menusToExpand.push("itinerary");
321 }
322
323 setExpandedMenus((prev) => {
324 // Only update if the menus to expand are different
325 const newMenus = [...new Set([...prev, ...menusToExpand])];
326 if (
327 newMenus.length !== prev.length ||
328 !newMenus.every((m) => prev.includes(m))
329 ) {
330 return newMenus;
331 }
332 return prev;
333 });
334 }, [currentSubpage, urlKey]);
335
336 // Get base admin URL
337 const baseUrl = useMemo(() => {
338 return window.yatraAdmin?.siteUrl
339 ? `${window.yatraAdmin.siteUrl}/wp-admin/admin.php?page=yatra`
340 : "/wp-admin/admin.php?page=yatra";
341 }, []);
342
343 const menuItems = useMemo(
344 () => [
345 { subpage: "dashboard", label: "Dashboard", icon: LayoutDashboard },
346 {
347 subpage: "trips",
348 label: "Trips",
349 icon: MapPin,
350 submenu: [
351 { tab: "all", label: "All Trips", icon: List },
352 { tab: "activities", label: "Activities", icon: Activity },
353 { tab: "destinations", label: "Destinations", icon: Route },
354 { tab: "categories", label: "Categories", icon: FolderTree },
355 {
356 tab: "difficulty-levels",
357 label: "Difficulty Levels",
358 icon: TrendingUp,
359 },
360 // Availability - FREE feature, always show
361 { tab: "availability", label: "Availability", icon: CalendarDays },
362 // Attributes - FREE feature, always show
363 { tab: "attributes", label: "Attributes", icon: Tag },
364 // Additional Services - show only if Pro plugin is active and module is enabled
365 ...(isProPluginActive() && isModuleActive("additional_services")
366 ? [
367 {
368 tab: "additional-services",
369 label: "Additional Services",
370 icon: Package,
371 isPremium: true,
372 },
373 ]
374 : []),
375 // Trip Consent - show only if Pro plugin is active and module is enabled
376 ...(isProPluginActive() && isModuleActive("trip_consent")
377 ? [
378 {
379 tab: "trip-consent",
380 label: "Trip Consent",
381 icon: FileSignature,
382 isPremium: true,
383 },
384 ]
385 : []),
386 ],
387 },
388 {
389 subpage: "traveler-categories",
390 label: "Traveler Categories",
391 icon: UserCircle,
392 },
393 {
394 subpage: "itinerary",
395 label: "Itinerary",
396 icon: FileText,
397 submenu: [
398 { tab: "item-types", label: "Item Types", icon: Tag },
399 { tab: "items", label: "Items", icon: Route },
400 { tab: "itinerary", label: "Itinerary", icon: FileText },
401 ],
402 },
403 // Departures - FREE feature, always show
404 { subpage: "departures", label: "Departures", icon: Calendar },
405 { subpage: "discounts", label: "Discounts", icon: BadgePercent },
406 { subpage: "payments", label: "Payments", icon: CreditCard },
407 { subpage: "bookings", label: "Bookings", icon: Calendar },
408 { subpage: "customers", label: "Customers", icon: UserCircle },
409 { subpage: "travelers", label: "Travelers", icon: Plane },
410 { subpage: "enquiries", label: "Enquiries", icon: MessageSquare },
411 { subpage: "reviews", label: "Reviews", icon: Star },
412 { subpage: "reports", label: "Reports", icon: BarChart3 },
413 // Email — SMTP & transactional for all; Pro adds automation tabs on the same screen
414 {
415 subpage: "email-automation",
416 label: __("Email", "yatra"),
417 icon: Mail,
418 isPremium: false,
419 },
420 // Abandoned Booking Recovery - show only if Pro plugin is active and module is enabled
421 ...(isProPluginActive() && isModuleActive("abandoned_booking_recovery")
422 ? [
423 {
424 subpage: "abandoned-recovery",
425 label: "Abandoned Recovery",
426 icon: RotateCcw,
427 isPremium: true,
428 },
429 ]
430 : []),
431 // Dynamic Pricing - show only if Pro plugin is active and module is enabled
432 ...(isProPluginActive() && isModuleActive("dynamic_pricing")
433 ? [
434 {
435 subpage: "dynamic-pricing",
436 label: "Dynamic Pricing",
437 icon: TrendingUp,
438 isPremium: true,
439 },
440 ]
441 : []),
442 { subpage: "modules", label: "Modules", icon: Puzzle },
443 { subpage: "license", label: "License", icon: Key },
444 { subpage: "settings", label: "Settings", icon: Settings },
445 ],
446 [navRefreshKey],
447 ); // Re-calculate when navRefreshKey changes
448
449 const isActive = (subpage: string, tab?: string) => {
450 if (tab) {
451 return currentSubpage === subpage && currentTab === tab;
452 }
453 // For parent menu items, check if current subpage matches
454 // or if any submenu item is active
455 if (currentSubpage === subpage) {
456 const menuItem = menuItems.find((item) => item.subpage === subpage);
457 if (menuItem?.submenu) {
458 // If it has submenu, check if any submenu item is active
459 return menuItem.submenu.some((sub) => sub.tab === currentTab);
460 }
461 return true;
462 }
463 return false;
464 };
465
466 const isMenuExpanded = (subpage: string) => {
467 return expandedMenus.includes(subpage);
468 };
469
470 const toggleMenu = (subpage: string) => {
471 setExpandedMenus((prev) =>
472 prev.includes(subpage)
473 ? prev.filter((m) => m !== subpage)
474 : [...prev, subpage],
475 );
476 };
477
478 const getUrl = (subpage: string, tab?: string) => {
479 if (subpage === "dashboard") {
480 return baseUrl;
481 }
482 if (tab) {
483 return `${baseUrl}&subpage=${subpage}&tab=${tab}`;
484 }
485 return `${baseUrl}&subpage=${subpage}`;
486 };
487
488 return (
489 <div
490 className={`min-h-screen ${darkMode ? "dark bg-gray-900" : "bg-gray-50"}`}
491 >
492 <div className="flex h-screen overflow-hidden">
493 {/* Sidebar */}
494 <aside className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
495 {/* Logo */}
496 <div className="h-16 px-6 flex items-center justify-center border-b border-gray-200 dark:border-gray-700">
497 <div className="flex flex-col gap-1">
498 <div className="flex items-center gap-3">
499 {window.yatraAdmin?.brandLogoUrl ? (
500 <img
501 src={window.yatraAdmin.brandLogoUrl}
502 alt={__("Yatra", "yatra")}
503 width={32}
504 height={32}
505 className="w-8 h-8 rounded-lg object-contain shrink-0 bg-blue-600 p-1 border border-blue-700 dark:border-blue-500"
506 />
507 ) : (
508 <div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center shrink-0">
509 <span className="text-white font-bold text-lg">Y</span>
510 </div>
511 )}
512 <div className="flex flex-col">
513 <span className="text-xl font-bold text-gray-900 dark:text-white">
514 Yatra
515 </span>
516 <div className="flex items-center gap-2 text-[10px] text-gray-500 dark:text-gray-400">
517 <span>v{window.yatraAdmin?.version || "1.0.0"}</span>
518 {(window as any).yatraAdmin?.proVersion && (
519 <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">
520 Pro v{(window as any).yatraAdmin?.proVersion}
521 </span>
522 )}
523 </div>
524 </div>
525 </div>
526 </div>
527 </div>
528
529 {/* Navigation + Bottom Actions */}
530 <nav className="flex-1 p-4 space-y-1 overflow-y-auto">
531 {menuItems.map((item) => {
532 const Icon = item.icon;
533 const hasSubmenu = item.submenu && item.submenu.length > 0;
534 const isExpanded = hasSubmenu && isMenuExpanded(item.subpage);
535 const active = isActive(item.subpage);
536
537 return (
538 <div key={item.subpage}>
539 {hasSubmenu ? (
540 <>
541 <button
542 onClick={() => toggleMenu(item.subpage)}
543 className={`w-full flex items-center justify-between gap-3 px-4 py-3 rounded-lg text-sm transition-colors ${
544 active || isExpanded
545 ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium"
546 : "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
547 }`}
548 >
549 <div className="flex items-center gap-3">
550 <Icon className="w-5 h-5" />
551 <span>{item.label}</span>
552 </div>
553 {isExpanded ? (
554 <ChevronDown className="w-4 h-4" />
555 ) : (
556 <ChevronRight className="w-4 h-4" />
557 )}
558 </button>
559 {isExpanded && item.submenu && (
560 <div className="ml-4 mt-1 space-y-1">
561 {item.submenu.map((subItem) => {
562 const subActive = isActive(
563 item.subpage,
564 subItem.tab,
565 );
566 const SubIcon = subItem.icon;
567 return (
568 <a
569 key={subItem.tab}
570 href={getUrl(item.subpage, subItem.tab)}
571 onClick={(e) =>
572 handleMenuNavClick(
573 e,
574 item.subpage,
575 subItem.tab,
576 )
577 }
578 className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors relative ${
579 subActive
580 ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium"
581 : "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
582 }`}
583 >
584 <div className="flex items-center gap-3">
585 {SubIcon && (
586 <div className="w-4 h-4">
587 {React.createElement(SubIcon, {
588 className: "w-4 h-4",
589 })}
590 </div>
591 )}
592 <span className="text-sm">
593 {subItem.label}
594 </span>
595 </div>
596 {subItem.isPremium && !isProPluginActive() && (
597 <div className="absolute inset-y-0 right-2 flex items-center justify-center">
598 <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">
599 <Crown className="w-2.5 h-2.5" />
600 </div>
601 </div>
602 )}
603 </a>
604 );
605 })}
606 </div>
607 )}
608 </>
609 ) : (
610 <a
611 href={getUrl(item.subpage)}
612 onClick={(e) => handleMenuNavClick(e, item.subpage)}
613 className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-colors relative ${
614 active
615 ? "bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-medium"
616 : "text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
617 }`}
618 >
619 <div className="flex items-center gap-3">
620 <Icon className="w-5 h-5" />
621 <span>{item.label}</span>
622 </div>
623 {item.isPremium && !isProPluginActive() && (
624 <div className="absolute inset-y-0 right-2 flex items-center justify-center">
625 <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">
626 <Crown className="w-2.5 h-2.5" />
627 </div>
628 </div>
629 )}
630 {item.subpage === "license" &&
631 isProPluginActive() &&
632 licenseStatus && (
633 <Badge
634 variant={
635 licenseStatus === "active"
636 ? "success"
637 : licenseStatus === "expired"
638 ? "error"
639 : licenseStatus === "invalid"
640 ? "error"
641 : "error"
642 }
643 className="text-[10px] px-2 py-0.5"
644 >
645 {licenseStatus === "active"
646 ? "Active"
647 : licenseStatus === "expired"
648 ? "Expired"
649 : licenseStatus === "invalid"
650 ? "Invalid"
651 : "Inactive"}
652 </Badge>
653 )}
654 </a>
655 )}
656 </div>
657 );
658 })}
659 </nav>
660
661 {!isProPluginActive() && (
662 <div className="shrink-0 px-4 pb-3">
663 <a
664 href="https://wpyatra.com/pricing/"
665 target="_blank"
666 rel="noopener noreferrer"
667 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"
668 >
669 <span className="inline-flex items-center gap-1.5 text-xs font-semibold text-amber-900 dark:text-amber-200">
670 <Crown
671 className="w-3.5 h-3.5 shrink-0 text-amber-600 dark:text-amber-400"
672 aria-hidden
673 />
674 {__("Upgrade to Pro", "yatra")}
675 </span>
676 <span className="text-[11px] leading-snug text-amber-800/75 dark:text-amber-300/70">
677 {__("Premium features & integrations — view plans.", "yatra")}
678 </span>
679 </a>
680 </div>
681 )}
682
683 {/* Sticky bottom back link */}
684 <div className="border-t border-gray-200 dark:border-gray-700 p-4">
685 <a
686 href={
687 window.yatraAdmin?.siteUrl
688 ? `${window.yatraAdmin.siteUrl}/wp-admin/`
689 : "/wp-admin/"
690 }
691 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"
692 >
693 <span className="inline-flex items-center gap-2">
694 <ArrowLeft className="w-3 h-3" />
695 <span>{__("Back to WordPress", "yatra")}</span>
696 </span>
697 </a>
698 </div>
699 </aside>
700
701 {/* Main Content */}
702 <div
703 className={`flex-1 flex flex-col ${isTripFormPage ? "overflow-hidden" : "overflow-y-auto"}`}
704 >
705 {/* Top Bar */}
706 <header className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 flex items-center">
707 <div className="flex items-center justify-between w-full">
708 <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
709 {(() => {
710 // Show specific text for trip form page
711 if (isTripFormPage) {
712 return currentAction === "create"
713 ? "Create Trip"
714 : "Edit Trip";
715 }
716
717 const activeItem = menuItems.find((item) =>
718 isActive(item.subpage),
719 );
720 if (activeItem?.submenu && currentTab) {
721 const activeSubItem = activeItem.submenu.find(
722 (sub) => sub.tab === currentTab,
723 );
724 return activeSubItem?.label || activeItem.label;
725 }
726 return activeItem?.label || "Dashboard";
727 })()}
728 </h1>
729
730 <div className="flex items-center gap-4">
731 {/* Back to WordPress button */}
732 <a
733 href={
734 window.yatraAdmin?.siteUrl
735 ? `${window.yatraAdmin.siteUrl}/wp-admin/`
736 : "/wp-admin/"
737 }
738 target="_blank"
739 rel="noopener noreferrer"
740 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"
741 >
742 <ArrowLeft className="w-4 h-4" />
743 {__("Back to WordPress", "yatra")}
744 </a>
745
746 {/* Flush Permalinks (same API as Settings → Permalink) */}
747 <Button
748 variant="outline"
749 size="sm"
750 onClick={() => flushRewriteRulesMutation.mutate()}
751 disabled={flushRewriteRulesMutation.isPending}
752 className="flex items-center gap-2"
753 >
754 <RotateCcw
755 className={`w-4 h-4 ${flushRewriteRulesMutation.isPending ? "animate-spin" : ""}`}
756 />
757 {flushRewriteRulesMutation.isPending
758 ? __("Flushing...", "yatra")
759 : __("Flush Permalinks", "yatra")}
760 </Button>
761
762 <ConditionalRender capability="yatra_edit_trips">
763 <Button
764 variant={currentSubpage === "tools" ? "default" : "outline"}
765 onClick={() => {
766 navigateMenu("tools");
767 }}
768 className={`flex items-center gap-2 ${
769 currentSubpage === "tools"
770 ? "bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
771 : ""
772 }`}
773 >
774 <Wrench className="w-4 h-4" />
775 {__("Tools", "yatra")}
776 </Button>
777 </ConditionalRender>
778 <ConditionalRender capability="yatra_edit_trips">
779 <div className="relative">
780 <button
781 onClick={() => setIsModulesPanelOpen((prev) => !prev)}
782 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" : ""}`}
783 aria-label={__("Toggle modules panel", "yatra")}
784 >
785 <Puzzle className="w-5 h-5" />
786 </button>
787 {isModulesPanelOpen && (
788 <div
789 ref={modulesPanelRef}
790 className="absolute right-0 top-12 z-50 w-80"
791 >
792 <Card className="shadow-xl border border-gray-200 dark:border-gray-700">
793 <CardHeader className="pb-3">
794 <div className="flex items-center justify-between">
795 <div>
796 <CardTitle className="text-base">
797 {__("Modules", "yatra")}
798 </CardTitle>
799 <CardDescription>
800 {__(
801 "Quickly enable or disable feature packs.",
802 "yatra",
803 )}
804 </CardDescription>
805 </div>
806 <Button
807 size="sm"
808 variant="ghost"
809 onClick={() => {
810 setIsModulesPanelOpen(false);
811 navigateMenu("modules");
812 }}
813 >
814 {__("Open", "yatra")}
815 </Button>
816 </div>
817 </CardHeader>
818 <CardContent className="space-y-3">
819 {isLoadingModules && (
820 <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm">
821 <Loader2 className="w-4 h-4 animate-spin" />
822 {__("Loading modules…", "yatra")}
823 </div>
824 )}
825 {!isLoadingModules &&
826 modulesPreview.length === 0 && (
827 <div className="text-sm text-gray-500 dark:text-gray-400">
828 {__("No modules found.", "yatra")}
829 </div>
830 )}
831 {!isLoadingModules && modulesPreview.length > 0 && (
832 <div className="space-y-3">
833 {modulesPreview.map((module) => (
834 <div
835 key={module.slug}
836 className="flex items-center justify-between border border-gray-100 dark:border-gray-800 rounded-lg p-2"
837 >
838 <div>
839 <p className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
840 {module.name}
841 {module.is_core && (
842 <Badge
843 variant="outline"
844 className="text-[10px]"
845 >
846 {__("Core", "yatra")}
847 </Badge>
848 )}
849 </p>
850 <p className="text-xs text-gray-500 dark:text-gray-400">
851 {module.enabled
852 ? __("Enabled", "yatra")
853 : __("Disabled", "yatra")}
854 </p>
855 </div>
856 <button
857 onClick={() =>
858 handleQuickToggle(
859 module,
860 !module.enabled,
861 )
862 }
863 disabled={
864 module.is_core ||
865 toggleModuleMutation.isPending
866 }
867 className={`relative inline-flex h-6 w-11 items-center rounded-full transition ${
868 module.enabled
869 ? "bg-blue-600"
870 : "bg-gray-300 dark:bg-gray-600"
871 } ${module.is_core ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
872 aria-pressed={module.enabled}
873 aria-label={
874 module.enabled
875 ? __("Disable module", "yatra")
876 : __("Enable module", "yatra")
877 }
878 >
879 <span
880 className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${
881 module.enabled
882 ? "translate-x-5"
883 : "translate-x-1"
884 }`}
885 />
886 </button>
887 </div>
888 ))}
889 </div>
890 )}
891 <div className="pt-2">
892 <Button
893 variant="outline"
894 size="sm"
895 className="w-full"
896 onClick={() => {
897 setIsModulesPanelOpen(false);
898 navigateMenu("modules");
899 }}
900 >
901 {__("Manage all modules", "yatra")}
902 </Button>
903 </div>
904 </CardContent>
905 </Card>
906 </div>
907 )}
908 </div>
909 </ConditionalRender>
910 <button
911 onClick={() => {
912 setDarkMode(!darkMode);
913 }}
914 className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400"
915 aria-label={
916 darkMode ? "Switch to light mode" : "Switch to dark mode"
917 }
918 >
919 {darkMode ? (
920 <Sun className="w-5 h-5" />
921 ) : (
922 <Moon className="w-5 h-5" />
923 )}
924 </button>
925
926 <div className="relative">
927 <button
928 onClick={() => setIsUserDropdownOpen((prev) => !prev)}
929 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"
930 aria-label={__("User menu", "yatra")}
931 >
932 <div className="w-8 h-8 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700">
933 <img
934 src={getGravatarUrl(32)}
935 alt={__("User avatar", "yatra")}
936 className="w-full h-full object-cover"
937 onError={(e) => {
938 e.currentTarget.style.display = "none";
939 e.currentTarget.parentElement!.innerHTML =
940 '<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>';
941 }}
942 />
943 </div>
944 </button>
945
946 {isUserDropdownOpen && (
947 <div
948 ref={userDropdownRef}
949 className="absolute right-0 top-12 z-50 w-64"
950 >
951 <Card className="shadow-xl border border-gray-200 dark:border-gray-700">
952 <CardContent className="p-0">
953 <div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
954 <div className="flex items-center gap-3">
955 <div className="w-10 h-10 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700">
956 <img
957 src={getGravatarUrl(40)}
958 alt={__("User avatar", "yatra")}
959 className="w-full h-full object-cover"
960 onError={(e) => {
961 e.currentTarget.style.display = "none";
962 e.currentTarget.parentElement!.innerHTML =
963 '<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>';
964 }}
965 />
966 </div>
967 <div className="flex-1 min-w-0">
968 <div className="text-sm font-medium text-gray-900 dark:text-white truncate">
969 {(window as any)?.yatraAdmin
970 ?.currentUserDisplayName ||
971 (window as any)?.yatraAdmin
972 ?.currentUserLogin ||
973 "Admin"}
974 </div>
975 <div className="text-xs text-gray-500 dark:text-gray-400 truncate">
976 {(window as any)?.yatraAdmin
977 ?.currentUserEmail || ""}
978 </div>
979 </div>
980 </div>
981 </div>
982
983 <a
984 href={`${(window as any)?.yatraAdmin?.siteUrl || ""}/wp-admin/profile.php`}
985 target="_blank"
986 rel="noopener noreferrer"
987 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"
988 >
989 <User className="w-4 h-4" />
990 {__("Edit Profile", "yatra")}
991 </a>
992
993 <a
994 href={`${(window as any)?.yatraAdmin?.siteUrl || ""}/wp-admin/`}
995 target="_blank"
996 rel="noopener noreferrer"
997 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"
998 >
999 <svg
1000 className="w-4 h-4"
1001 fill="none"
1002 stroke="currentColor"
1003 viewBox="0 0 24 24"
1004 >
1005 <path
1006 strokeLinecap="round"
1007 strokeLinejoin="round"
1008 strokeWidth={2}
1009 d="M10 19l-7-7m0 0l7-7m-7 7h18"
1010 />
1011 </svg>
1012 {__("Back to WordPress", "yatra")}
1013 </a>
1014
1015 <button
1016 onClick={() => {
1017 const admin = (window as any)?.yatraAdmin;
1018 const siteUrl = admin?.siteUrl || "";
1019 window.location.href = `${siteUrl}/wp-login.php?action=logout`;
1020 }}
1021 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"
1022 >
1023 <svg
1024 className="w-4 h-4"
1025 fill="none"
1026 stroke="currentColor"
1027 viewBox="0 0 24 24"
1028 >
1029 <path
1030 strokeLinecap="round"
1031 strokeLinejoin="round"
1032 strokeWidth={2}
1033 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"
1034 />
1035 </svg>
1036 {__("Logout", "yatra")}
1037 </button>
1038 </CardContent>
1039 </Card>
1040 </div>
1041 )}
1042 </div>
1043 </div>
1044 </div>
1045 </header>
1046
1047 {/* Admin notices (Review / Buy Pro, etc.) */}
1048 <InlineNotices />
1049
1050 {/* License Warning Banner */}
1051 {isProPluginActive() &&
1052 licenseStatus &&
1053 licenseStatus !== "active" && (
1054 <div className="bg-red-50 dark:bg-red-950/30 border-l-4 border-b-2 border-red-500">
1055 <div className="px-6 py-3">
1056 <div className="flex items-center gap-4">
1057 <div className="flex-shrink-0">
1058 <div className="w-10 h-10 rounded-lg bg-red-500 flex items-center justify-center">
1059 <svg
1060 className="w-6 h-6 text-white"
1061 fill="currentColor"
1062 viewBox="0 0 20 20"
1063 >
1064 <path
1065 fillRule="evenodd"
1066 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"
1067 clipRule="evenodd"
1068 />
1069 </svg>
1070 </div>
1071 </div>
1072 <div className="flex-1 min-w-0">
1073 <p className="text-sm font-medium text-red-800 dark:text-red-200">
1074 <span className="font-semibold">
1075 {licenseStatus === "expired"
1076 ? "License Expired: "
1077 : licenseStatus === "invalid"
1078 ? "Invalid License: "
1079 : "License Not Activated: "}
1080 </span>
1081 {licenseStatus === "expired"
1082 ? "Renew your license to continue receiving updates and support."
1083 : licenseStatus === "invalid"
1084 ? "Please check your license key."
1085 : "Activate your license to receive updates and support."}
1086 </p>
1087 </div>
1088 <a
1089 href={getUrl("license")}
1090 onClick={(e) => handleMenuNavClick(e, "license")}
1091 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"
1092 >
1093 {licenseStatus === "expired"
1094 ? "Renew Now"
1095 : "Activate Now"}
1096 </a>
1097 </div>
1098 </div>
1099 </div>
1100 )}
1101
1102 {/* Page Content */}
1103 <main
1104 className={`flex-1 ${isTripFormPage ? "p-0 overflow-hidden flex flex-col" : "p-6 overflow-y-auto"}`}
1105 >
1106 <div
1107 className={
1108 isTripFormPage
1109 ? "flex-1 min-h-0 overflow-hidden flex flex-col"
1110 : currentSubpage === "tools"
1111 ? ""
1112 : "space-y-6"
1113 }
1114 >
1115 {children}
1116 </div>
1117 </main>
1118 </div>
1119 </div>
1120 </div>
1121 );
1122 };
1123
1124 export default Layout;
1125