| @@ -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 | ); |