import React, { useMemo, useState } from "react"; import { Puzzle, Search, Filter, ArrowUpDown, ExternalLink, ArrowUpRight, } from "lucide-react"; import { __ } from "../lib/i18n"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from "../components/ui/card"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Select } from "../components/ui/select"; import { useModulesQuery, useToggleModule, ModuleDefinition, ModulePlan, useBulkToggleModules, } from "../hooks/useModules"; import { usePermissions } from "../hooks/usePermissions"; import { PremiumUpgradeDialog } from "../components/modules/PremiumUpgradeDialog"; /** * Build the "Learn more" link to a module's dedicated feature page on * wpyatra.com (e.g. https://wpyatra.com/features/dynamic-pricing/), tagged with * UTM params. The feature-page slug matches the docs slug, so we derive it from * the module's docs_url last segment (defined once per module on the backend). * Returns null when there's no docs_url to derive a slug from. */ const featureUrlFromDocs = (docsUrl?: string): string | null => { if (!docsUrl) return null; const slug = docsUrl.replace(/\/+$/, "").split("/").pop(); if (!slug) return null; const params = "utm_source=plugin&utm_medium=modules&utm_campaign=learn-more&utm_content=" + encodeURIComponent(slug); return `https://wpyatra.com/features/${slug}/?${params}`; }; const Modules: React.FC = () => { const { can } = usePermissions(); const canManageModules = can("yatra_edit_trips"); const { data: modules = [], isLoading, error } = useModulesQuery(); const toggleMutation = useToggleModule(); const bulkToggleMutation = useBulkToggleModules(); const [searchTerm, setSearchTerm] = useState(""); const [categoryFilter, setCategoryFilter] = useState<"all" | string>("all"); const [planFilter, setPlanFilter] = useState<"all" | ModulePlan>("all"); const [sortOption, setSortOption] = useState< "name_asc" | "name_desc" | "status_enabled" | "status_disabled" >("name_asc"); const [selected, setSelected] = useState>(new Set()); const categories = useMemo(() => { const set = new Set(); modules.forEach((module) => { if (module.category) { set.add(module.category); } }); return Array.from(set).sort((a, b) => a.localeCompare(b)); }, [modules]); // Plan-filter dropdown only lists the tiers actually present in the // current module set — keeps the menu clean on sites where only some // tiers are in play (e.g. a free-only install shouldn't list Agency). // Modules with no `plan` field are treated as Free so they're filterable. const availablePlans = useMemo(() => { const set = new Set(); modules.forEach((module) => { set.add(module.plan ?? "free"); }); // Canonical order from cheapest tier to most premium so the dropdown // reads like a price-ladder, not random. const order: ModulePlan[] = ["free", "personal", "growth", "agency"]; return order.filter((tier) => set.has(tier)); }, [modules]); // 2026 plan rename: display labels changed (Personal→Starter, Agency→Scale) // but the plan *slugs* ('personal'/'agency') stay — they mirror the Pro // internal tier slugs and keep existing-user feature gating intact. const planLabels: Record = { free: __("Free", "yatra"), personal: __("Starter", "yatra"), growth: __("Growth", "yatra"), agency: __("Scale", "yatra"), }; const filteredModules = useMemo(() => { let list = modules; if (searchTerm.trim()) { const term = searchTerm.toLowerCase(); list = list.filter( (module) => module.name.toLowerCase().includes(term) || (module.description?.toLowerCase().includes(term) ?? false) || (module.tags?.some((tag) => tag.toLowerCase().includes(term)) ?? false), ); } if (categoryFilter !== "all") { list = list.filter( (module) => (module.category || __("General", "yatra")) === categoryFilter, ); } if (planFilter !== "all") { // Modules without an explicit plan are treated as Free — matches // the Free-tier badge fallthrough in the card renderer below. list = list.filter((module) => (module.plan ?? "free") === planFilter); } const sorted = [...list]; sorted.sort((a, b) => { switch (sortOption) { case "name_desc": return b.name.localeCompare(a.name); case "status_enabled": return Number(b.enabled) - Number(a.enabled); case "status_disabled": return Number(a.enabled) - Number(b.enabled); case "name_asc": default: return a.name.localeCompare(b.name); } }); return sorted; }, [modules, searchTerm, categoryFilter, planFilter, sortOption]); const moduleMap = useMemo(() => { const map = new Map(); modules.forEach((module) => { map.set(module.slug, module); }); return map; }, [modules]); const [premiumDialog, setPremiumDialog] = useState<{ open: boolean; module?: ModuleDefinition; }>({ open: false }); const handleToggle = (module: ModuleDefinition) => { if (module.is_core || !canManageModules) return; // Show premium dialog only if module requires Pro and is NOT available (Pro not active or module not in Pro) if (module.is_premium && !module.enabled && !module.is_available) { setPremiumDialog({ open: true, module }); return; } toggleMutation.mutate({ slug: module.slug, enabled: !module.enabled, name: module.name, }); }; const renderToggle = (module: ModuleDefinition) => { // Module is locked if it's premium, not enabled, and not available (Pro not active) const isLockedPremium = module.is_premium && !module.enabled && !module.is_available; // When Pro is active and module is available, allow toggling (user can enable/disable) const disabled = module.is_core || toggleMutation.isPending || !canManageModules; return ( ); }; const handleSelect = (slug: string) => { const module = moduleMap.get(slug); // Only show premium dialog if module is premium, not enabled, and not available if (module?.is_premium && !module.enabled && !module.is_available) { setPremiumDialog({ open: true, module }); return; } setSelected((prev) => { const next = new Set(prev); if (next.has(slug)) { next.delete(slug); } else { next.add(slug); } return next; }); }; const handleSelectAllVisible = () => { setSelected((prev) => { const next = new Set(prev); const allSelected = filteredModules.every((module) => next.has(module.slug), ); if (allSelected) { filteredModules.forEach((module) => next.delete(module.slug)); } else { filteredModules.forEach((module) => next.add(module.slug)); } return next; }); }; const bulkUpdate = (enabled: boolean) => { const items = Array.from(selected) .map((slug) => moduleMap.get(slug)) .filter((module): module is ModuleDefinition => !!module) // Only filter out premium modules that are NOT available (Pro not active) .filter( (module) => !(module.is_premium && enabled && !module.is_available), ); if (items.length === 0) { if (selected.size > 0 && enabled) { // Only show premium dialog for modules that are not available const premiumModules = Array.from(selected) .map((slug) => moduleMap.get(slug)) .filter((module) => module?.is_premium && !module?.is_available); if (premiumModules.length > 0) { setPremiumDialog({ open: true, module: premiumModules[0] }); } } return; } const payload = items.map((module) => ({ slug: module.slug, enabled, name: module.name, })); bulkToggleMutation.mutate(payload, { onSuccess: () => setSelected(new Set()), }); }; const isAllVisibleSelected = filteredModules.length > 0 && filteredModules.every((module) => selected.has(module.slug)); const selectedCount = selected.size; return (
window.location.reload()} className="flex items-center gap-2" > {__("Refresh Modules", "yatra")} } />
{__("Showing {count} modules", "yatra").replace( "{count}", String(filteredModules.length), )}
setSearchTerm(e.target.value)} placeholder={__("Search modules...", "yatra")} className="pl-9" />
{/* Plan filter — only renders when there are at least two tiers represented in the modules list. A site that only has Free modules doesn't need a plan filter, and showing a one-option dropdown is worse UX than no dropdown. */} {availablePlans.length > 1 && ( )} {(searchTerm || categoryFilter !== "all" || planFilter !== "all" || sortOption !== "name_asc") && ( )}
{isLoading && (
{Array.from({ length: 3 }).map((_, idx) => (
))}
)} {error && ( {__("Failed to load modules", "yatra")} )} {!isLoading && !error && modules.length === 0 && ( {__("No modules available yet.", "yatra")} )} {!isLoading && !error && filteredModules.length > 0 && (
{filteredModules.map((module) => ( setPremiumDialog({ open: true, module }), onKeyDown: (event: React.KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setPremiumDialog({ open: true, module }); } }, } : {})} >
handleSelect(module.slug)} /> {module.enabled && ( {__("Enabled", "yatra")} )}
{ event.stopPropagation(); handleSelect(module.slug); }} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleSelect(module.slug); } }} > {module.name} {module.is_core && ( {__("Core", "yatra")} )} {/* Plan badge — Starter for any Pro module, Scale for white-label-tier modules. Always visible so customers know which plan unlocks the module. (Slug stays 'agency'; label is the new "Scale".) */} {module.plan === "agency" && ( {__("Scale", "yatra")} )} {/* Growth tier — AI Assistant + any future module requiring the Growth-or-Agency band. Without this branch the card showed NO plan badge at all, so customers couldn't tell which tier unlocks AI features. */} {module.plan === "growth" && ( {__("Growth", "yatra")} )} {module.plan === "personal" && ( {__("Starter", "yatra")} )} {module.description}

{module.category || __("General", "yatra")}

{renderToggle(module)}
e.stopPropagation()} > {module.video_url && ( {__("Video", "yatra")} )}
))}
)} setPremiumDialog({ open: false })} />
); }; export default Modules;