CreateFormCTA.tsx
2 weeks ago
CreateWithAI.tsx
2 weeks ago
Main.tsx
2 weeks ago
PluginStatus.tsx
2 weeks ago
Sidebar.tsx
2 weeks ago
TemplateList.tsx
2 weeks ago
TemplatesSkeleton.tsx
4 months ago
Main.tsx
602 lines
| 1 | import { |
| 2 | Box, |
| 3 | Flex, |
| 4 | Heading, |
| 5 | Icon, |
| 6 | Input, |
| 7 | InputGroup, |
| 8 | InputLeftElement, |
| 9 | keyframes, |
| 10 | Tab, |
| 11 | TabList, |
| 12 | Tabs, |
| 13 | Text, |
| 14 | useToast, |
| 15 | } from '@chakra-ui/react'; |
| 16 | import { useQuery } from '@tanstack/react-query'; |
| 17 | import apiFetch from '@wordpress/api-fetch'; |
| 18 | import { __ } from '@wordpress/i18n'; |
| 19 | import debounce from 'lodash.debounce'; |
| 20 | import React, { useCallback, useEffect, useMemo, useState } from 'react'; |
| 21 | import { IoSearchOutline } from 'react-icons/io5'; |
| 22 | import { FiRefreshCw } from 'react-icons/fi'; |
| 23 | import { templatesScriptData } from '../utils/global'; |
| 24 | import Sidebar from './Sidebar'; |
| 25 | import TemplateList from './TemplateList'; |
| 26 | import CreateFormCTA from './CreateFormCTA'; |
| 27 | |
| 28 | const { restURL, security } = templatesScriptData; |
| 29 | |
| 30 | const fetchTemplates = async () => { |
| 31 | const response = (await apiFetch({ |
| 32 | path: `${restURL}everest-forms/v1/templates`, |
| 33 | method: 'GET', |
| 34 | headers: { |
| 35 | 'X-WP-Nonce': security, |
| 36 | }, |
| 37 | })) as { templates: { category: string; templates: Template[] }[] }; |
| 38 | |
| 39 | if (response && Array.isArray(response.templates)) { |
| 40 | const allTemplates = response.templates.flatMap( |
| 41 | (category) => category.templates, |
| 42 | ); |
| 43 | return allTemplates; |
| 44 | } else { |
| 45 | throw new Error(__('Unexpected response format.', 'everest-forms')); |
| 46 | } |
| 47 | }; |
| 48 | |
| 49 | interface CreateFormResponse { |
| 50 | success: boolean; |
| 51 | data?: { id: number; redirect: string; status: number }; |
| 52 | message?: string; |
| 53 | } |
| 54 | |
| 55 | const shimmer = keyframes` |
| 56 | 0% { background-position: -600px 0; } |
| 57 | 100% { background-position: 600px 0; } |
| 58 | `; |
| 59 | |
| 60 | const spin = keyframes` |
| 61 | from { transform: rotate(0deg); } |
| 62 | to { transform: rotate(360deg); } |
| 63 | `; |
| 64 | |
| 65 | const skimmerStyle = { |
| 66 | background: 'linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%)', |
| 67 | backgroundSize: '600px 100%', |
| 68 | animation: `${shimmer} 1.6s ease-in-out infinite`, |
| 69 | borderRadius: '4px', |
| 70 | }; |
| 71 | |
| 72 | const SkelBox: React.FC<{ w?: string; h?: string; mb?: string; br?: string }> = ({ w = '100%', h = '14px', mb = '0', br = '4px' }) => ( |
| 73 | <Box w={w} h={h} mb={mb} borderRadius={br} sx={skimmerStyle} /> |
| 74 | ); |
| 75 | |
| 76 | // Template card skeleton — matches actual card: gradient image bg + white inner wrapper + info section |
| 77 | const TemplateCardSkeleton = () => ( |
| 78 | <Box bg="white" borderRadius="12px" border="1px solid #e2e8f0" overflow="hidden" display="flex" flexDirection="column"> |
| 79 | {/* Image area: gradient bg + white inner card (matching actual card structure) */} |
| 80 | <Box |
| 81 | position="relative" |
| 82 | borderBottom="1px solid #e2e8f0" |
| 83 | pt="20px" px="20px" pb="0" |
| 84 | overflow="hidden" |
| 85 | background="linear-gradient(129deg, #F3F2F8 2.83%, #F7F5F9 110.96%)" |
| 86 | minH="160px" |
| 87 | > |
| 88 | <Box |
| 89 | w="100%" |
| 90 | borderRadius="8px 8px 0 0" |
| 91 | border="1px solid #e2e8f0" |
| 92 | borderBottom="none" |
| 93 | overflow="hidden" |
| 94 | h="140px" |
| 95 | sx={skimmerStyle} |
| 96 | opacity={0.6} |
| 97 | /> |
| 98 | </Box> |
| 99 | {/* Info section */} |
| 100 | <Box p="20px" flex="1"> |
| 101 | <SkelBox w="62%" h="13px" mb="6px" /> |
| 102 | <SkelBox w="90%" h="11px" mb="4px" /> |
| 103 | <SkelBox w="76%" h="11px" /> |
| 104 | </Box> |
| 105 | </Box> |
| 106 | ); |
| 107 | |
| 108 | const TemplateSkeleton = () => ( |
| 109 | <Box p="32px"> |
| 110 | {/* CTA cards — 2-col equal-width grid, matching CreateFormCTA layout */} |
| 111 | <Flex gap="32px" mb="32px" direction={{ base: 'column', md: 'row' }}> |
| 112 | {/* AI card: icon + badge row, then title/desc/link */} |
| 113 | <Box flex="1" bg="white" borderRadius="16px" p="32px" border="1px solid #e2e8f0" display="flex" flexDirection="column"> |
| 114 | <Flex justify="space-between" align="center" mb="16px"> |
| 115 | <Box borderRadius="12px" w="48px" h="48px" sx={skimmerStyle} /> |
| 116 | <Box borderRadius="6px" w="44px" h="22px" sx={skimmerStyle} /> |
| 117 | </Flex> |
| 118 | <SkelBox w="58%" h="17px" mb="8px" /> |
| 119 | <SkelBox w="92%" h="12px" mb="4px" /> |
| 120 | <SkelBox w="76%" h="12px" mb="24px" /> |
| 121 | <SkelBox w="86px" h="13px" /> |
| 122 | </Box> |
| 123 | {/* Scratch card: standalone icon, then title/desc/link */} |
| 124 | <Box flex="1" bg="white" borderRadius="16px" p="32px" border="1px solid #e2e8f0" display="flex" flexDirection="column"> |
| 125 | <Box borderRadius="12px" w="48px" h="48px" mb="20px" sx={skimmerStyle} /> |
| 126 | <SkelBox w="62%" h="17px" mb="8px" /> |
| 127 | <SkelBox w="92%" h="12px" mb="4px" /> |
| 128 | <SkelBox w="80%" h="12px" mb="24px" /> |
| 129 | <SkelBox w="70px" h="13px" /> |
| 130 | </Box> |
| 131 | </Flex> |
| 132 | |
| 133 | {/* Template section card */} |
| 134 | <Box bg="white" borderRadius="16px" border="1px solid #e2e8f0" overflow="hidden"> |
| 135 | |
| 136 | {/* Top bar: search (left, w-256px) | heading + filter tabs (right) */} |
| 137 | <Flex borderBottom="1px solid #e2e8f0" direction={{ base: 'column', md: 'row' }}> |
| 138 | <Box w={{ base: '100%', md: '256px' }} minW={{ md: '256px' }} p="20px" borderRight={{ base: 'none', md: '1px solid #e2e8f0' }}> |
| 139 | {/* Search input shimmer */} |
| 140 | <Box h="36px" borderRadius="8px" sx={skimmerStyle} /> |
| 141 | </Box> |
| 142 | <Flex flex="1" px="28px" py="20px" align="center" justify="space-between"> |
| 143 | {/* "Choose from Templates" heading */} |
| 144 | <SkelBox w="190px" h="19px" /> |
| 145 | {/* Filter tabs pill */} |
| 146 | <Box borderRadius="8px" w="150px" h="34px" sx={skimmerStyle} /> |
| 147 | </Flex> |
| 148 | </Flex> |
| 149 | |
| 150 | {/* Sidebar + template grid */} |
| 151 | <Flex direction={{ base: 'column', md: 'row' }}> |
| 152 | {/* Sidebar: CATEGORIES label + rows + Can't find card */} |
| 153 | <Box w={{ base: '100%', md: '256px' }} minW={{ md: '256px' }} p="20px" pt="12px" borderRight={{ base: 'none', md: '1px solid #e2e8f0' }}> |
| 154 | {/* "CATEGORIES" label */} |
| 155 | <Box w="72px" h="10px" mb="10px" sx={skimmerStyle} borderRadius="3px" /> |
| 156 | {/* Category rows */} |
| 157 | {[78, 62, 88, 55, 72, 60, 70, 52, 65, 58].map((w, i) => ( |
| 158 | <Flex key={i} justify="space-between" align="center" mb="2px" px="12px" py="12px" borderRadius="8px"> |
| 159 | <Box w={`${w}%`} h="13px" sx={skimmerStyle} borderRadius="3px" /> |
| 160 | <Box w="20px" h="13px" sx={skimmerStyle} borderRadius="3px" /> |
| 161 | </Flex> |
| 162 | ))} |
| 163 | {/* "Can't find a template?" card */} |
| 164 | <Box mt="20px" borderRadius="12px" border="1px solid #e2e8f0" p="16px"> |
| 165 | <SkelBox w="68%" h="13px" mb="6px" /> |
| 166 | <SkelBox w="92%" h="11px" mb="4px" /> |
| 167 | <SkelBox w="80%" h="11px" mb="12px" /> |
| 168 | <Box h="36px" borderRadius="8px" sx={skimmerStyle} /> |
| 169 | </Box> |
| 170 | </Box> |
| 171 | |
| 172 | {/* Template grid: 2-col (xl: 3-col) matching TemplateList */} |
| 173 | <Box p={{ base: '20px', md: '28px' }} pt="12px" flex={1} minW="0"> |
| 174 | <Box sx={{ |
| 175 | display: 'grid', |
| 176 | gridTemplateColumns: 'repeat(2, 1fr)', |
| 177 | gap: '16px', |
| 178 | '@media (min-width: 1280px)': { gridTemplateColumns: 'repeat(3, 1fr)' }, |
| 179 | }}> |
| 180 | {Array.from({ length: 6 }).map((_, i) => ( |
| 181 | <TemplateCardSkeleton key={i} /> |
| 182 | ))} |
| 183 | </Box> |
| 184 | </Box> |
| 185 | </Flex> |
| 186 | </Box> |
| 187 | </Box> |
| 188 | ); |
| 189 | |
| 190 | const Main: React.FC<{ onCreateWithAI?: (formId?: number, title?: string) => void }> = ({ onCreateWithAI }) => { |
| 191 | const toast = useToast(); |
| 192 | const [filter, setFilter] = useState(__('All', 'everest-forms')); |
| 193 | const [isCreatingBlank, setIsCreatingBlank] = useState(false); |
| 194 | const [searchInputValue, setSearchInputValue] = useState(''); |
| 195 | const [state, setState] = useState({ |
| 196 | selectedCategory: __('All Forms', 'everest-forms'), |
| 197 | searchTerm: '', |
| 198 | }); |
| 199 | const [categorySetFromURL, setCategorySetFromURL] = useState(false); |
| 200 | |
| 201 | const { selectedCategory, searchTerm } = state; |
| 202 | |
| 203 | const { |
| 204 | data: templates = [], |
| 205 | isLoading, |
| 206 | isFetching, |
| 207 | refetch, |
| 208 | error, |
| 209 | } = useQuery(['templates'], fetchTemplates); |
| 210 | |
| 211 | const categories = useMemo(() => { |
| 212 | const categoriesSet = new Set<string>(); |
| 213 | templates.forEach((template) => { |
| 214 | template.categories.forEach((category) => categoriesSet.add(category)); |
| 215 | }); |
| 216 | |
| 217 | return [ |
| 218 | { name: __('All Forms', 'everest-forms'), count: templates.length }, |
| 219 | ...Array.from(categoriesSet).map((category) => ({ |
| 220 | name: category, |
| 221 | count: templates.filter((template) => |
| 222 | template.categories.includes(category), |
| 223 | ).length, |
| 224 | })), |
| 225 | ]; |
| 226 | }, [templates]); |
| 227 | |
| 228 | useEffect(() => { |
| 229 | if (categorySetFromURL) return; |
| 230 | if (categories.length <= 1) return; |
| 231 | |
| 232 | const urlParams = new URLSearchParams(window.location.search); |
| 233 | |
| 234 | if (urlParams.has('evf_template_category')) { |
| 235 | const categorySlug = urlParams.get('evf_template_category') || ''; |
| 236 | |
| 237 | const normalize = (str: string) => |
| 238 | str |
| 239 | .toLowerCase() |
| 240 | .replace(/\s+/g, '') |
| 241 | .replace(/[^a-z0-9]/g, ''); |
| 242 | |
| 243 | const normalizedSlug = normalize(categorySlug); |
| 244 | |
| 245 | let matchedCategory = categories.find((cat) => { |
| 246 | const normalizedCatName = normalize(cat.name); |
| 247 | |
| 248 | if (normalizedCatName === normalizedSlug) return true; |
| 249 | if (normalizedCatName.startsWith(normalizedSlug)) return true; |
| 250 | if (normalizedSlug.startsWith(normalizedCatName)) return true; |
| 251 | |
| 252 | return false; |
| 253 | }); |
| 254 | |
| 255 | if (!matchedCategory) { |
| 256 | matchedCategory = categories.find((cat) => { |
| 257 | const normalizedCatName = normalize(cat.name); |
| 258 | |
| 259 | const slugWords = categorySlug.toLowerCase().split(/[\s-]+/); |
| 260 | const catWords = cat.name.toLowerCase().split(/[\s-]+/); |
| 261 | |
| 262 | const hasMatchingWord = slugWords.some((word) => |
| 263 | catWords.some( |
| 264 | (catWord) => catWord.includes(word) || word.includes(catWord), |
| 265 | ), |
| 266 | ); |
| 267 | |
| 268 | if (hasMatchingWord) return true; |
| 269 | |
| 270 | if ( |
| 271 | normalizedCatName.includes(normalizedSlug) || |
| 272 | normalizedSlug.includes(normalizedCatName) |
| 273 | ) return true; |
| 274 | |
| 275 | return false; |
| 276 | }); |
| 277 | } |
| 278 | |
| 279 | if ( |
| 280 | matchedCategory && |
| 281 | matchedCategory.name !== __('All Forms', 'everest-forms') |
| 282 | ) { |
| 283 | setState((prevState) => ({ |
| 284 | ...prevState, |
| 285 | selectedCategory: matchedCategory.name, |
| 286 | })); |
| 287 | setCategorySetFromURL(true); |
| 288 | } |
| 289 | } |
| 290 | }, [categories, categorySetFromURL]); |
| 291 | |
| 292 | const filteredTemplates = useMemo(() => { |
| 293 | return templates.filter( |
| 294 | (template) => |
| 295 | template.slug !== 'blank' && |
| 296 | (selectedCategory === __('All Forms', 'everest-forms') || |
| 297 | template.categories.includes(selectedCategory)) && |
| 298 | template.title.toLowerCase().includes(searchTerm.toLowerCase()) && |
| 299 | (filter === __('All', 'everest-forms') || |
| 300 | (filter === __('Free', 'everest-forms') && !template.isPro) || |
| 301 | (filter === __('Premium', 'everest-forms') && template.isPro)), |
| 302 | ); |
| 303 | }, [selectedCategory, searchTerm, templates, filter]); |
| 304 | |
| 305 | const handleCategorySelect = useCallback((category: string) => { |
| 306 | setState((prevState) => ({ ...prevState, selectedCategory: category })); |
| 307 | }, []); |
| 308 | |
| 309 | const debouncedSetSearch = useCallback( |
| 310 | debounce((value: string) => { |
| 311 | setState((prevState) => ({ ...prevState, searchTerm: value })); |
| 312 | }, 300), |
| 313 | [], |
| 314 | ); |
| 315 | |
| 316 | const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
| 317 | const value = e.target.value; |
| 318 | setSearchInputValue(value); |
| 319 | debouncedSetSearch(value); |
| 320 | }; |
| 321 | |
| 322 | if (isLoading) return <TemplateSkeleton />; |
| 323 | if (error) return <div>{(error as Error).message}</div>; |
| 324 | |
| 325 | const handleCreateWithAI = (formId?: number, title?: string) => { |
| 326 | if (onCreateWithAI) onCreateWithAI(formId, title); |
| 327 | }; |
| 328 | |
| 329 | const handleCreateBlank = async () => { |
| 330 | setIsCreatingBlank(true); |
| 331 | try { |
| 332 | const response = (await apiFetch({ |
| 333 | path: `${restURL}everest-forms/v1/templates/create`, |
| 334 | method: 'POST', |
| 335 | body: JSON.stringify({ |
| 336 | title: __('Untitled', 'everest-forms'), |
| 337 | slug: 'blank', |
| 338 | }), |
| 339 | headers: { |
| 340 | 'Content-Type': 'application/json', |
| 341 | 'X-WP-Nonce': security, |
| 342 | }, |
| 343 | })) as CreateFormResponse; |
| 344 | |
| 345 | if (response.success && response.data) { |
| 346 | window.location.href = response.data.redirect; |
| 347 | } else { |
| 348 | setIsCreatingBlank(false); |
| 349 | toast({ |
| 350 | title: __('Error', 'everest-forms'), |
| 351 | description: response.message || __('Failed to create form.', 'everest-forms'), |
| 352 | status: 'error', |
| 353 | position: 'bottom-right', |
| 354 | duration: 5000, |
| 355 | isClosable: true, |
| 356 | variant: 'subtle', |
| 357 | }); |
| 358 | } |
| 359 | } catch (error) { |
| 360 | setIsCreatingBlank(false); |
| 361 | toast({ |
| 362 | title: __('Error', 'everest-forms'), |
| 363 | description: __('An error occurred while creating the form.', 'everest-forms'), |
| 364 | status: 'error', |
| 365 | position: 'bottom-right', |
| 366 | duration: 5000, |
| 367 | isClosable: true, |
| 368 | variant: 'subtle', |
| 369 | }); |
| 370 | } |
| 371 | }; |
| 372 | |
| 373 | const filterLabels = [ |
| 374 | __('All', 'everest-forms'), |
| 375 | __('Free', 'everest-forms'), |
| 376 | __('Premium', 'everest-forms'), |
| 377 | ]; |
| 378 | |
| 379 | return ( |
| 380 | <Box p="32px"> |
| 381 | {/* CTA Cards — 2-column equal-width grid */} |
| 382 | <Box mb="32px"> |
| 383 | <CreateFormCTA |
| 384 | onCreateWithAI={handleCreateWithAI} |
| 385 | onCreateBlank={handleCreateBlank} |
| 386 | isCreatingBlank={isCreatingBlank} |
| 387 | /> |
| 388 | </Box> |
| 389 | |
| 390 | {/* Template Section Card */} |
| 391 | <Box |
| 392 | bg="white" |
| 393 | borderRadius="16px" |
| 394 | border="1px solid #e2e8f0" |
| 395 | overflow="hidden" |
| 396 | position="relative" |
| 397 | > |
| 398 | {/* Subtle refetch indicator — thin animated bar at top */} |
| 399 | {isFetching && !isLoading && ( |
| 400 | <Box |
| 401 | position="absolute" |
| 402 | top="0" |
| 403 | left="0" |
| 404 | right="0" |
| 405 | h="2px" |
| 406 | zIndex={10} |
| 407 | borderRadius="16px 16px 0 0" |
| 408 | overflow="hidden" |
| 409 | > |
| 410 | <Box |
| 411 | position="absolute" |
| 412 | top="0" |
| 413 | left="0" |
| 414 | right="0" |
| 415 | h="100%" |
| 416 | bg="rgba(117,69,187,0.15)" |
| 417 | /> |
| 418 | <Box |
| 419 | position="absolute" |
| 420 | top="0" |
| 421 | h="100%" |
| 422 | w="40%" |
| 423 | bg="#7545BB" |
| 424 | sx={{ |
| 425 | animation: 'refetch-slide 1s ease-in-out infinite', |
| 426 | '@keyframes refetch-slide': { |
| 427 | '0%': { left: '-40%' }, |
| 428 | '100%': { left: '140%' }, |
| 429 | }, |
| 430 | }} |
| 431 | /> |
| 432 | </Box> |
| 433 | )} |
| 434 | |
| 435 | {/* Top bar: search (left) | heading + filter tabs (right) */} |
| 436 | <Flex |
| 437 | align="center" |
| 438 | borderBottom="1px solid #e2e8f0" |
| 439 | direction={{ base: 'column', md: 'row' }} |
| 440 | > |
| 441 | {/* Search area — aligned with sidebar width */} |
| 442 | <Box |
| 443 | w={{ base: '100%', md: '256px' }} |
| 444 | minW={{ md: '256px' }} |
| 445 | p="20px" |
| 446 | borderRight={{ base: 'none', md: '1px solid #e2e8f0' }} |
| 447 | borderBottom={{ base: '1px solid #e2e8f0', md: 'none' }} |
| 448 | > |
| 449 | <InputGroup> |
| 450 | <InputLeftElement pointerEvents="none" h="36px"> |
| 451 | <Icon as={IoSearchOutline} boxSize="4" color="#999" /> |
| 452 | </InputLeftElement> |
| 453 | <Input |
| 454 | placeholder={__('Search templates', 'everest-forms')} |
| 455 | value={searchInputValue} |
| 456 | onChange={handleSearchInputChange} |
| 457 | fontSize="14px" |
| 458 | border="1px solid #e2e8f0" |
| 459 | borderRadius="8px" |
| 460 | h="36px" |
| 461 | pl="36px" |
| 462 | pr="12px" |
| 463 | _focus={{ |
| 464 | borderColor: '#7545BB', |
| 465 | outline: 'none', |
| 466 | boxShadow: 'none', |
| 467 | }} |
| 468 | _placeholder={{ color: '#999' }} |
| 469 | /> |
| 470 | </InputGroup> |
| 471 | </Box> |
| 472 | |
| 473 | {/* Heading + filter tabs */} |
| 474 | <Flex |
| 475 | flex="1" |
| 476 | align="center" |
| 477 | justify="space-between" |
| 478 | px={{ base: '20px', md: '28px' }} |
| 479 | py="20px" |
| 480 | wrap="wrap" |
| 481 | gap="12px" |
| 482 | > |
| 483 | <Heading |
| 484 | as="h2" |
| 485 | fontSize="20px" |
| 486 | fontWeight="500" |
| 487 | color="#0e0e0e" |
| 488 | m="0" |
| 489 | letterSpacing="-0.01em" |
| 490 | > |
| 491 | {__('Choose from Templates', 'everest-forms')} |
| 492 | </Heading> |
| 493 | |
| 494 | {/* Refetch button + Filter tabs */} |
| 495 | <Flex align="center" gap="10px"> |
| 496 | <Box |
| 497 | as="button" |
| 498 | display="inline-flex" |
| 499 | alignItems="center" |
| 500 | gap="6px" |
| 501 | px="10px" |
| 502 | py="6px" |
| 503 | borderRadius="8px" |
| 504 | border="1px solid #e2e8f0" |
| 505 | bg="white" |
| 506 | fontSize="12px" |
| 507 | fontWeight="500" |
| 508 | color="#6b6b6b" |
| 509 | cursor={isFetching ? 'not-allowed' : 'pointer'} |
| 510 | onClick={() => { if (!isFetching) refetch(); }} |
| 511 | _hover={{ borderColor: '#7545BB', color: '#7545BB' }} |
| 512 | transition="all 0.15s" |
| 513 | title={__('Refresh templates', 'everest-forms')} |
| 514 | > |
| 515 | <Icon |
| 516 | as={FiRefreshCw} |
| 517 | boxSize="12px" |
| 518 | sx={isFetching ? { animation: `${spin} 0.7s linear infinite` } : {}} |
| 519 | /> |
| 520 | {__('Refetch', 'everest-forms')} |
| 521 | </Box> |
| 522 | |
| 523 | <Tabs |
| 524 | variant="unstyled" |
| 525 | onChange={(index) => setFilter(filterLabels[index])} |
| 526 | > |
| 527 | <TabList |
| 528 | bg="#f1f5f9" |
| 529 | border="1px solid #e2e8f0" |
| 530 | borderRadius="8px" |
| 531 | p="4px" |
| 532 | gap="0" |
| 533 | > |
| 534 | {filterLabels.map((label) => ( |
| 535 | <Tab |
| 536 | key={label} |
| 537 | px="12px" |
| 538 | py="6px" |
| 539 | borderRadius="6px" |
| 540 | fontSize="12px" |
| 541 | fontWeight="500" |
| 542 | color="#6b6b6b" |
| 543 | _selected={{ |
| 544 | bg: 'white', |
| 545 | color: '#7445ba', |
| 546 | boxShadow: '0 1px 3px rgba(0,0,0,0.08)', |
| 547 | }} |
| 548 | _hover={{ color: '#0e0e0e' }} |
| 549 | transition="all 0.15s" |
| 550 | > |
| 551 | {label} |
| 552 | </Tab> |
| 553 | ))} |
| 554 | </TabList> |
| 555 | </Tabs> |
| 556 | </Flex> |
| 557 | </Flex> |
| 558 | </Flex> |
| 559 | |
| 560 | {/* Sidebar + Template Grid */} |
| 561 | <Flex direction={{ base: 'column', md: 'row' }}> |
| 562 | {/* Sidebar */} |
| 563 | <Box |
| 564 | w={{ base: '100%', md: '256px' }} |
| 565 | minW={{ md: '256px' }} |
| 566 | p="20px" |
| 567 | pt="12px" |
| 568 | borderRight={{ base: 'none', md: '1px solid #e2e8f0' }} |
| 569 | borderBottom={{ base: '1px solid #e2e8f0', md: 'none' }} |
| 570 | > |
| 571 | <Sidebar |
| 572 | categories={categories} |
| 573 | selectedCategory={state.selectedCategory} |
| 574 | onCategorySelect={handleCategorySelect} |
| 575 | onRequestTemplate={handleCreateWithAI} |
| 576 | /> |
| 577 | </Box> |
| 578 | |
| 579 | {/* Template grid */} |
| 580 | <Box |
| 581 | p={{ base: '20px', md: '28px' }} |
| 582 | pt="12px" |
| 583 | flex={1} |
| 584 | minW="0" |
| 585 | opacity={isFetching && !isLoading ? 0.55 : 1} |
| 586 | transition="opacity 0.25s ease" |
| 587 | pointerEvents={isFetching && !isLoading ? 'none' : 'auto'} |
| 588 | > |
| 589 | <TemplateList |
| 590 | selectedCategory={selectedCategory} |
| 591 | templates={filteredTemplates} |
| 592 | onCreateWithAI={handleCreateWithAI} |
| 593 | /> |
| 594 | </Box> |
| 595 | </Flex> |
| 596 | </Box> |
| 597 | </Box> |
| 598 | ); |
| 599 | }; |
| 600 | |
| 601 | export default Main; |
| 602 |