PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
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
← All changes | resources/js/pages/Modules.tsx +142 -11 3.0.2.6 → 3.0.16 View file →
@@ -4,9 +4,9 @@
4 4 Search,
5 5 Filter,
6 6 ArrowUpDown,
7 7 ExternalLink,
8 - Crown,
8 + ArrowUpRight,
9 9 } from "lucide-react";
10 10 import { __ } from "../lib/i18n";
11 11 import { PageHeader } from "../components/common/PageHeader";
12 12 import {
@@ -23,13 +23,31 @@
23 23 import {
24 24 useModulesQuery,
25 25 useToggleModule,
26 26 ModuleDefinition,
27 + ModulePlan,
27 28 useBulkToggleModules,
28 29 } from "../hooks/useModules";
29 30 import { usePermissions } from "../hooks/usePermissions";
30 31 import { PremiumUpgradeDialog } from "../components/modules/PremiumUpgradeDialog";
31 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 +
32 50 const Modules: React.FC = () => {
33 51 const { can } = usePermissions();
34 52 const canManageModules = can("yatra_edit_trips");
35 53 const { data: modules = [], isLoading, error } = useModulesQuery();
@@ -36,8 +54,9 @@
36 54 const toggleMutation = useToggleModule();
37 55 const bulkToggleMutation = useBulkToggleModules();
38 56 const [searchTerm, setSearchTerm] = useState("");
39 57 const [categoryFilter, setCategoryFilter] = useState<"all" | string>("all");
58 + const [planFilter, setPlanFilter] = useState<"all" | ModulePlan>("all");
40 59 const [sortOption, setSortOption] = useState<
41 60 "name_asc" | "name_desc" | "status_enabled" | "status_disabled"
42 61 >("name_asc");
43 62 const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -51,8 +70,33 @@
51 70 });
52 71 return Array.from(set).sort((a, b) => a.localeCompare(b));
53 72 }, [modules]);
54 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 +
55 99 const filteredModules = useMemo(() => {
56 100 let list = modules;
57 101
58 102 if (searchTerm.trim()) {
@@ -72,8 +116,14 @@
72 116 (module.category || __("General", "yatra")) === categoryFilter,
73 117 );
74 118 }
75 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 +
76 126 const sorted = [...list];
77 127 sorted.sort((a, b) => {
78 128 switch (sortOption) {
79 129 case "name_desc":
@@ -88,9 +138,9 @@
88 138 }
89 139 });
90 140
91 141 return sorted;
92 - }, [modules, searchTerm, categoryFilter, sortOption]);
142 + }, [modules, searchTerm, categoryFilter, planFilter, sortOption]);
93 143
94 144 const moduleMap = useMemo(() => {
95 145 const map = new Map<string, ModuleDefinition>();
96 146 modules.forEach((module) => {
@@ -311,9 +361,10 @@
311 361 </div>
312 362 <Select
313 363 value={categoryFilter}
314 364 onChange={(e) => setCategoryFilter(e.target.value)}
315 - className="w-full lg:w-48"
365 + className="w-full lg:w-44"
366 + aria-label={__("Filter by category", "yatra")}
316 367 >
317 368 <option value="all">{__("All Categories", "yatra")}</option>
318 369 {categories.map((category) => (
319 370 <option key={category} value={category}>
@@ -320,14 +371,36 @@
320 371 {category}
321 372 </option>
322 373 ))}
323 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 + )}
324 396 <Select
325 397 value={sortOption}
326 398 onChange={(e) =>
327 399 setSortOption(e.target.value as typeof sortOption)
328 400 }
329 - className="w-full lg:w-48"
401 + className="w-full lg:w-44"
402 + aria-label={__("Sort modules", "yatra")}
330 403 >
331 404 <option value="name_asc">{__("Name A → Z", "yatra")}</option>
332 405 <option value="name_desc">{__("Name Z → A", "yatra")}</option>
333 406 <option value="status_enabled">
@@ -338,8 +411,9 @@
338 411 </option>
339 412 </Select>
340 413 {(searchTerm ||
341 414 categoryFilter !== "all" ||
415 + planFilter !== "all" ||
342 416 sortOption !== "name_asc") && (
343 417 <Button
344 418 variant="outline"
345 419 onClick={() => {
@@ -344,8 +418,9 @@
344 418 variant="outline"
345 419 onClick={() => {
346 420 setSearchTerm("");
347 421 setCategoryFilter("all");
422 + setPlanFilter("all");
348 423 setSortOption("name_asc");
349 424 }}
350 425 >
351 426 {__("Reset", "yatra")}
@@ -470,18 +545,41 @@
470 545 {module.name}
471 546 {module.is_core && (
472 547 <Badge variant="outline">{__("Core", "yatra")}</Badge>
473 548 )}
474 - {/* Only show Premium badge if module is premium AND not available (Pro not active) */}
475 - {module.is_premium && !module.is_available && (
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" && (
476 554 <Badge
477 555 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"
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"
479 557 >
480 - <Crown className="w-3 h-3" />
481 - {__("Premium", "yatra")}
558 + {__("Scale", "yatra")}
482 559 </Badge>
483 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 + )}
484 582 </CardTitle>
485 583 <CardDescription className="mt-1 text-sm line-clamp-3">
486 584 {module.description}
487 585 </CardDescription>
@@ -496,9 +594,12 @@
496 594 </div>
497 595 </div>
498 596 </CardHeader>
499 597 <CardContent className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
500 - <div className="flex items-center gap-2">
598 + <div
599 + className="flex items-center gap-2"
600 + onClick={(e) => e.stopPropagation()}
601 + >
501 602 {module.video_url && (
502 603 <a
503 604 href={module.video_url}
504 605 target="_blank"
@@ -520,9 +621,12 @@
520 621 {__("Video", "yatra")}
521 622 </a>
522 623 )}
523 624 </div>
524 - <div className="flex items-center gap-2">
625 + <div
626 + className="flex items-center gap-2"
627 + onClick={(e) => e.stopPropagation()}
628 + >
525 629 {module.slug === "dynamic_form_field" && module.enabled && (
526 630 <a
527 631 href={`${window.location.origin}/wp-admin/admin.php?page=yatra&subpage=settings`}
528 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"
@@ -542,8 +646,21 @@
542 646 </svg>
543 647 {__("Settings", "yatra")}
544 648 </a>
545 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 + )}
546 663 {module.docs_url && (
547 664 <a
548 665 href={module.docs_url}
549 666 target="_blank"
@@ -562,10 +679,24 @@
562 679 )}
563 680 <PremiumUpgradeDialog
564 681 open={premiumDialog.open}
565 682 moduleName={premiumDialog.module?.name}
683 + moduleSlug={premiumDialog.module?.slug}
566 684 purchaseUrl={premiumDialog.module?.purchase_url}
567 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 + }
568 699 onClose={() => setPremiumDialog({ open: false })}
569 700 />
570 701 </div>
571 702 );