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

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

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