PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.9
Yatra – Travel Booking & Tour Operator Software v3.0.2.9
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 / pages / Modules.tsx

Modules.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.2.9, at resources/js/pages/Modules.tsx

575 lines 22.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useMemo, useState } from "react";
2 import {
3 Puzzle,
4 Search,
5 Filter,
6 ArrowUpDown,
7 ExternalLink,
8 Crown,
9 } from "lucide-react";
10 import { __ } from "../lib/i18n";
11 import { PageHeader } from "../components/common/PageHeader";
12 import {
13 Card,
14 CardContent,
15 CardHeader,
16 CardTitle,
17 CardDescription,
18 } from "../components/ui/card";
19 import { Badge } from "../components/ui/badge";
20 import { Button } from "../components/ui/button";
21 import { Input } from "../components/ui/input";
22 import { Select } from "../components/ui/select";
23 import {
24 useModulesQuery,
25 useToggleModule,
26 ModuleDefinition,
27 useBulkToggleModules,
28 } from "../hooks/useModules";
29 import { usePermissions } from "../hooks/usePermissions";
30 import { PremiumUpgradeDialog } from "../components/modules/PremiumUpgradeDialog";
31
32 const Modules: React.FC = () => {
33 const { can } = usePermissions();
34 const canManageModules = can("yatra_edit_trips");
35 const { data: modules = [], isLoading, error } = useModulesQuery();
36 const toggleMutation = useToggleModule();
37 const bulkToggleMutation = useBulkToggleModules();
38 const [searchTerm, setSearchTerm] = useState("");
39 const [categoryFilter, setCategoryFilter] = useState<"all" | string>("all");
40 const [sortOption, setSortOption] = useState<
41 "name_asc" | "name_desc" | "status_enabled" | "status_disabled"
42 >("name_asc");
43 const [selected, setSelected] = useState<Set<string>>(new Set());
44
45 const categories = useMemo(() => {
46 const set = new Set<string>();
47 modules.forEach((module) => {
48 if (module.category) {
49 set.add(module.category);
50 }
51 });
52 return Array.from(set).sort((a, b) => a.localeCompare(b));
53 }, [modules]);
54
55 const filteredModules = useMemo(() => {
56 let list = modules;
57
58 if (searchTerm.trim()) {
59 const term = searchTerm.toLowerCase();
60 list = list.filter(
61 (module) =>
62 module.name.toLowerCase().includes(term) ||
63 (module.description?.toLowerCase().includes(term) ?? false) ||
64 (module.tags?.some((tag) => tag.toLowerCase().includes(term)) ??
65 false),
66 );
67 }
68
69 if (categoryFilter !== "all") {
70 list = list.filter(
71 (module) =>
72 (module.category || __("General", "yatra")) === categoryFilter,
73 );
74 }
75
76 const sorted = [...list];
77 sorted.sort((a, b) => {
78 switch (sortOption) {
79 case "name_desc":
80 return b.name.localeCompare(a.name);
81 case "status_enabled":
82 return Number(b.enabled) - Number(a.enabled);
83 case "status_disabled":
84 return Number(a.enabled) - Number(b.enabled);
85 case "name_asc":
86 default:
87 return a.name.localeCompare(b.name);
88 }
89 });
90
91 return sorted;
92 }, [modules, searchTerm, categoryFilter, sortOption]);
93
94 const moduleMap = useMemo(() => {
95 const map = new Map<string, ModuleDefinition>();
96 modules.forEach((module) => {
97 map.set(module.slug, module);
98 });
99 return map;
100 }, [modules]);
101
102 const [premiumDialog, setPremiumDialog] = useState<{
103 open: boolean;
104 module?: ModuleDefinition;
105 }>({ open: false });
106
107 const handleToggle = (module: ModuleDefinition) => {
108 if (module.is_core || !canManageModules) return;
109 // Show premium dialog only if module requires Pro and is NOT available (Pro not active or module not in Pro)
110 if (module.is_premium && !module.enabled && !module.is_available) {
111 setPremiumDialog({ open: true, module });
112 return;
113 }
114 toggleMutation.mutate({
115 slug: module.slug,
116 enabled: !module.enabled,
117 name: module.name,
118 });
119 };
120
121 const renderToggle = (module: ModuleDefinition) => {
122 // Module is locked if it's premium, not enabled, and not available (Pro not active)
123 const isLockedPremium =
124 module.is_premium && !module.enabled && !module.is_available;
125 // When Pro is active and module is available, allow toggling (user can enable/disable)
126 const disabled =
127 module.is_core || toggleMutation.isPending || !canManageModules;
128 return (
129 <button
130 onClick={(event) => {
131 event.stopPropagation();
132 if (isLockedPremium) {
133 setPremiumDialog({ open: true, module });
134 return;
135 }
136 handleToggle(module);
137 }}
138 disabled={disabled}
139 className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
140 module.enabled ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
141 } ${
142 isLockedPremium
143 ? "ring-2 ring-amber-300 dark:ring-amber-500 cursor-pointer"
144 : ""
145 } ${disabled && !isLockedPremium ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
146 aria-pressed={module.enabled}
147 aria-label={
148 module.enabled
149 ? __("Disable module", "yatra")
150 : __("Enable module", "yatra")
151 }
152 >
153 <span
154 className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${
155 module.enabled ? "translate-x-5" : "translate-x-1"
156 }`}
157 />
158 </button>
159 );
160 };
161
162 const handleSelect = (slug: string) => {
163 const module = moduleMap.get(slug);
164 // Only show premium dialog if module is premium, not enabled, and not available
165 if (module?.is_premium && !module.enabled && !module.is_available) {
166 setPremiumDialog({ open: true, module });
167 return;
168 }
169
170 setSelected((prev) => {
171 const next = new Set(prev);
172 if (next.has(slug)) {
173 next.delete(slug);
174 } else {
175 next.add(slug);
176 }
177 return next;
178 });
179 };
180
181 const handleSelectAllVisible = () => {
182 setSelected((prev) => {
183 const next = new Set(prev);
184 const allSelected = filteredModules.every((module) =>
185 next.has(module.slug),
186 );
187 if (allSelected) {
188 filteredModules.forEach((module) => next.delete(module.slug));
189 } else {
190 filteredModules.forEach((module) => next.add(module.slug));
191 }
192 return next;
193 });
194 };
195
196 const bulkUpdate = (enabled: boolean) => {
197 const items = Array.from(selected)
198 .map((slug) => moduleMap.get(slug))
199 .filter((module): module is ModuleDefinition => !!module)
200 // Only filter out premium modules that are NOT available (Pro not active)
201 .filter(
202 (module) => !(module.is_premium && enabled && !module.is_available),
203 );
204
205 if (items.length === 0) {
206 if (selected.size > 0 && enabled) {
207 // Only show premium dialog for modules that are not available
208 const premiumModules = Array.from(selected)
209 .map((slug) => moduleMap.get(slug))
210 .filter((module) => module?.is_premium && !module?.is_available);
211 if (premiumModules.length > 0) {
212 setPremiumDialog({ open: true, module: premiumModules[0] });
213 }
214 }
215 return;
216 }
217
218 const payload = items.map((module) => ({
219 slug: module.slug,
220 enabled,
221 name: module.name,
222 }));
223
224 bulkToggleMutation.mutate(payload, {
225 onSuccess: () => setSelected(new Set()),
226 });
227 };
228
229 const isAllVisibleSelected =
230 filteredModules.length > 0 &&
231 filteredModules.every((module) => selected.has(module.slug));
232 const selectedCount = selected.size;
233
234 return (
235 <div className="space-y-4">
236 <PageHeader
237 title={__("Modules", "yatra")}
238 description={__(
239 "Manage feature modules and control which capabilities are active.",
240 "yatra",
241 )}
242 actionCapability="yatra_edit_trips"
243 actions={
244 <Button
245 variant="outline"
246 onClick={() => window.location.reload()}
247 className="flex items-center gap-2"
248 >
249 <Puzzle className="w-4 h-4" />
250 {__("Refresh Modules", "yatra")}
251 </Button>
252 }
253 />
254
255 <Card>
256 <CardContent className="p-4">
257 <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
258 <div className="flex flex-wrap items-center gap-2">
259 <Button
260 variant={isAllVisibleSelected ? "default" : "outline"}
261 onClick={handleSelectAllVisible}
262 disabled={filteredModules.length === 0}
263 className="flex items-center gap-2"
264 >
265 <ArrowUpDown className="w-4 h-4" />
266 {isAllVisibleSelected
267 ? __("Clear visible selection", "yatra")
268 : __("Select visible", "yatra")}
269 </Button>
270 <Button
271 variant="outline"
272 onClick={() => bulkUpdate(true)}
273 disabled={
274 !canManageModules ||
275 selectedCount === 0 ||
276 bulkToggleMutation.isPending
277 }
278 >
279 {__("Enable Selected", "yatra")}
280 </Button>
281 <Button
282 variant="outline"
283 onClick={() => bulkUpdate(false)}
284 disabled={
285 !canManageModules ||
286 selectedCount === 0 ||
287 bulkToggleMutation.isPending
288 }
289 >
290 {__("Disable Selected", "yatra")}
291 </Button>
292 <div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 pl-2">
293 <Filter className="w-4 h-4" />
294 <span>
295 {__("Showing {count} modules", "yatra").replace(
296 "{count}",
297 String(filteredModules.length),
298 )}
299 </span>
300 </div>
301 </div>
302 <div className="flex flex-1 flex-col gap-2 lg:flex-row lg:items-center lg:justify-end lg:gap-3">
303 <div className="relative w-full lg:max-w-sm">
304 <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
305 <Input
306 value={searchTerm}
307 onChange={(e) => setSearchTerm(e.target.value)}
308 placeholder={__("Search modules...", "yatra")}
309 className="pl-9"
310 />
311 </div>
312 <Select
313 value={categoryFilter}
314 onChange={(e) => setCategoryFilter(e.target.value)}
315 className="w-full lg:w-48"
316 >
317 <option value="all">{__("All Categories", "yatra")}</option>
318 {categories.map((category) => (
319 <option key={category} value={category}>
320 {category}
321 </option>
322 ))}
323 </Select>
324 <Select
325 value={sortOption}
326 onChange={(e) =>
327 setSortOption(e.target.value as typeof sortOption)
328 }
329 className="w-full lg:w-48"
330 >
331 <option value="name_asc">{__("Name A → Z", "yatra")}</option>
332 <option value="name_desc">{__("Name Z → A", "yatra")}</option>
333 <option value="status_enabled">
334 {__("Enabled first", "yatra")}
335 </option>
336 <option value="status_disabled">
337 {__("Disabled first", "yatra")}
338 </option>
339 </Select>
340 {(searchTerm ||
341 categoryFilter !== "all" ||
342 sortOption !== "name_asc") && (
343 <Button
344 variant="outline"
345 onClick={() => {
346 setSearchTerm("");
347 setCategoryFilter("all");
348 setSortOption("name_asc");
349 }}
350 >
351 {__("Reset", "yatra")}
352 </Button>
353 )}
354 </div>
355 </div>
356 </CardContent>
357 </Card>
358
359 {isLoading && (
360 <Card>
361 <CardContent className="p-4">
362 <div className="animate-pulse space-y-4">
363 <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
364 <div className="flex-1 h-10 rounded-md bg-gray-100 dark:bg-gray-800" />
365 <div className="flex gap-3 w-full lg:w-auto">
366 <div className="h-10 w-40 rounded-md bg-gray-100 dark:bg-gray-800" />
367 <div className="h-10 w-40 rounded-md bg-gray-100 dark:bg-gray-800" />
368 <div className="h-10 w-24 rounded-md bg-gray-100 dark:bg-gray-800" />
369 </div>
370 </div>
371 <div className="grid gap-3 md:grid-cols-3">
372 {Array.from({ length: 3 }).map((_, idx) => (
373 <div
374 key={`module-skeleton-${idx}`}
375 className="border border-gray-200 dark:border-gray-800 rounded-xl p-4 space-y-3"
376 >
377 <div className="flex gap-3">
378 <div className="h-5 w-5 rounded bg-gray-200 dark:bg-gray-700" />
379 <div className="flex-1 space-y-2">
380 <div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
381 <div className="h-3 w-full rounded bg-gray-100 dark:bg-gray-800" />
382 </div>
383 </div>
384 <div className="h-24 rounded bg-gray-50 dark:bg-gray-900" />
385 </div>
386 ))}
387 </div>
388 </div>
389 </CardContent>
390 </Card>
391 )}
392
393 {error && (
394 <Card>
395 <CardContent className="p-6 text-red-600 dark:text-red-400">
396 {__("Failed to load modules", "yatra")}
397 </CardContent>
398 </Card>
399 )}
400
401 {!isLoading && !error && modules.length === 0 && (
402 <Card>
403 <CardContent className="p-6 text-gray-500 dark:text-gray-400">
404 {__("No modules available yet.", "yatra")}
405 </CardContent>
406 </Card>
407 )}
408
409 {!isLoading && !error && filteredModules.length > 0 && (
410 <div className="grid gap-3 md:grid-cols-3">
411 {filteredModules.map((module) => (
412 <Card
413 key={module.slug}
414 className={`border ${
415 // Only show premium styling if module is premium AND not available (Pro not active)
416 module.is_premium && !module.is_available
417 ? "border-amber-300 dark:border-amber-500/60 bg-amber-50/80 dark:bg-amber-500/10 relative overflow-hidden"
418 : "border-gray-200 dark:border-gray-700"
419 }`}
420 {...(module.is_premium &&
421 !module.is_available &&
422 canManageModules &&
423 !module.enabled
424 ? {
425 role: "button" as const,
426 tabIndex: 0,
427 onClick: () => setPremiumDialog({ open: true, module }),
428 onKeyDown: (event: React.KeyboardEvent) => {
429 if (event.key === "Enter" || event.key === " ") {
430 event.preventDefault();
431 setPremiumDialog({ open: true, module });
432 }
433 },
434 }
435 : {})}
436 >
437 <CardHeader className="pb-3">
438 <div className="flex items-start justify-between gap-3 flex-wrap">
439 <div className="flex items-start gap-3 flex-1">
440 <div className="flex flex-col items-center gap-1">
441 <input
442 id={`module-select-${module.slug}`}
443 type="checkbox"
444 className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
445 checked={selected.has(module.slug)}
446 onChange={() => handleSelect(module.slug)}
447 />
448 {module.enabled && (
449 <span className="text-[11px] font-semibold text-green-700 uppercase tracking-wide [writing-mode:vertical-rl] [text-orientation:mixed] transform rotate-180 bg-green-100 dark:bg-green-900/40 px-1 py-1 rounded">
450 {__("Enabled", "yatra")}
451 </span>
452 )}
453 </div>
454 <div
455 className="cursor-pointer select-none"
456 onClick={(event) => {
457 event.stopPropagation();
458 handleSelect(module.slug);
459 }}
460 role="button"
461 tabIndex={0}
462 onKeyDown={(e) => {
463 if (e.key === "Enter" || e.key === " ") {
464 e.preventDefault();
465 handleSelect(module.slug);
466 }
467 }}
468 >
469 <CardTitle className="text-lg flex items-center gap-2 flex-wrap">
470 {module.name}
471 {module.is_core && (
472 <Badge variant="outline">{__("Core", "yatra")}</Badge>
473 )}
474 {/* Only show Premium badge if module is premium AND not available (Pro not active) */}
475 {module.is_premium && !module.is_available && (
476 <Badge
477 variant="outline"
478 className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-gradient-to-r from-orange-500 to-orange-400 border-orange-600 text-white shadow-sm dark:bg-orange-600/60 dark:border-orange-500 dark:text-orange-50"
479 >
480 <Crown className="w-3 h-3" />
481 {__("Premium", "yatra")}
482 </Badge>
483 )}
484 </CardTitle>
485 <CardDescription className="mt-1 text-sm line-clamp-3">
486 {module.description}
487 </CardDescription>
488 <p className="inline-flex items-center px-2 py-0.5 mt-1 text-[11px] font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300">
489 <span className="w-1.5 h-1.5 rounded-full bg-blue-500 mr-1"></span>
490 {module.category || __("General", "yatra")}
491 </p>
492 </div>
493 </div>
494 <div className="flex items-center">
495 {renderToggle(module)}
496 </div>
497 </div>
498 </CardHeader>
499 <CardContent className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
500 <div className="flex items-center gap-2">
501 {module.video_url && (
502 <a
503 href={module.video_url}
504 target="_blank"
505 rel="noreferrer"
506 className="flex items-center gap-1 text-red-600 hover:text-red-500 dark:text-red-400 text-xs font-medium"
507 title={__("Watch video tutorial", "yatra")}
508 >
509 <svg
510 className="w-3 h-3"
511 fill="none"
512 stroke="currentColor"
513 strokeWidth="2"
514 strokeLinecap="round"
515 strokeLinejoin="round"
516 viewBox="0 0 24 24"
517 >
518 <polygon points="5 3 19 12 5 21 5 3"></polygon>
519 </svg>
520 {__("Video", "yatra")}
521 </a>
522 )}
523 </div>
524 <div className="flex items-center gap-2">
525 {module.slug === "dynamic_form_field" && module.enabled && (
526 <a
527 href={`${window.location.origin}/wp-admin/admin.php?page=yatra&subpage=settings`}
528 className="flex items-center gap-1 text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 text-xs font-medium"
529 title={__("Settings", "yatra")}
530 >
531 <svg
532 className="w-3 h-3"
533 fill="none"
534 stroke="currentColor"
535 strokeWidth="2"
536 strokeLinecap="round"
537 strokeLinejoin="round"
538 viewBox="0 0 24 24"
539 >
540 <circle cx="12" cy="12" r="3"></circle>
541 <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
542 </svg>
543 {__("Settings", "yatra")}
544 </a>
545 )}
546 {module.docs_url && (
547 <a
548 href={module.docs_url}
549 target="_blank"
550 rel="noreferrer"
551 className="flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 text-xs font-medium"
552 >
553 <ExternalLink className="w-3 h-3" />
554 {__("Docs", "yatra")}
555 </a>
556 )}
557 </div>
558 </CardContent>
559 </Card>
560 ))}
561 </div>
562 )}
563 <PremiumUpgradeDialog
564 open={premiumDialog.open}
565 moduleName={premiumDialog.module?.name}
566 purchaseUrl={premiumDialog.module?.purchase_url}
567 moduleDescription={premiumDialog.module?.description}
568 onClose={() => setPremiumDialog({ open: false })}
569 />
570 </div>
571 );
572 };
573
574 export default Modules;
575