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

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