PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.5.2
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.5.2
3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / src / dashboard / screens / Modules / Modules.js
everest-forms / src / dashboard / screens / Modules Last commit date
components 2 weeks ago Modules.js 2 weeks ago
Modules.js
703 lines
1 /**
2 * External Dependencies
3 */
4 import { Box, Container, IconButton, Text, useToast } from '@chakra-ui/react';
5 import { useQuery } from '@tanstack/react-query';
6 import { __ } from '@wordpress/i18n';
7 import { debounce } from 'lodash';
8 import {
9 useCallback,
10 useContext,
11 useEffect,
12 useMemo,
13 useRef,
14 useState,
15 } from 'react';
16 import { FaArrowUp } from 'react-icons/fa';
17 import { useSearchParams } from 'react-router-dom';
18
19 import { PageNotFound } from './../../components/Icon/Icon';
20 import DashboardContext from './../../context/DashboardContext';
21 import { actionTypes } from './../../reducers/DashboardReducer';
22 import AddonsSkeleton from './../../skeleton/AddonsSkeleton/AddonsSkeleton';
23 import CardsGrid from './components/CardsGrid';
24 import Categories from './components/Categories';
25 import Filters from './components/Filters';
26 import { getAllModules } from './components/modules-api';
27
28 const Modules = () => {
29 const toast = useToast();
30 const [{ allModules }, dispatch] = useContext(DashboardContext);
31 const [searchParams] = useSearchParams();
32
33 const [state, setState] = useState({
34 modules: [],
35 originalModules: [],
36 modulesLoaded: false,
37 selectedModuleData: {},
38 bulkAction: '',
39 isPerformingBulkAction: false,
40 searchItem: '',
41 noItemFound: false,
42 error: null,
43 selectedCategory: 'All',
44 selectedSort: 'default',
45 selectedStatus: 'all',
46 selectedPlan: 'all',
47 isLoading: false,
48 highlightedCategories: [],
49 isTransitioning: false,
50 });
51 const [showScrollTop, setShowScrollTop] = useState(false);
52 const searchItemRef = useRef(state.searchItem);
53 const isFirstRender = useRef(true);
54
55 const searchIndex = useMemo(() => {
56 if (!state.originalModules || state.originalModules.length === 0) {
57 return new Map();
58 }
59
60 const index = new Map();
61 state.originalModules.forEach((module, idx) => {
62 index.set(idx, {
63 titleLower: module.title.toLowerCase(),
64 category: module.category,
65 status: module.status,
66 plan: module.plan,
67 module: module,
68 });
69 });
70
71 return index;
72 }, [state.originalModules]);
73
74 const getDynamicCategories = () => {
75 if (!state.originalModules || state.originalModules.length === 0) {
76 return [{ value: 'All', label: 'All', internalValue: 'All' }];
77 }
78
79 const uniqueCategories = [
80 ...new Set(
81 state.originalModules.map((module) => module.category).filter(Boolean),
82 ),
83 ];
84
85 const categoryDisplayNames = {
86 'Form Elements': 'Form Elements',
87 Integrations: 'Integrations',
88 Marketing: 'Marketing',
89 'Payment Gateways': 'Payment Gateways',
90 'Email Marketing': 'Email Marketing',
91 Others: 'Others',
92 };
93
94 const categories = [{ value: 'All', label: 'All', internalValue: 'All' }];
95
96 uniqueCategories.forEach((category) => {
97 categories.push({
98 value: categoryDisplayNames[category] || category,
99 label: categoryDisplayNames[category] || category,
100 internalValue: category,
101 });
102 });
103
104 return categories;
105 };
106
107 const categories = useMemo(
108 () => getDynamicCategories(),
109 [state.originalModules],
110 );
111
112 const statusOptions = [
113 { label: 'All Status', value: 'all' },
114 { label: 'Active', value: 'active' },
115 { label: 'Inactive', value: 'inactive' },
116 ];
117
118 const planOptions = [
119 { label: 'All Plans', value: 'all' },
120 { label: 'Free', value: 'free' },
121 { label: 'Pro', value: 'pro' },
122 ];
123
124 const sortOptions = [
125 { label: __('All', 'everest-forms'), value: 'default' },
126 { label: __('Newest', 'everest-forms'), value: 'newest' },
127 { label: __('Oldest', 'everest-forms'), value: 'oldest' },
128 { label: __('Ascending', 'everest-forms'), value: 'asc' },
129 { label: __('Descending', 'everest-forms'), value: 'desc' },
130 ];
131
132 const selectedSortValue = useMemo(
133 () =>
134 sortOptions.find((option) => option.value === state.selectedSort) || null,
135 [state.selectedSort],
136 );
137
138 const selectedStatusValue = useMemo(
139 () =>
140 statusOptions.find((option) => option.value === state.selectedStatus) ||
141 null,
142 [state.selectedStatus],
143 );
144
145 const selectedPlanValue = useMemo(
146 () =>
147 planOptions.find((option) => option.value === state.selectedPlan) || null,
148 [state.selectedPlan],
149 );
150
151 const deduplicateModules = (modules) => {
152 const seen = new Set();
153 return modules.filter((module) => {
154 if (seen.has(module.slug)) {
155 return false;
156 }
157 seen.add(module.slug);
158 return true;
159 });
160 };
161
162 const filterModules = (
163 modules,
164 category,
165 showLoading = false,
166 statusFilter = null,
167 planFilter = null,
168 ) => {
169 if (!modules || modules.length === 0) {
170 setState((prev) => ({
171 ...prev,
172 modules: [],
173 noItemFound: true,
174 highlightedCategories: [],
175 }));
176 return;
177 }
178
179 const currentStatus =
180 statusFilter !== null ? statusFilter : state.selectedStatus;
181 const currentPlan = planFilter !== null ? planFilter : state.selectedPlan;
182 const searchValue = searchItemRef.current.toLowerCase().trim();
183
184 const filtered = [];
185 const categoriesWithResults = new Set();
186
187 const indexToUse =
188 searchIndex.size > 0
189 ? searchIndex
190 : new Map(
191 modules.map((mod, idx) => [
192 idx,
193 {
194 titleLower: mod.title.toLowerCase(),
195 category: mod.category,
196 status: mod.status,
197 plan: mod.plan,
198 module: mod,
199 },
200 ]),
201 );
202
203 indexToUse.forEach((indexedModule) => {
204 if (
205 category &&
206 category !== 'All' &&
207 indexedModule.category !== category
208 ) {
209 return;
210 }
211
212 if (
213 currentStatus &&
214 currentStatus !== 'all' &&
215 indexedModule.status !== currentStatus
216 ) {
217 return;
218 }
219
220 if (currentPlan && currentPlan !== 'all') {
221 const modulePlan = indexedModule.plan || '';
222 const planLower =
223 typeof modulePlan === 'string'
224 ? modulePlan.toLowerCase()
225 : Array.isArray(modulePlan)
226 ? modulePlan.join(',').toLowerCase()
227 : '';
228
229 if (currentPlan === 'free') {
230 if (!planLower.includes('free')) {
231 return;
232 }
233 }
234
235 if (currentPlan === 'pro') {
236 const isFree = planLower.includes('free');
237 const isPro = planLower.includes('pro');
238
239 if (isFree && !isPro) {
240 return;
241 }
242 }
243 }
244
245 if (searchValue && !indexedModule.titleLower.includes(searchValue)) {
246 return;
247 }
248
249 filtered.push(indexedModule.module);
250
251 if (searchValue && indexedModule.category) {
252 categoriesWithResults.add(indexedModule.category);
253 }
254 });
255
256 setState((prev) => ({
257 ...prev,
258 modules: filtered,
259 noItemFound: filtered.length === 0,
260 highlightedCategories: searchValue
261 ? Array.from(categoriesWithResults)
262 : [],
263 }));
264 };
265
266 const {
267 data: modulesData,
268 isLoading: isQueryLoading,
269 isError,
270 error: queryError,
271 } = useQuery({
272 queryKey: ['modules'],
273 queryFn: getAllModules,
274 staleTime: 5 * 60 * 1000,
275 cacheTime: 10 * 60 * 1000,
276 retry: 2,
277 });
278
279 useEffect(() => {
280 if (modulesData?.success) {
281 const deduplicatedModules = deduplicateModules(modulesData.modules_lists);
282
283 dispatch({
284 type: actionTypes.GET_ALL_MODULES,
285 allModules: deduplicatedModules,
286 });
287
288 const categoryParam = searchParams.get('category') || 'All';
289
290 setState((prev) => ({
291 ...prev,
292 originalModules: deduplicatedModules,
293 modulesLoaded: true,
294 selectedCategory: categoryParam,
295 }));
296
297 setTimeout(() => {
298 filterModules(deduplicatedModules, categoryParam, false);
299 }, 0);
300 }
301 }, [modulesData, dispatch]);
302
303 useEffect(() => {
304 if (isError) {
305 setState((prev) => ({
306 ...prev,
307 error: queryError?.message || 'Failed to load modules',
308 modulesLoaded: true,
309 isLoading: false,
310 }));
311 }
312 }, [isError, queryError]);
313
314 useEffect(() => {
315 const handleScroll = () => {
316 const scrollTop =
317 window.pageYOffset || document.documentElement.scrollTop;
318 setShowScrollTop(scrollTop > 300);
319 };
320
321 window.addEventListener('scroll', handleScroll);
322 return () => window.removeEventListener('scroll', handleScroll);
323 }, []);
324
325 const scrollToTop = () => {
326 window.scrollTo({
327 top: 0,
328 behavior: 'smooth',
329 });
330 };
331
332 const showToast = (title, status) => {
333 toast({
334 title: __(title, 'everest-forms'),
335 status,
336 duration: 3000,
337 isClosable: true,
338 });
339 };
340
341 const debounceSearch = useCallback(
342 debounce(() => {
343 const currentCategoryObj = categories.find(
344 (cat) => cat.value === state.selectedCategory,
345 );
346 const currentInternalCategory = currentCategoryObj
347 ? currentCategoryObj.internalValue
348 : 'All';
349
350 filterModules(
351 state.originalModules,
352 currentInternalCategory,
353 false,
354 state.selectedStatus,
355 state.selectedPlan,
356 );
357 }, 200),
358 [
359 state.originalModules,
360 state.selectedCategory,
361 state.selectedStatus,
362 state.selectedPlan,
363 categories,
364 ],
365 );
366
367 const handleSearchInputChange = (e) => {
368 const val = e.target.value;
369 setState((prev) => ({ ...prev, searchItem: val }));
370 searchItemRef.current = val;
371
372 if (val.length === 0) {
373 debounceSearch.cancel();
374
375 setState((prev) => ({ ...prev, highlightedCategories: [] }));
376
377 const currentCategoryObj = categories.find(
378 (cat) => cat.value === state.selectedCategory,
379 );
380 const currentInternalCategory = currentCategoryObj
381 ? currentCategoryObj.internalValue
382 : 'All';
383
384 filterModules(
385 state.originalModules,
386 currentInternalCategory,
387 false,
388 state.selectedStatus,
389 state.selectedPlan,
390 );
391 } else if (val.length >= 2) {
392 if (state.selectedCategory !== 'All') {
393 setState((prev) => ({ ...prev, selectedCategory: 'All' }));
394 }
395
396 debounceSearch();
397 }
398 };
399
400 const parseDate = (dateString) => {
401 const [day, month, year] = dateString.split('/').map(Number);
402 return new Date(year, month - 1, day);
403 };
404
405 const handleSorterChange = useCallback((sortType) => {
406 setState((prev) => {
407 let sortedModules = [...prev.modules];
408
409 switch (sortType) {
410 case 'newest':
411 sortedModules.sort(
412 (a, b) => parseDate(b.released_date) - parseDate(a.released_date),
413 );
414 break;
415 case 'oldest':
416 sortedModules.sort(
417 (a, b) => parseDate(a.released_date) - parseDate(b.released_date),
418 );
419 break;
420 case 'asc':
421 sortedModules.sort((a, b) => a.title.localeCompare(b.title));
422 break;
423 case 'desc':
424 sortedModules.sort((a, b) => b.title.localeCompare(a.title));
425 break;
426 case 'default':
427 sortedModules.sort((a, b) => {
428 if ('popular_rank' in a && 'popular_rank' in b) {
429 return a.popular_rank - b.popular_rank;
430 } else if ('popular_rank' in a) {
431 return -1;
432 } else if ('popular_rank' in b) {
433 return 1;
434 }
435 return 0;
436 });
437 break;
438 default:
439 break;
440 }
441
442 return {
443 ...prev,
444 modules: sortedModules,
445 selectedSort: sortType,
446 };
447 });
448 }, []);
449
450 const handleResetFilters = () => {
451 debounceSearch.cancel();
452
453 setState((prev) => ({
454 ...prev,
455 selectedCategory: 'All',
456 selectedSort: 'default',
457 selectedStatus: 'all',
458 selectedPlan: 'all',
459 searchItem: '',
460 highlightedCategories: [],
461 }));
462 searchItemRef.current = '';
463 filterModules(state.originalModules, 'All', false, 'all', 'all');
464 };
465
466 const getNoResultsMessage = () => {
467 const hasSearch = state.searchItem.trim().length > 0;
468 const hasFilters =
469 state.selectedCategory !== 'All' ||
470 state.selectedStatus !== 'all' ||
471 state.selectedPlan !== 'all';
472
473 if (hasSearch && hasFilters) {
474 return {
475 title: __('No modules match your search and filters', 'everest-forms'),
476 subtitle: __(
477 'Try adjusting your search term or filters',
478 'everest-forms',
479 ),
480 };
481 } else if (hasSearch) {
482 return {
483 title: __('Sorry, no result found.', 'everest-forms'),
484 subtitle: __('Please try another search', 'everest-forms'),
485 };
486 } else if (hasFilters) {
487 return {
488 title: __('No modules match your filters', 'everest-forms'),
489 subtitle: __('Try adjusting your filter selection', 'everest-forms'),
490 };
491 }
492
493 return {
494 title: __('No modules available', 'everest-forms'),
495 subtitle: __('Please check back later', 'everest-forms'),
496 };
497 };
498
499 const noResultsMessage = getNoResultsMessage();
500
501 /**
502 * Updates both originalModules and modules in state when a toggle occurs.
503 * This ensures the correct status is reflected when switching category tabs
504 * without requiring a full page reload.
505 */
506 const handleModuleToggle = useCallback((slug, newStatus) => {
507 setState((prev) => {
508 const updatedOriginalModules = prev.originalModules.map((mod) =>
509 mod.slug === slug ? { ...mod, status: newStatus } : mod,
510 );
511 const updatedModules = prev.modules.map((mod) =>
512 mod.slug === slug ? { ...mod, status: newStatus } : mod,
513 );
514 return {
515 ...prev,
516 originalModules: updatedOriginalModules,
517 modules: updatedModules,
518 };
519 });
520 }, []);
521
522 return (
523 <Box top="var(--wp-admin--admin-bar--height, 0)" zIndex={1} minH="100vh">
524 <Container
525 maxW="100%"
526 p={{ base: '12px', sm: '16px', md: '20px' }}
527 padding="24px"
528 >
529 {state.isLoading || isQueryLoading || !state.modulesLoaded ? (
530 <AddonsSkeleton />
531 ) : (
532 <>
533 <Box mb="4">
534 <Filters
535 sortOptions={sortOptions}
536 statusOptions={statusOptions}
537 planOptions={planOptions}
538 selectedSortValue={selectedSortValue}
539 selectedStatusValue={selectedStatusValue}
540 selectedPlanValue={selectedPlanValue}
541 onSortChange={(selectedOption) => {
542 handleSorterChange(selectedOption?.value || 'default');
543 }}
544 onStatusChange={(selectedOption) => {
545 const newStatus = selectedOption?.value || 'all';
546 setState((prev) => ({ ...prev, selectedStatus: newStatus }));
547
548 const currentCategoryObj = categories.find(
549 (cat) => cat.value === state.selectedCategory,
550 );
551 const currentInternalCategory = currentCategoryObj
552 ? currentCategoryObj.internalValue
553 : 'All';
554
555 filterModules(
556 state.originalModules,
557 currentInternalCategory,
558 false,
559 newStatus,
560 null,
561 );
562 }}
563 onPlanChange={(selectedOption) => {
564 const newPlan = selectedOption?.value || 'all';
565 setState((prev) => ({ ...prev, selectedPlan: newPlan }));
566
567 const currentCategoryObj = categories.find(
568 (cat) => cat.value === state.selectedCategory,
569 );
570 const currentInternalCategory = currentCategoryObj
571 ? currentCategoryObj.internalValue
572 : 'All';
573
574 filterModules(
575 state.originalModules,
576 currentInternalCategory,
577 false,
578 null,
579 newPlan,
580 );
581 }}
582 searchValue={state.searchItem}
583 onSearchChange={handleSearchInputChange}
584 onReset={handleResetFilters}
585 />
586
587 <Categories
588 categories={categories}
589 selectedCategory={state.selectedCategory}
590 highlightedCategories={state.highlightedCategories}
591 onCategoryChange={(displayValue, internalValue) => {
592 setState((prev) => ({
593 ...prev,
594 selectedCategory: displayValue,
595 isTransitioning: true,
596 }));
597
598 requestAnimationFrame(() => {
599 filterModules(state.originalModules, internalValue, false);
600 setTimeout(() => {
601 setState((prev) => ({
602 ...prev,
603 isTransitioning: false,
604 }));
605 }, 10);
606 });
607 }}
608 />
609 </Box>
610
611 <Box
612 opacity={state.isTransitioning ? 0 : 1}
613 transition="opacity 0.15s ease-in-out"
614 >
615 {state.noItemFound ? (
616 <Box
617 bg="white"
618 borderRadius="lg"
619 display="flex"
620 justifyContent="center"
621 flexDirection="column"
622 padding={{
623 base: '40px 16px',
624 sm: '60px 20px',
625 md: '80px 40px',
626 lg: '100px',
627 }}
628 gap={{ base: '3', md: '4' }}
629 alignItems="center"
630 minH={{ base: '300px', md: '400px' }}
631 >
632 <PageNotFound
633 color="gray.300"
634 boxSize={{ base: '16', sm: '20', md: '24' }}
635 />
636 <Text
637 fontSize={{ base: '16px', sm: '18px', md: '20px' }}
638 fontWeight="600"
639 color="gray.800"
640 textAlign="center"
641 px={{ base: '2', sm: '4' }}
642 >
643 {noResultsMessage.title}
644 </Text>
645 <Text
646 fontSize={{ base: '13px', sm: '14px' }}
647 color="gray.500"
648 textAlign="center"
649 px={{ base: '2', sm: '4' }}
650 >
651 {noResultsMessage.subtitle}
652 </Text>
653 </Box>
654 ) : (
655 <CardsGrid
656 modules={state.modules}
657 selectedCategory={state.selectedCategory}
658 showToast={showToast}
659 onModuleToggle={handleModuleToggle}
660 />
661 )}
662 </Box>
663 </>
664 )}
665 </Container>
666
667 {showScrollTop && (
668 <IconButton
669 position="fixed"
670 bottom={{ base: '16px', sm: '20px', md: '24px' }}
671 right={{ base: '16px', sm: '20px', md: '24px' }}
672 zIndex="1000"
673 aria-label="Scroll to top"
674 icon={<FaArrowUp />}
675 size="md"
676 variant="solid"
677 bg="white"
678 border="1px solid"
679 borderColor="#E5E7EB"
680 color="#6B7280"
681 borderRadius="full"
682 w={{ base: '44px', sm: '48px' }}
683 h={{ base: '44px', sm: '48px' }}
684 minW={{ base: '44px', sm: '48px' }}
685 _hover={{
686 bg: '#F9FAFB',
687 borderColor: '#D1D5DB',
688 color: '#374151',
689 transform: 'translateY(-2px)',
690 }}
691 _active={{
692 transform: 'translateY(0)',
693 }}
694 transition="all 0.2s ease"
695 onClick={scrollToTop}
696 />
697 )}
698 </Box>
699 );
700 };
701
702 export default Modules;
703