PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
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.5, at resources/js/pages/Modules.tsx

679 lines 27.3 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 Sparkles,
10 } from "lucide-react";
11 import { __ } from "../lib/i18n";
12 import { PageHeader } from "../components/common/PageHeader";
13 import {
14 Card,
15 CardContent,
16 CardHeader,
17 CardTitle,
18 CardDescription,
19 } from "../components/ui/card";
20 import { Badge } from "../components/ui/badge";
21 import { Button } from "../components/ui/button";
22 import { Input } from "../components/ui/input";
23 import { Select } from "../components/ui/select";
24 import {
25 useModulesQuery,
26 useToggleModule,
27 ModuleDefinition,
28 ModulePlan,
29 useBulkToggleModules,
30 } from "../hooks/useModules";
31 import { usePermissions } from "../hooks/usePermissions";
32 import { PremiumUpgradeDialog } from "../components/modules/PremiumUpgradeDialog";
33
34 const Modules: React.FC = () => {
35 const { can } = usePermissions();
36 const canManageModules = can("yatra_edit_trips");
37 const { data: modules = [], isLoading, error } = useModulesQuery();
38 const toggleMutation = useToggleModule();
39 const bulkToggleMutation = useBulkToggleModules();
40 const [searchTerm, setSearchTerm] = useState("");
41 const [categoryFilter, setCategoryFilter] = useState<"all" | string>("all");
42 const [planFilter, setPlanFilter] = useState<"all" | ModulePlan>("all");
43 const [sortOption, setSortOption] = useState<
44 "name_asc" | "name_desc" | "status_enabled" | "status_disabled"
45 >("name_asc");
46 const [selected, setSelected] = useState<Set<string>>(new Set());
47
48 const categories = useMemo(() => {
49 const set = new Set<string>();
50 modules.forEach((module) => {
51 if (module.category) {
52 set.add(module.category);
53 }
54 });
55 return Array.from(set).sort((a, b) => a.localeCompare(b));
56 }, [modules]);
57
58 // Plan-filter dropdown only lists the tiers actually present in the
59 // current module set — keeps the menu clean on sites where only some
60 // tiers are in play (e.g. a free-only install shouldn't list Agency).
61 // Modules with no `plan` field are treated as Free so they're filterable.
62 const availablePlans = useMemo(() => {
63 const set = new Set<ModulePlan>();
64 modules.forEach((module) => {
65 set.add(module.plan ?? "free");
66 });
67 // Canonical order from cheapest tier to most premium so the dropdown
68 // reads like a price-ladder, not random.
69 const order: ModulePlan[] = ["free", "personal", "growth", "agency"];
70 return order.filter((tier) => set.has(tier));
71 }, [modules]);
72
73 const planLabels: Record<ModulePlan, string> = {
74 free: __("Free", "yatra"),
75 personal: __("Personal", "yatra"),
76 growth: __("Growth", "yatra"),
77 agency: __("Agency", "yatra"),
78 };
79
80 const filteredModules = useMemo(() => {
81 let list = modules;
82
83 if (searchTerm.trim()) {
84 const term = searchTerm.toLowerCase();
85 list = list.filter(
86 (module) =>
87 module.name.toLowerCase().includes(term) ||
88 (module.description?.toLowerCase().includes(term) ?? false) ||
89 (module.tags?.some((tag) => tag.toLowerCase().includes(term)) ??
90 false),
91 );
92 }
93
94 if (categoryFilter !== "all") {
95 list = list.filter(
96 (module) =>
97 (module.category || __("General", "yatra")) === categoryFilter,
98 );
99 }
100
101 if (planFilter !== "all") {
102 // Modules without an explicit plan are treated as Free — matches
103 // the Free-tier badge fallthrough in the card renderer below.
104 list = list.filter((module) => (module.plan ?? "free") === planFilter);
105 }
106
107 const sorted = [...list];
108 sorted.sort((a, b) => {
109 switch (sortOption) {
110 case "name_desc":
111 return b.name.localeCompare(a.name);
112 case "status_enabled":
113 return Number(b.enabled) - Number(a.enabled);
114 case "status_disabled":
115 return Number(a.enabled) - Number(b.enabled);
116 case "name_asc":
117 default:
118 return a.name.localeCompare(b.name);
119 }
120 });
121
122 return sorted;
123 }, [modules, searchTerm, categoryFilter, planFilter, sortOption]);
124
125 const moduleMap = useMemo(() => {
126 const map = new Map<string, ModuleDefinition>();
127 modules.forEach((module) => {
128 map.set(module.slug, module);
129 });
130 return map;
131 }, [modules]);
132
133 const [premiumDialog, setPremiumDialog] = useState<{
134 open: boolean;
135 module?: ModuleDefinition;
136 }>({ open: false });
137
138 const handleToggle = (module: ModuleDefinition) => {
139 if (module.is_core || !canManageModules) return;
140 // Show premium dialog only if module requires Pro and is NOT available (Pro not active or module not in Pro)
141 if (module.is_premium && !module.enabled && !module.is_available) {
142 setPremiumDialog({ open: true, module });
143 return;
144 }
145 toggleMutation.mutate({
146 slug: module.slug,
147 enabled: !module.enabled,
148 name: module.name,
149 });
150 };
151
152 const renderToggle = (module: ModuleDefinition) => {
153 // Module is locked if it's premium, not enabled, and not available (Pro not active)
154 const isLockedPremium =
155 module.is_premium && !module.enabled && !module.is_available;
156 // When Pro is active and module is available, allow toggling (user can enable/disable)
157 const disabled =
158 module.is_core || toggleMutation.isPending || !canManageModules;
159 return (
160 <button
161 onClick={(event) => {
162 event.stopPropagation();
163 if (isLockedPremium) {
164 setPremiumDialog({ open: true, module });
165 return;
166 }
167 handleToggle(module);
168 }}
169 disabled={disabled}
170 className={`relative inline-flex h-7 w-12 items-center rounded-full transition ${
171 module.enabled ? "bg-blue-600" : "bg-gray-300 dark:bg-gray-600"
172 } ${
173 isLockedPremium
174 ? "ring-2 ring-amber-300 dark:ring-amber-500 cursor-pointer"
175 : ""
176 } ${disabled && !isLockedPremium ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
177 aria-pressed={module.enabled}
178 aria-label={
179 module.enabled
180 ? __("Disable module", "yatra")
181 : __("Enable module", "yatra")
182 }
183 >
184 <span
185 className={`inline-block h-5 w-5 transform rounded-full bg-white transition ${
186 module.enabled ? "translate-x-5" : "translate-x-1"
187 }`}
188 />
189 </button>
190 );
191 };
192
193 const handleSelect = (slug: string) => {
194 const module = moduleMap.get(slug);
195 // Only show premium dialog if module is premium, not enabled, and not available
196 if (module?.is_premium && !module.enabled && !module.is_available) {
197 setPremiumDialog({ open: true, module });
198 return;
199 }
200
201 setSelected((prev) => {
202 const next = new Set(prev);
203 if (next.has(slug)) {
204 next.delete(slug);
205 } else {
206 next.add(slug);
207 }
208 return next;
209 });
210 };
211
212 const handleSelectAllVisible = () => {
213 setSelected((prev) => {
214 const next = new Set(prev);
215 const allSelected = filteredModules.every((module) =>
216 next.has(module.slug),
217 );
218 if (allSelected) {
219 filteredModules.forEach((module) => next.delete(module.slug));
220 } else {
221 filteredModules.forEach((module) => next.add(module.slug));
222 }
223 return next;
224 });
225 };
226
227 const bulkUpdate = (enabled: boolean) => {
228 const items = Array.from(selected)
229 .map((slug) => moduleMap.get(slug))
230 .filter((module): module is ModuleDefinition => !!module)
231 // Only filter out premium modules that are NOT available (Pro not active)
232 .filter(
233 (module) => !(module.is_premium && enabled && !module.is_available),
234 );
235
236 if (items.length === 0) {
237 if (selected.size > 0 && enabled) {
238 // Only show premium dialog for modules that are not available
239 const premiumModules = Array.from(selected)
240 .map((slug) => moduleMap.get(slug))
241 .filter((module) => module?.is_premium && !module?.is_available);
242 if (premiumModules.length > 0) {
243 setPremiumDialog({ open: true, module: premiumModules[0] });
244 }
245 }
246 return;
247 }
248
249 const payload = items.map((module) => ({
250 slug: module.slug,
251 enabled,
252 name: module.name,
253 }));
254
255 bulkToggleMutation.mutate(payload, {
256 onSuccess: () => setSelected(new Set()),
257 });
258 };
259
260 const isAllVisibleSelected =
261 filteredModules.length > 0 &&
262 filteredModules.every((module) => selected.has(module.slug));
263 const selectedCount = selected.size;
264
265 return (
266 <div className="space-y-4">
267 <PageHeader
268 title={__("Modules", "yatra")}
269 description={__(
270 "Manage feature modules and control which capabilities are active.",
271 "yatra",
272 )}
273 actionCapability="yatra_edit_trips"
274 actions={
275 <Button
276 variant="outline"
277 onClick={() => window.location.reload()}
278 className="flex items-center gap-2"
279 >
280 <Puzzle className="w-4 h-4" />
281 {__("Refresh Modules", "yatra")}
282 </Button>
283 }
284 />
285
286 <Card>
287 <CardContent className="p-4">
288 <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
289 <div className="flex flex-wrap items-center gap-2">
290 <Button
291 variant={isAllVisibleSelected ? "default" : "outline"}
292 onClick={handleSelectAllVisible}
293 disabled={filteredModules.length === 0}
294 className="flex items-center gap-2"
295 >
296 <ArrowUpDown className="w-4 h-4" />
297 {isAllVisibleSelected
298 ? __("Clear visible selection", "yatra")
299 : __("Select visible", "yatra")}
300 </Button>
301 <Button
302 variant="outline"
303 onClick={() => bulkUpdate(true)}
304 disabled={
305 !canManageModules ||
306 selectedCount === 0 ||
307 bulkToggleMutation.isPending
308 }
309 >
310 {__("Enable Selected", "yatra")}
311 </Button>
312 <Button
313 variant="outline"
314 onClick={() => bulkUpdate(false)}
315 disabled={
316 !canManageModules ||
317 selectedCount === 0 ||
318 bulkToggleMutation.isPending
319 }
320 >
321 {__("Disable Selected", "yatra")}
322 </Button>
323 <div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 pl-2">
324 <Filter className="w-4 h-4" />
325 <span>
326 {__("Showing {count} modules", "yatra").replace(
327 "{count}",
328 String(filteredModules.length),
329 )}
330 </span>
331 </div>
332 </div>
333 <div className="flex flex-1 flex-col gap-2 lg:flex-row lg:items-center lg:justify-end lg:gap-3">
334 <div className="relative w-full lg:max-w-sm">
335 <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
336 <Input
337 value={searchTerm}
338 onChange={(e) => setSearchTerm(e.target.value)}
339 placeholder={__("Search modules...", "yatra")}
340 className="pl-9"
341 />
342 </div>
343 <Select
344 value={categoryFilter}
345 onChange={(e) => setCategoryFilter(e.target.value)}
346 className="w-full lg:w-44"
347 aria-label={__("Filter by category", "yatra")}
348 >
349 <option value="all">{__("All Categories", "yatra")}</option>
350 {categories.map((category) => (
351 <option key={category} value={category}>
352 {category}
353 </option>
354 ))}
355 </Select>
356 {/* Plan filter — only renders when there are at least two
357 tiers represented in the modules list. A site that only
358 has Free modules doesn't need a plan filter, and showing
359 a one-option dropdown is worse UX than no dropdown. */}
360 {availablePlans.length > 1 && (
361 <Select
362 value={planFilter}
363 onChange={(e) =>
364 setPlanFilter(e.target.value as "all" | ModulePlan)
365 }
366 className="w-full lg:w-44"
367 aria-label={__("Filter by plan", "yatra")}
368 >
369 <option value="all">{__("All Plans", "yatra")}</option>
370 {availablePlans.map((tier) => (
371 <option key={tier} value={tier}>
372 {planLabels[tier]}
373 </option>
374 ))}
375 </Select>
376 )}
377 <Select
378 value={sortOption}
379 onChange={(e) =>
380 setSortOption(e.target.value as typeof sortOption)
381 }
382 className="w-full lg:w-44"
383 aria-label={__("Sort modules", "yatra")}
384 >
385 <option value="name_asc">{__("Name A → Z", "yatra")}</option>
386 <option value="name_desc">{__("Name Z → A", "yatra")}</option>
387 <option value="status_enabled">
388 {__("Enabled first", "yatra")}
389 </option>
390 <option value="status_disabled">
391 {__("Disabled first", "yatra")}
392 </option>
393 </Select>
394 {(searchTerm ||
395 categoryFilter !== "all" ||
396 planFilter !== "all" ||
397 sortOption !== "name_asc") && (
398 <Button
399 variant="outline"
400 onClick={() => {
401 setSearchTerm("");
402 setCategoryFilter("all");
403 setPlanFilter("all");
404 setSortOption("name_asc");
405 }}
406 >
407 {__("Reset", "yatra")}
408 </Button>
409 )}
410 </div>
411 </div>
412 </CardContent>
413 </Card>
414
415 {isLoading && (
416 <Card>
417 <CardContent className="p-4">
418 <div className="animate-pulse space-y-4">
419 <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
420 <div className="flex-1 h-10 rounded-md bg-gray-100 dark:bg-gray-800" />
421 <div className="flex gap-3 w-full lg:w-auto">
422 <div className="h-10 w-40 rounded-md bg-gray-100 dark:bg-gray-800" />
423 <div className="h-10 w-40 rounded-md bg-gray-100 dark:bg-gray-800" />
424 <div className="h-10 w-24 rounded-md bg-gray-100 dark:bg-gray-800" />
425 </div>
426 </div>
427 <div className="grid gap-3 md:grid-cols-3">
428 {Array.from({ length: 3 }).map((_, idx) => (
429 <div
430 key={`module-skeleton-${idx}`}
431 className="border border-gray-200 dark:border-gray-800 rounded-xl p-4 space-y-3"
432 >
433 <div className="flex gap-3">
434 <div className="h-5 w-5 rounded bg-gray-200 dark:bg-gray-700" />
435 <div className="flex-1 space-y-2">
436 <div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
437 <div className="h-3 w-full rounded bg-gray-100 dark:bg-gray-800" />
438 </div>
439 </div>
440 <div className="h-24 rounded bg-gray-50 dark:bg-gray-900" />
441 </div>
442 ))}
443 </div>
444 </div>
445 </CardContent>
446 </Card>
447 )}
448
449 {error && (
450 <Card>
451 <CardContent className="p-6 text-red-600 dark:text-red-400">
452 {__("Failed to load modules", "yatra")}
453 </CardContent>
454 </Card>
455 )}
456
457 {!isLoading && !error && modules.length === 0 && (
458 <Card>
459 <CardContent className="p-6 text-gray-500 dark:text-gray-400">
460 {__("No modules available yet.", "yatra")}
461 </CardContent>
462 </Card>
463 )}
464
465 {!isLoading && !error && filteredModules.length > 0 && (
466 <div className="grid gap-3 md:grid-cols-3">
467 {filteredModules.map((module) => (
468 <Card
469 key={module.slug}
470 className={`border ${
471 // Only show premium styling if module is premium AND not available (Pro not active)
472 module.is_premium && !module.is_available
473 ? "border-amber-300 dark:border-amber-500/60 bg-amber-50/80 dark:bg-amber-500/10 relative overflow-hidden"
474 : "border-gray-200 dark:border-gray-700"
475 }`}
476 {...(module.is_premium &&
477 !module.is_available &&
478 canManageModules &&
479 !module.enabled
480 ? {
481 role: "button" as const,
482 tabIndex: 0,
483 onClick: () => setPremiumDialog({ open: true, module }),
484 onKeyDown: (event: React.KeyboardEvent) => {
485 if (event.key === "Enter" || event.key === " ") {
486 event.preventDefault();
487 setPremiumDialog({ open: true, module });
488 }
489 },
490 }
491 : {})}
492 >
493 <CardHeader className="pb-3">
494 <div className="flex items-start justify-between gap-3 flex-wrap">
495 <div className="flex items-start gap-3 flex-1">
496 <div className="flex flex-col items-center gap-1">
497 <input
498 id={`module-select-${module.slug}`}
499 type="checkbox"
500 className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
501 checked={selected.has(module.slug)}
502 onChange={() => handleSelect(module.slug)}
503 />
504 {module.enabled && (
505 <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">
506 {__("Enabled", "yatra")}
507 </span>
508 )}
509 </div>
510 <div
511 className="cursor-pointer select-none"
512 onClick={(event) => {
513 event.stopPropagation();
514 handleSelect(module.slug);
515 }}
516 role="button"
517 tabIndex={0}
518 onKeyDown={(e) => {
519 if (e.key === "Enter" || e.key === " ") {
520 e.preventDefault();
521 handleSelect(module.slug);
522 }
523 }}
524 >
525 <CardTitle className="text-lg flex items-center gap-2 flex-wrap">
526 {module.name}
527 {module.is_core && (
528 <Badge variant="outline">{__("Core", "yatra")}</Badge>
529 )}
530 {/* Plan badge — Personal for any Pro module, Agency
531 for white-label-tier modules. Always visible so
532 customers know which plan unlocks the module. */}
533 {module.plan === "agency" && (
534 <Badge
535 variant="outline"
536 className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-gradient-to-r from-purple-600 to-indigo-500 border-purple-700 text-white shadow-sm dark:from-purple-500 dark:to-indigo-400 dark:border-purple-400 dark:text-white"
537 >
538 <Crown className="w-3 h-3" />
539 {__("Agency", "yatra")}
540 </Badge>
541 )}
542 {/* Growth tier — AI Assistant + any future module
543 requiring the Growth-or-Agency band. Without
544 this branch the card showed NO plan badge at
545 all, so customers couldn't tell which tier
546 unlocks AI features. */}
547 {module.plan === "growth" && (
548 <Badge
549 variant="outline"
550 className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-gradient-to-r from-emerald-500 to-teal-500 border-emerald-600 text-white shadow-sm dark:from-emerald-500 dark:to-teal-400 dark:border-emerald-400 dark:text-white"
551 >
552 <Sparkles className="w-3 h-3" />
553 {__("Growth", "yatra")}
554 </Badge>
555 )}
556 {module.plan === "personal" && (
557 <Badge
558 variant="outline"
559 className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-50 border-blue-200 text-blue-700 dark:bg-blue-900/30 dark:border-blue-500/40 dark:text-blue-200"
560 >
561 {__("Personal", "yatra")}
562 </Badge>
563 )}
564 {/* Only show Premium badge if module is premium AND not available (Pro not active) */}
565 {module.is_premium && !module.is_available && (
566 <Badge
567 variant="outline"
568 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"
569 >
570 <Crown className="w-3 h-3" />
571 {__("Premium", "yatra")}
572 </Badge>
573 )}
574 </CardTitle>
575 <CardDescription className="mt-1 text-sm line-clamp-3">
576 {module.description}
577 </CardDescription>
578 <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">
579 <span className="w-1.5 h-1.5 rounded-full bg-blue-500 mr-1"></span>
580 {module.category || __("General", "yatra")}
581 </p>
582 </div>
583 </div>
584 <div className="flex items-center">
585 {renderToggle(module)}
586 </div>
587 </div>
588 </CardHeader>
589 <CardContent className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
590 <div className="flex items-center gap-2">
591 {module.video_url && (
592 <a
593 href={module.video_url}
594 target="_blank"
595 rel="noreferrer"
596 className="flex items-center gap-1 text-red-600 hover:text-red-500 dark:text-red-400 text-xs font-medium"
597 title={__("Watch video tutorial", "yatra")}
598 >
599 <svg
600 className="w-3 h-3"
601 fill="none"
602 stroke="currentColor"
603 strokeWidth="2"
604 strokeLinecap="round"
605 strokeLinejoin="round"
606 viewBox="0 0 24 24"
607 >
608 <polygon points="5 3 19 12 5 21 5 3"></polygon>
609 </svg>
610 {__("Video", "yatra")}
611 </a>
612 )}
613 </div>
614 <div className="flex items-center gap-2">
615 {module.slug === "dynamic_form_field" && module.enabled && (
616 <a
617 href={`${window.location.origin}/wp-admin/admin.php?page=yatra&subpage=settings`}
618 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"
619 title={__("Settings", "yatra")}
620 >
621 <svg
622 className="w-3 h-3"
623 fill="none"
624 stroke="currentColor"
625 strokeWidth="2"
626 strokeLinecap="round"
627 strokeLinejoin="round"
628 viewBox="0 0 24 24"
629 >
630 <circle cx="12" cy="12" r="3"></circle>
631 <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>
632 </svg>
633 {__("Settings", "yatra")}
634 </a>
635 )}
636 {module.docs_url && (
637 <a
638 href={module.docs_url}
639 target="_blank"
640 rel="noreferrer"
641 className="flex items-center gap-1 text-blue-600 hover:text-blue-500 dark:text-blue-400 text-xs font-medium"
642 >
643 <ExternalLink className="w-3 h-3" />
644 {__("Docs", "yatra")}
645 </a>
646 )}
647 </div>
648 </CardContent>
649 </Card>
650 ))}
651 </div>
652 )}
653 <PremiumUpgradeDialog
654 open={premiumDialog.open}
655 moduleName={premiumDialog.module?.name}
656 moduleSlug={premiumDialog.module?.slug}
657 purchaseUrl={premiumDialog.module?.purchase_url}
658 moduleDescription={premiumDialog.module?.description}
659 // Pass the module's plan tier so the dialog shows
660 // "Growth plan" / "Agency plan" badge + matching CTA copy —
661 // operators stop seeing the generic "Upgrade to Pro" message
662 // and know exactly which plan unlocks the module.
663 requiredPlan={
664 premiumDialog.module?.plan === "agency"
665 ? "agency"
666 : premiumDialog.module?.plan === "growth"
667 ? "growth"
668 : premiumDialog.module?.plan === "personal"
669 ? "personal"
670 : undefined
671 }
672 onClose={() => setPremiumDialog({ open: false })}
673 />
674 </div>
675 );
676 };
677
678 export default Modules;
679