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

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