CreateFormCTA.tsx
2 months ago
CreateWithAI.tsx
5 days ago
Main.tsx
2 months ago
PluginStatus.tsx
2 months ago
Sidebar.tsx
2 months ago
TemplateList.tsx
5 days ago
TemplatesSkeleton.tsx
5 months ago
CreateWithAI.tsx
1997 lines
| 1 | import { |
| 2 | Box, |
| 3 | Flex, |
| 4 | HStack, |
| 5 | Heading, |
| 6 | Icon, |
| 7 | Modal, |
| 8 | ModalBody, |
| 9 | ModalCloseButton, |
| 10 | ModalContent, |
| 11 | ModalFooter, |
| 12 | ModalHeader, |
| 13 | ModalOverlay, |
| 14 | Popover, |
| 15 | PopoverArrow, |
| 16 | PopoverBody, |
| 17 | PopoverContent, |
| 18 | PopoverTrigger, |
| 19 | SimpleGrid, |
| 20 | Spinner, |
| 21 | Text, |
| 22 | Textarea, |
| 23 | Tooltip, |
| 24 | VStack, |
| 25 | keyframes, |
| 26 | useDisclosure, |
| 27 | useToast, |
| 28 | } from '@chakra-ui/react'; |
| 29 | import { __, _n, sprintf } from '@wordpress/i18n'; |
| 30 | import React, { useEffect, useState } from 'react'; |
| 31 | import { BsStars } from 'react-icons/bs'; |
| 32 | import { |
| 33 | FiArrowLeft, |
| 34 | FiArrowUp, |
| 35 | FiCheck, |
| 36 | FiEdit3, |
| 37 | FiRefreshCw, |
| 38 | } from 'react-icons/fi'; |
| 39 | import { templatesScriptData } from '../utils/global'; |
| 40 | |
| 41 | const pulseGlow = keyframes` |
| 42 | 0% { box-shadow: 0 0 0 0 rgba(117,69,187,0.3); transform: scale(1); } |
| 43 | 50% { box-shadow: 0 0 28px 10px rgba(117,69,187,0.12); transform: scale(1.06); } |
| 44 | 100% { box-shadow: 0 0 0 0 rgba(117,69,187,0.3); transform: scale(1); } |
| 45 | `; |
| 46 | |
| 47 | const dotBounce = keyframes` |
| 48 | 0%, 80%, 100% { transform: scale(0.35); opacity: 0.3; } |
| 49 | 40% { transform: scale(1); opacity: 1; } |
| 50 | `; |
| 51 | |
| 52 | const fadeUp = keyframes` |
| 53 | from { opacity: 0; transform: translateY(12px); } |
| 54 | to { opacity: 1; transform: translateY(0); } |
| 55 | `; |
| 56 | |
| 57 | const shimmer = keyframes` |
| 58 | 0% { background-position: -400px 0; } |
| 59 | 100% { background-position: 400px 0; } |
| 60 | `; |
| 61 | |
| 62 | const INSPIRATION_CARDS = [ |
| 63 | { |
| 64 | title: __('Online store checkout', 'everest-forms'), |
| 65 | description: __( |
| 66 | 'Product selection with prices, quantity, coupon code, order total, and payment gateway.', |
| 67 | 'everest-forms', |
| 68 | ), |
| 69 | prompt: |
| 70 | 'An online store checkout form with customer name and email, product selection as payment radio buttons with prices, quantity field, coupon code field, order subtotal display, order total display, shipping address, and a payment gateway section.', |
| 71 | }, |
| 72 | { |
| 73 | title: __('Subscription sign-up', 'everest-forms'), |
| 74 | description: __( |
| 75 | 'Subscription plan selection, billing details, and payment gateway for a recurring membership.', |
| 76 | 'everest-forms', |
| 77 | ), |
| 78 | prompt: |
| 79 | 'A subscription membership sign-up form with first name, last name, email, phone number, subscription plan selection with monthly and annual pricing options, billing address, and payment gateway fields.', |
| 80 | }, |
| 81 | { |
| 82 | title: __('Event ticket purchase', 'everest-forms'), |
| 83 | description: __( |
| 84 | 'Ticket type with pricing, quantity, coupon code, total, and payment gateway.', |
| 85 | 'everest-forms', |
| 86 | ), |
| 87 | prompt: |
| 88 | 'An event ticket purchase form with attendee name and email, ticket type as payment radio buttons with prices (general admission, VIP, student), ticket quantity field, coupon code field, order subtotal and total display, and payment gateway fields.', |
| 89 | }, |
| 90 | { |
| 91 | title: __('Freelance contract & NDA', 'everest-forms'), |
| 92 | description: __( |
| 93 | 'Client details, project scope, agreed rate, contract terms, and digital signature.', |
| 94 | 'everest-forms', |
| 95 | ), |
| 96 | prompt: |
| 97 | 'A freelance contract and NDA form with client name, company, email, project title text field, project scope textarea, deliverables textarea, agreed rate number field, project start date, contract terms and conditions textarea, and a digital signature field for client approval.', |
| 98 | }, |
| 99 | { |
| 100 | title: __('Health & wellness check-in', 'everest-forms'), |
| 101 | description: __( |
| 102 | 'Pain, energy, and stress levels via range sliders, symptoms checklist, and notes.', |
| 103 | 'everest-forms', |
| 104 | ), |
| 105 | prompt: |
| 106 | 'A daily health and wellness check-in form with patient name, date, pain level range slider (0 to 10), energy level range slider (0 to 10), stress level range slider (0 to 10), sleep hours number field, symptoms checkboxes (headache, fatigue, nausea, dizziness), and a notes textarea for additional observations.', |
| 107 | }, |
| 108 | { |
| 109 | title: __('User account registration', 'everest-forms'), |
| 110 | description: __( |
| 111 | 'Username, email, password with confirmation, profile photo, and preferences.', |
| 112 | 'everest-forms', |
| 113 | ), |
| 114 | prompt: |
| 115 | 'A user account registration form with first name, last name, username text field, email address, password field, confirm password field, profile photo file upload, date of birth, country dropdown, and a newsletter subscription checkbox.', |
| 116 | }, |
| 117 | ]; |
| 118 | |
| 119 | const GEN_STEPS = [ |
| 120 | __('Understanding your prompt', 'everest-forms'), |
| 121 | __('Designing field structure', 'everest-forms'), |
| 122 | __('Configuring validation & logic', 'everest-forms'), |
| 123 | __('Finalizing your form', 'everest-forms'), |
| 124 | ]; |
| 125 | |
| 126 | const MAX_CHARS = 500; |
| 127 | |
| 128 | // Matches Style Customizer v2's own `.pv-iframe { min-height: 480px }` (style.scss) — the two |
| 129 | // AI-preview surfaces should feel like the same feature, not a taller one and a cramped one. |
| 130 | const MIN_PREVIEW_HEIGHT = 480; |
| 131 | |
| 132 | // Style Customizer v2's PreviewPane forces its preview visible after a short deadline no matter |
| 133 | // what ("Force the reveal after a short deadline so the preview is ALWAYS shown" — PreviewPane.tsx) |
| 134 | // so a slow/blocked load never leaves the user staring at a skeleton forever. Mirrored here. |
| 135 | const PREVIEW_REVEAL_DEADLINE = 2500; |
| 136 | |
| 137 | const UPGRADE_URL = |
| 138 | 'https://everestforms.net/upgrade/?utm_source=evf-free&utm_medium=ai-form-builder&utm_campaign=ai-rate-limit&utm_content=Upgrade+to+Pro'; |
| 139 | |
| 140 | /** |
| 141 | * The "you've hit the daily cap" popover body — shared by the three limit-reached buttons |
| 142 | * below. Pro has its own (much higher) daily limit too, so a Pro user who hits it sees a |
| 143 | * plain "try again tomorrow" message instead of a nonsensical "upgrade to Pro" CTA. |
| 144 | */ |
| 145 | const LimitReachedBody: React.FC<{ tier: string }> = ({ tier }) => ( |
| 146 | <> |
| 147 | <Text fontSize="12px" color="#555" mb={2} lineHeight="1.5"> |
| 148 | {tier === 'pro' |
| 149 | ? __( |
| 150 | "You've reached today's request limit. It resets tomorrow.", |
| 151 | 'everest-forms', |
| 152 | ) |
| 153 | : __("You've reached your daily free limit.", 'everest-forms')} |
| 154 | </Text> |
| 155 | {tier !== 'pro' && ( |
| 156 | <Box |
| 157 | as="a" |
| 158 | href={UPGRADE_URL} |
| 159 | target="_blank" |
| 160 | rel="noopener noreferrer" |
| 161 | color="#7545BB" |
| 162 | fontSize="12px" |
| 163 | fontWeight="600" |
| 164 | _hover={{ textDecoration: 'underline' }} |
| 165 | > |
| 166 | {__('Upgrade to Pro →', 'everest-forms')} |
| 167 | </Box> |
| 168 | )} |
| 169 | </> |
| 170 | ); |
| 171 | |
| 172 | /** Daily-request usage snapshot the gateway now returns on every AI response. */ |
| 173 | interface UsageInfo { |
| 174 | remaining: number; |
| 175 | limit: number; |
| 176 | used: number; |
| 177 | } |
| 178 | |
| 179 | // At/below this many remaining requests the note switches to a gentle amber warning. |
| 180 | const USAGE_LOW_THRESHOLD = 3; |
| 181 | |
| 182 | /** Read the { remaining, limit, used } usage object off a raw AI response, or null. */ |
| 183 | const readUsage = (raw: any): UsageInfo | null => { |
| 184 | const u = raw && raw.usage; |
| 185 | if (u && typeof u.remaining === 'number') { |
| 186 | return { remaining: u.remaining, limit: u.limit, used: u.used }; |
| 187 | } |
| 188 | return null; |
| 189 | }; |
| 190 | |
| 191 | // Full tooltip label, e.g. "18 of 20 AI requests left today · resets daily". |
| 192 | const usageTooltip = (usage: UsageInfo): string => { |
| 193 | if (typeof usage.limit === 'number' && usage.limit > 0) { |
| 194 | return sprintf( |
| 195 | /* translators: 1: remaining requests, 2: daily limit. */ |
| 196 | __('%1$d of %2$d AI requests left today · resets daily', 'everest-forms'), |
| 197 | usage.remaining, |
| 198 | usage.limit, |
| 199 | ); |
| 200 | } |
| 201 | return sprintf( |
| 202 | _n( |
| 203 | '%d request left today', |
| 204 | '%d requests left today', |
| 205 | usage.remaining, |
| 206 | 'everest-forms', |
| 207 | ), |
| 208 | usage.remaining, |
| 209 | ); |
| 210 | }; |
| 211 | |
| 212 | /** |
| 213 | * The daily-request "credits" pill — a sparkle, an `18/20` count, and a slim meter that |
| 214 | * drains as requests are used. Muted purple normally, amber when low, red when exhausted. |
| 215 | * Shows a shimmer skeleton while the first count is still loading so it reserves its space. |
| 216 | */ |
| 217 | const CreditsPill: React.FC<{ usage: UsageInfo | null; loading?: boolean }> = ({ |
| 218 | usage, |
| 219 | loading, |
| 220 | }) => { |
| 221 | if (loading && !usage) { |
| 222 | return ( |
| 223 | <Box |
| 224 | h="26px" |
| 225 | w="76px" |
| 226 | borderRadius="full" |
| 227 | sx={{ |
| 228 | background: |
| 229 | 'linear-gradient(90deg, #eeeeef 25%, #e4e4e8 50%, #eeeeef 75%)', |
| 230 | backgroundSize: '400px 100%', |
| 231 | animation: `${shimmer} 1.6s ease-in-out infinite`, |
| 232 | }} |
| 233 | /> |
| 234 | ); |
| 235 | } |
| 236 | if (!usage) return null; |
| 237 | |
| 238 | const hasLimit = typeof usage.limit === 'number' && usage.limit > 0; |
| 239 | const { remaining } = usage; |
| 240 | const exhausted = remaining <= 0; |
| 241 | const low = remaining <= USAGE_LOW_THRESHOLD; |
| 242 | const frac = hasLimit ? Math.max(0, Math.min(1, remaining / usage.limit)) : 1; |
| 243 | |
| 244 | const c = exhausted |
| 245 | ? { bg: '#fef2f2', border: '#fecaca', num: '#dc2626', den: '#f5a3a3', track: '#fcdcdc', fill: '#dc2626', icon: '#dc2626' } |
| 246 | : low |
| 247 | ? { bg: '#fff7ed', border: '#fed7aa', num: '#b45309', den: '#d9a066', track: '#fde4c4', fill: '#f59e0b', icon: '#d97706' } |
| 248 | : { bg: '#f7f4fc', border: '#e9e2f3', num: '#7545BB', den: '#b3a3d6', track: '#e9e2f3', fill: '#7545BB', icon: '#7545BB' }; |
| 249 | |
| 250 | return ( |
| 251 | <Tooltip |
| 252 | label={usageTooltip(usage)} |
| 253 | placement="top" |
| 254 | hasArrow |
| 255 | bg="#1a1a1a" |
| 256 | color="white" |
| 257 | fontSize="12px" |
| 258 | borderRadius="6px" |
| 259 | px="10px" |
| 260 | py="6px" |
| 261 | openDelay={200} |
| 262 | > |
| 263 | <HStack |
| 264 | spacing="7px" |
| 265 | h="26px" |
| 266 | pl="9px" |
| 267 | pr="11px" |
| 268 | borderRadius="full" |
| 269 | bg={c.bg} |
| 270 | border="1px solid" |
| 271 | borderColor={c.border} |
| 272 | cursor="default" |
| 273 | userSelect="none" |
| 274 | transition="background 0.25s, border-color 0.25s" |
| 275 | > |
| 276 | <Icon as={BsStars} boxSize="11px" color={c.icon} /> |
| 277 | <HStack spacing="1px" align="baseline"> |
| 278 | <Text fontSize="12.5px" fontWeight="700" color={c.num} lineHeight="1" m="0"> |
| 279 | {remaining} |
| 280 | </Text> |
| 281 | {hasLimit && ( |
| 282 | <Text fontSize="12.5px" fontWeight="500" color={c.den} lineHeight="1" m="0"> |
| 283 | /{usage.limit} |
| 284 | </Text> |
| 285 | )} |
| 286 | </HStack> |
| 287 | {hasLimit && ( |
| 288 | <Box w="26px" h="4px" borderRadius="full" bg={c.track} overflow="hidden"> |
| 289 | <Box |
| 290 | h="100%" |
| 291 | borderRadius="full" |
| 292 | bg={c.fill} |
| 293 | w={`${Math.round(frac * 100)}%`} |
| 294 | transition="width 0.45s ease" |
| 295 | /> |
| 296 | </Box> |
| 297 | )} |
| 298 | </HStack> |
| 299 | </Tooltip> |
| 300 | ); |
| 301 | }; |
| 302 | |
| 303 | const SkeletonField: React.FC<{ delay?: string }> = ({ delay = '0s' }) => ( |
| 304 | <Box sx={{ animation: `${fadeUp} 0.4s ease ${delay} both` }}> |
| 305 | <Box |
| 306 | h="12px" |
| 307 | w="30%" |
| 308 | mb="8px" |
| 309 | borderRadius="4px" |
| 310 | sx={{ |
| 311 | background: |
| 312 | 'linear-gradient(90deg, #eeeeef 25%, #e4e4e8 50%, #eeeeef 75%)', |
| 313 | backgroundSize: '400px 100%', |
| 314 | animation: `${shimmer} 1.6s ease-in-out infinite`, |
| 315 | }} |
| 316 | /> |
| 317 | <Box |
| 318 | h="36px" |
| 319 | borderRadius="6px" |
| 320 | sx={{ |
| 321 | background: |
| 322 | 'linear-gradient(90deg, #eeeeef 25%, #e4e4e8 50%, #eeeeef 75%)', |
| 323 | backgroundSize: '400px 100%', |
| 324 | animation: `${shimmer} 1.6s ease-in-out infinite`, |
| 325 | }} |
| 326 | /> |
| 327 | </Box> |
| 328 | ); |
| 329 | |
| 330 | const PageShell: React.FC<{ |
| 331 | onBack: () => void; |
| 332 | backLabel?: string; |
| 333 | headerRight?: React.ReactNode; |
| 334 | children: React.ReactNode; |
| 335 | }> = ({ onBack, backLabel, headerRight, children }) => { |
| 336 | useEffect(() => { |
| 337 | const prev = document.body.style.overflow; |
| 338 | document.body.style.overflow = 'hidden'; |
| 339 | return () => { |
| 340 | document.body.style.overflow = prev; |
| 341 | }; |
| 342 | }, []); |
| 343 | return ( |
| 344 | <Flex |
| 345 | position="fixed" |
| 346 | top="0" |
| 347 | left="0" |
| 348 | right="0" |
| 349 | bottom="0" |
| 350 | zIndex={100000} |
| 351 | direction="column" |
| 352 | overflow="hidden" |
| 353 | bg="#f6f6f8" |
| 354 | > |
| 355 | <Box h="4px" bg="#7545BB" flexShrink={0} /> |
| 356 | <Flex |
| 357 | as="header" |
| 358 | align="center" |
| 359 | h="56px" |
| 360 | bg="white" |
| 361 | borderBottom="1px solid #e2e8f0" |
| 362 | px="24px" |
| 363 | flexShrink={0} |
| 364 | > |
| 365 | <HStack spacing="12px" flex="1"> |
| 366 | <Box |
| 367 | as="button" |
| 368 | w="32px" |
| 369 | h="32px" |
| 370 | display="inline-flex" |
| 371 | alignItems="center" |
| 372 | justifyContent="center" |
| 373 | borderRadius="8px" |
| 374 | color="#383838" |
| 375 | bg="transparent" |
| 376 | border="none" |
| 377 | cursor="pointer" |
| 378 | onClick={onBack} |
| 379 | _hover={{ bg: '#f8fafc' }} |
| 380 | transition="background 0.2s" |
| 381 | > |
| 382 | <Icon as={FiArrowLeft} boxSize="4" /> |
| 383 | </Box> |
| 384 | <Heading |
| 385 | as="h1" |
| 386 | fontSize="16px" |
| 387 | fontWeight="500" |
| 388 | color="#0e0e0e" |
| 389 | m="0" |
| 390 | > |
| 391 | {backLabel || __('Create with AI', 'everest-forms')} |
| 392 | </Heading> |
| 393 | </HStack> |
| 394 | {headerRight} |
| 395 | </Flex> |
| 396 | <Box flex={1} overflowY="auto" display="flex" flexDirection="column"> |
| 397 | {children} |
| 398 | </Box> |
| 399 | </Flex> |
| 400 | ); |
| 401 | }; |
| 402 | |
| 403 | interface CreateWithAIProps { |
| 404 | onBack: () => void; |
| 405 | initialFormId?: number; |
| 406 | initialTitle?: string; |
| 407 | } |
| 408 | |
| 409 | interface ChatMessage { |
| 410 | role: 'user' | 'assistant'; |
| 411 | text: string; |
| 412 | loading?: boolean; |
| 413 | error?: boolean; |
| 414 | notice?: boolean; |
| 415 | noticeUrl?: string; |
| 416 | } |
| 417 | |
| 418 | const { homeUrl, ajaxUrl, aiNonce } = templatesScriptData; |
| 419 | |
| 420 | /** Normalize a same-site URL to the CURRENT window's exact origin (protocol + host + port), |
| 421 | * keeping only the path/query — mirrors src/style-customizer-v2/PreviewPane.tsx's |
| 422 | * `previewSrc`. Without this, a proxy/alias/scheme mismatch between the admin page's |
| 423 | * actual origin and what home_url() computes server-side can make the iframe genuinely |
| 424 | * cross-origin (or trigger a mixed-content block), rendering it blank. */ |
| 425 | const sameOriginUrl = (raw: string): string => { |
| 426 | try { |
| 427 | const url = new URL(raw, window.location.href); |
| 428 | url.protocol = window.location.protocol; |
| 429 | url.host = window.location.host; |
| 430 | return url.href; |
| 431 | } catch (e) { |
| 432 | return raw; |
| 433 | } |
| 434 | }; |
| 435 | |
| 436 | const callAi = async ( |
| 437 | action: string, |
| 438 | data: Record<string, string | number> = {}, |
| 439 | ): Promise<any> => { |
| 440 | const body = new URLSearchParams(); |
| 441 | body.append('action', action); |
| 442 | body.append('nonce', aiNonce); |
| 443 | Object.entries(data).forEach(([key, value]) => |
| 444 | body.append(key, String(value)), |
| 445 | ); |
| 446 | |
| 447 | const response = await fetch(ajaxUrl, { |
| 448 | method: 'POST', |
| 449 | credentials: 'same-origin', |
| 450 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 451 | body: body.toString(), |
| 452 | }); |
| 453 | |
| 454 | return response.json(); |
| 455 | }; |
| 456 | |
| 457 | const CreateWithAI: React.FC<CreateWithAIProps> = ({ |
| 458 | onBack, |
| 459 | initialFormId, |
| 460 | initialTitle, |
| 461 | }) => { |
| 462 | const toast = useToast(); |
| 463 | const [prompt, setPrompt] = useState(''); |
| 464 | const [isRateLimited, setIsRateLimited] = useState(false); |
| 465 | // Which tier hit the daily cap — a Pro user has their own (much higher) limit and |
| 466 | // shouldn't be told to "upgrade to Pro" when they're already on it. |
| 467 | const [limitTier, setLimitTier] = useState('free'); |
| 468 | // Daily-request usage snapshot — drives the "credits" pill. Seeded on mount from |
| 469 | // evf_ai_get_usage so it's visible before the first generate, then refreshed from every |
| 470 | // generate/update response. |
| 471 | const [usage, setUsage] = useState<UsageInfo | null>(null); |
| 472 | const [usageLoading, setUsageLoading] = useState(true); |
| 473 | const [genState, setGenState] = useState<'idle' | 'generating' | 'generated'>( |
| 474 | 'idle', |
| 475 | ); |
| 476 | const [genStep, setGenStep] = useState(-1); |
| 477 | const [hint, setHint] = useState({ show: false, x: 0, y: 0 }); |
| 478 | const [isRegenerating, setIsRegenerating] = useState(false); |
| 479 | const [isCreatingForm, setIsCreatingForm] = useState(false); |
| 480 | // Real front-end preview (iframe of ?form_id=&evf_preview=true) — bumping the key forces a |
| 481 | // fresh remount/reload after every successful generate/refine, since the src string itself |
| 482 | // doesn't change. See render of the "generated" screen below for the iframe + overlay. |
| 483 | const [previewReloadKey, setPreviewReloadKey] = useState(0); |
| 484 | const [isPreviewLoading, setIsPreviewLoading] = useState(false); |
| 485 | // Auto-sized to the iframe's actual content height (see onLoad below) so the WHOLE |
| 486 | // form is visible and the surrounding page scrolls normally — no inner iframe |
| 487 | // scrollbar, which is easy to miss/awkward to use nested inside the admin page. |
| 488 | const [previewHeight, setPreviewHeight] = useState(MIN_PREVIEW_HEIGHT); |
| 489 | const previewResizeObserverRef = React.useRef<ResizeObserver | null>(null); |
| 490 | const [formId, setFormId] = useState(0); |
| 491 | const [formTitle, setFormTitle] = useState(initialTitle || ''); |
| 492 | const [editUrl, setEditUrl] = useState(''); |
| 493 | const [refinePrompt, setRefinePrompt] = useState(''); |
| 494 | const [messages, setMessages] = useState<ChatMessage[]>([]); |
| 495 | const { |
| 496 | isOpen: isDiscardOpen, |
| 497 | onOpen: openDiscardModal, |
| 498 | onClose: closeDiscardModal, |
| 499 | } = useDisclosure(); |
| 500 | const [isDiscarding, setIsDiscarding] = useState(false); |
| 501 | const [isFreshDraft, setIsFreshDraft] = useState(false); |
| 502 | const previewHintTimer = React.useRef<ReturnType<typeof setTimeout> | null>( |
| 503 | null, |
| 504 | ); |
| 505 | const aiResponseRef = React.useRef<any>(null); |
| 506 | const promptInputRef = React.useRef<HTMLTextAreaElement | null>(null); |
| 507 | |
| 508 | useEffect(() => { |
| 509 | return () => previewResizeObserverRef.current?.disconnect(); |
| 510 | }, []); |
| 511 | |
| 512 | // Seed the credits pill on mount so it's visible before the first generate. Best-effort — |
| 513 | // on an unregistered/local site this just fails quietly and the pill stays hidden. |
| 514 | useEffect(() => { |
| 515 | let cancelled = false; |
| 516 | callAi('evf_ai_get_usage') |
| 517 | .then((res) => { |
| 518 | if (cancelled) return; |
| 519 | const u = readUsage(res?.data); |
| 520 | if (u) setUsage(u); |
| 521 | }) |
| 522 | .catch(() => {}) |
| 523 | .finally(() => { |
| 524 | if (!cancelled) setUsageLoading(false); |
| 525 | }); |
| 526 | return () => { |
| 527 | cancelled = true; |
| 528 | }; |
| 529 | }, []); |
| 530 | |
| 531 | // Never leave the user staring at the skeleton forever if the iframe's `load` event is slow |
| 532 | // or never fires (a security/caching plugin blocking the preview route, a very slow theme) — |
| 533 | // mirrors PreviewPane.tsx's identical "force the reveal after a short deadline" safety net. |
| 534 | useEffect(() => { |
| 535 | if (!isPreviewLoading) return; |
| 536 | const t = setTimeout( |
| 537 | () => setIsPreviewLoading(false), |
| 538 | PREVIEW_REVEAL_DEADLINE, |
| 539 | ); |
| 540 | return () => clearTimeout(t); |
| 541 | }, [isPreviewLoading, previewReloadKey]); |
| 542 | |
| 543 | useEffect(() => { |
| 544 | if (genState !== 'idle') return; |
| 545 | const t = setTimeout(() => promptInputRef.current?.focus(), 50); |
| 546 | return () => clearTimeout(t); |
| 547 | }, [genState]); |
| 548 | |
| 549 | useEffect(() => { |
| 550 | if (typeof initialFormId !== 'number' || initialFormId <= 0) return; |
| 551 | setFormId(initialFormId); |
| 552 | setPrompt(initialTitle || __('this form', 'everest-forms')); |
| 553 | setMessages([ |
| 554 | { |
| 555 | role: 'assistant', |
| 556 | text: initialTitle |
| 557 | ? /* translators: %s: template name. */ |
| 558 | sprintf( |
| 559 | __( |
| 560 | 'Loaded the “%s” template. Tell me what to change — add fields, reword labels, or anything else.', |
| 561 | 'everest-forms', |
| 562 | ), |
| 563 | initialTitle, |
| 564 | ) |
| 565 | : __('Loaded your form. Tell me what to change.', 'everest-forms'), |
| 566 | }, |
| 567 | ]); |
| 568 | setGenState('generated'); |
| 569 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 570 | }, [initialFormId]); |
| 571 | |
| 572 | const handleUseThisForm = async () => { |
| 573 | if (isCreatingForm || !formId) return; |
| 574 | setIsCreatingForm(true); |
| 575 | try { |
| 576 | const res = await callAi('evf_ai_activate_form', { form_id: formId }); |
| 577 | |
| 578 | if (res?.success && res.data?.edit_url) { |
| 579 | window.location.href = res.data.edit_url; |
| 580 | } else if (editUrl) { |
| 581 | window.location.href = editUrl; |
| 582 | } else { |
| 583 | throw new Error(res?.data?.message || 'Unexpected response'); |
| 584 | } |
| 585 | } catch (e: any) { |
| 586 | setIsCreatingForm(false); |
| 587 | toast({ |
| 588 | title: __('Error', 'everest-forms'), |
| 589 | description: |
| 590 | e?.message || |
| 591 | __('Could not open the form. Please try again.', 'everest-forms'), |
| 592 | status: 'error', |
| 593 | position: 'bottom', |
| 594 | duration: 5000, |
| 595 | isClosable: true, |
| 596 | variant: 'subtle', |
| 597 | }); |
| 598 | } |
| 599 | }; |
| 600 | |
| 601 | const resetToNewPrompt = () => { |
| 602 | setGenState('idle'); |
| 603 | setFormId(0); |
| 604 | setIsFreshDraft(false); |
| 605 | setFormTitle(''); |
| 606 | setEditUrl(''); |
| 607 | setMessages([]); |
| 608 | setPrompt(''); |
| 609 | setRefinePrompt(''); |
| 610 | }; |
| 611 | |
| 612 | const handleBackToNewPrompt = () => { |
| 613 | if (isFreshDraft && formId) { |
| 614 | openDiscardModal(); |
| 615 | } else { |
| 616 | resetToNewPrompt(); |
| 617 | } |
| 618 | }; |
| 619 | |
| 620 | const handleKeepAsDraft = () => { |
| 621 | closeDiscardModal(); |
| 622 | resetToNewPrompt(); |
| 623 | }; |
| 624 | |
| 625 | const handleDiscardConfirm = async () => { |
| 626 | if (!formId || isDiscarding) return; |
| 627 | setIsDiscarding(true); |
| 628 | try { |
| 629 | await callAi('evf_ai_discard_form', { form_id: formId }); |
| 630 | } catch (e) { |
| 631 | } finally { |
| 632 | setIsDiscarding(false); |
| 633 | closeDiscardModal(); |
| 634 | resetToNewPrompt(); |
| 635 | } |
| 636 | }; |
| 637 | |
| 638 | const resolveLoading = ( |
| 639 | text: string, |
| 640 | isError = false, |
| 641 | isNotice = false, |
| 642 | noticeUrl = '', |
| 643 | ) => |
| 644 | setMessages((m) => { |
| 645 | const next = [...m]; |
| 646 | for (let i = next.length - 1; i >= 0; i--) { |
| 647 | if (next[i].role === 'assistant' && next[i].loading) { |
| 648 | next[i] = { |
| 649 | role: 'assistant', |
| 650 | text, |
| 651 | error: isError, |
| 652 | notice: isNotice, |
| 653 | noticeUrl, |
| 654 | }; |
| 655 | break; |
| 656 | } |
| 657 | } |
| 658 | return next; |
| 659 | }); |
| 660 | |
| 661 | const handleUpdate = async (refinePromptText: string = '') => { |
| 662 | if (isRegenerating || !formId || !prompt.trim()) return; |
| 663 | const refine = (refinePromptText || '').trim(); |
| 664 | const userText = refine || __('Regenerate the form', 'everest-forms'); |
| 665 | |
| 666 | setMessages((m) => [ |
| 667 | ...m, |
| 668 | { role: 'user', text: userText }, |
| 669 | { role: 'assistant', text: '', loading: true }, |
| 670 | ]); |
| 671 | setRefinePrompt(''); |
| 672 | setIsRegenerating(true); |
| 673 | try { |
| 674 | const res = await callAi('evf_ai_update_form', { |
| 675 | form_id: formId, |
| 676 | prompt, |
| 677 | refine_prompt: refine, |
| 678 | }); |
| 679 | const usageSnapshot = readUsage(res?.data); |
| 680 | if (usageSnapshot) setUsage(usageSnapshot); |
| 681 | if (res?.success) { |
| 682 | if (res.data?.form_title) setFormTitle(res.data.form_title); |
| 683 | const notice = res.data?.notice || ''; |
| 684 | const noticeUrl = res.data?.notice_url || ''; |
| 685 | const doneText = refine |
| 686 | ? __( |
| 687 | "Done — I've updated your form. Check the preview on the right.", |
| 688 | 'everest-forms', |
| 689 | ) |
| 690 | : __("Here's a fresh version of your form.", 'everest-forms'); |
| 691 | |
| 692 | if (notice && !messages.some((m) => m.notice && m.text === notice)) { |
| 693 | setMessages((m) => { |
| 694 | const next = [...m]; |
| 695 | for (let i = next.length - 1; i >= 0; i--) { |
| 696 | if (next[i].role === 'assistant' && next[i].loading) { |
| 697 | next[i] = { role: 'assistant', text: doneText }; |
| 698 | break; |
| 699 | } |
| 700 | } |
| 701 | return [ |
| 702 | ...next, |
| 703 | { role: 'assistant', text: notice, notice: true, noticeUrl }, |
| 704 | ]; |
| 705 | }); |
| 706 | } else { |
| 707 | resolveLoading(doneText, false, false, ''); |
| 708 | } |
| 709 | setIsPreviewLoading(true); |
| 710 | setPreviewReloadKey((k) => k + 1); |
| 711 | } else { |
| 712 | if (res?.data?.code === 'daily_limit_reached') { |
| 713 | setIsRateLimited(true); |
| 714 | setLimitTier(res?.data?.tier || 'free'); |
| 715 | } |
| 716 | throw new Error( |
| 717 | res?.data?.message || |
| 718 | __('Could not update the form. Please try again.', 'everest-forms'), |
| 719 | ); |
| 720 | } |
| 721 | } catch (e: any) { |
| 722 | const message = |
| 723 | e?.message || |
| 724 | __('Could not update the form. Please try again.', 'everest-forms'); |
| 725 | resolveLoading(message, true); |
| 726 | } finally { |
| 727 | setIsRegenerating(false); |
| 728 | } |
| 729 | }; |
| 730 | |
| 731 | const handleRegenerate = () => handleUpdate(); |
| 732 | |
| 733 | const handleFieldClick = (e: React.MouseEvent) => { |
| 734 | const { clientX, clientY } = e; |
| 735 | setHint({ show: true, x: clientX, y: clientY }); |
| 736 | if (previewHintTimer.current) clearTimeout(previewHintTimer.current); |
| 737 | previewHintTimer.current = setTimeout( |
| 738 | () => setHint((h) => ({ ...h, show: false })), |
| 739 | 2600, |
| 740 | ); |
| 741 | }; |
| 742 | |
| 743 | const hasPrompt = prompt.trim().length > 0; |
| 744 | |
| 745 | useEffect(() => { |
| 746 | if (genState !== 'generating') return; |
| 747 | setGenStep(-1); |
| 748 | aiResponseRef.current = null; |
| 749 | |
| 750 | let cancelled = false; |
| 751 | let intervalId: ReturnType<typeof setInterval> | null = null; |
| 752 | |
| 753 | const showError = (message: string, isRateLimit = false, tier = 'free') => { |
| 754 | if (cancelled) return; |
| 755 | if (intervalId) clearInterval(intervalId); |
| 756 | if (isRateLimit) { |
| 757 | setIsRateLimited(true); |
| 758 | setLimitTier(tier); |
| 759 | } |
| 760 | setGenState('idle'); |
| 761 | toast({ |
| 762 | title: isRateLimit |
| 763 | ? __('Daily limit reached', 'everest-forms') |
| 764 | : __('AI generation failed', 'everest-forms'), |
| 765 | description: isRateLimit ? ( |
| 766 | <Box> |
| 767 | <Text mb={1}>{message}</Text> |
| 768 | {tier !== 'pro' && ( |
| 769 | <Box |
| 770 | as="a" |
| 771 | href={UPGRADE_URL} |
| 772 | target="_blank" |
| 773 | rel="noopener noreferrer" |
| 774 | fontWeight="600" |
| 775 | textDecoration="underline" |
| 776 | _hover={{ opacity: 0.8 }} |
| 777 | > |
| 778 | {__('Upgrade to Pro →', 'everest-forms')} |
| 779 | </Box> |
| 780 | )} |
| 781 | </Box> |
| 782 | ) : ( |
| 783 | message |
| 784 | ), |
| 785 | status: 'error', |
| 786 | position: 'bottom', |
| 787 | duration: 5000, |
| 788 | isClosable: true, |
| 789 | variant: 'subtle', |
| 790 | }); |
| 791 | }; |
| 792 | |
| 793 | callAi('evf_ai_generate_form', { prompt }) |
| 794 | .then((res: any) => { |
| 795 | if (!cancelled) { |
| 796 | const usageSnapshot = readUsage(res?.data); |
| 797 | if (usageSnapshot) setUsage(usageSnapshot); |
| 798 | } |
| 799 | if (res?.success && res.data?.form_id) { |
| 800 | aiResponseRef.current = { |
| 801 | ok: true, |
| 802 | formId: res.data.form_id, |
| 803 | formTitle: res.data.form_title || '', |
| 804 | editUrl: res.data.edit_url || '', |
| 805 | notice: res.data.notice || '', |
| 806 | noticeUrl: res.data.notice_url || '', |
| 807 | }; |
| 808 | } else { |
| 809 | showError( |
| 810 | res?.data?.message || |
| 811 | __('Something went wrong. Please try again.', 'everest-forms'), |
| 812 | res?.data?.code === 'daily_limit_reached', |
| 813 | res?.data?.tier || 'free', |
| 814 | ); |
| 815 | } |
| 816 | }) |
| 817 | .catch(() => { |
| 818 | showError( |
| 819 | __( |
| 820 | 'Could not reach the AI service. Please try again.', |
| 821 | 'everest-forms', |
| 822 | ), |
| 823 | ); |
| 824 | }); |
| 825 | |
| 826 | let step = -1; |
| 827 | intervalId = setInterval(() => { |
| 828 | step += 1; |
| 829 | setGenStep(step); |
| 830 | if (step >= GEN_STEPS.length - 1) { |
| 831 | if (intervalId) clearInterval(intervalId); |
| 832 | const finish = () => { |
| 833 | if (cancelled) return; |
| 834 | const result = aiResponseRef.current; |
| 835 | if (!result) { |
| 836 | setTimeout(finish, 200); |
| 837 | return; |
| 838 | } |
| 839 | if (result.ok) { |
| 840 | setFormId(result.formId); |
| 841 | setIsFreshDraft(true); |
| 842 | setFormTitle(result.formTitle || ''); |
| 843 | setEditUrl(result.editUrl); |
| 844 | setIsPreviewLoading(true); |
| 845 | setPreviewReloadKey((k) => k + 1); |
| 846 | setMessages([ |
| 847 | { role: 'user', text: prompt }, |
| 848 | { |
| 849 | role: 'assistant', |
| 850 | text: |
| 851 | result.notice || |
| 852 | __( |
| 853 | 'Here\'s your form! Review the preview on the right and click "Use This Form" when you\'re happy with it.', |
| 854 | 'everest-forms', |
| 855 | ), |
| 856 | notice: !!result.notice, |
| 857 | noticeUrl: result.noticeUrl || '', |
| 858 | }, |
| 859 | ]); |
| 860 | setGenState('generated'); |
| 861 | } |
| 862 | }; |
| 863 | setTimeout(finish, 700); |
| 864 | } |
| 865 | }, 950); |
| 866 | |
| 867 | return () => { |
| 868 | cancelled = true; |
| 869 | if (intervalId) clearInterval(intervalId); |
| 870 | }; |
| 871 | }, [genState]); |
| 872 | |
| 873 | // Real front-end preview URL — assembled once formId is known; normalized to the |
| 874 | // current origin (see sameOriginUrl) and cache-busted by remounting on previewReloadKey |
| 875 | // change (the <iframe key={previewReloadKey}> below), not by varying this string. |
| 876 | const previewSrc = React.useMemo(() => { |
| 877 | if (!formId) return ''; |
| 878 | const url = new URL(homeUrl, window.location.href); |
| 879 | url.searchParams.set('form_id', String(formId)); |
| 880 | url.searchParams.set('evf_preview', 'true'); |
| 881 | // Embed mode — just the form, no toolbar/device-switcher/side panel (this screen |
| 882 | // already renders its own chrome around the iframe). See evf-form-preview-embed.php. |
| 883 | url.searchParams.set('evf_preview_mode', 'embed'); |
| 884 | return sameOriginUrl(url.href); |
| 885 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 886 | }, [formId]); |
| 887 | |
| 888 | const handleGenerate = () => { |
| 889 | if (!hasPrompt) return; |
| 890 | setGenState('generating'); |
| 891 | }; |
| 892 | |
| 893 | if (genState === 'generating') { |
| 894 | return ( |
| 895 | <PageShell onBack={onBack}> |
| 896 | <Flex |
| 897 | flex="1" |
| 898 | overflow="hidden" |
| 899 | sx={{ animation: `${fadeUp} 0.3s ease` }} |
| 900 | > |
| 901 | <Flex |
| 902 | w="480px" |
| 903 | flexShrink={0} |
| 904 | bg="white" |
| 905 | borderRight="1px solid #e2e8f0" |
| 906 | direction="column" |
| 907 | align="center" |
| 908 | justify="center" |
| 909 | px="36px" |
| 910 | py="40px" |
| 911 | gap="0" |
| 912 | > |
| 913 | <Box |
| 914 | w="60px" |
| 915 | h="60px" |
| 916 | borderRadius="full" |
| 917 | background="linear-gradient(135deg, #9c6de8 0%, #7545BB 100%)" |
| 918 | display="flex" |
| 919 | alignItems="center" |
| 920 | justifyContent="center" |
| 921 | mb="20px" |
| 922 | sx={{ animation: `${pulseGlow} 2s ease-in-out infinite` }} |
| 923 | > |
| 924 | <Icon as={BsStars} boxSize={6} color="white" /> |
| 925 | </Box> |
| 926 | |
| 927 | <VStack spacing="4px" textAlign="center" mb="24px"> |
| 928 | <Heading |
| 929 | as="h2" |
| 930 | fontSize="20px" |
| 931 | fontWeight="700" |
| 932 | color="#0e0e0e" |
| 933 | margin="0" |
| 934 | letterSpacing="-0.3px" |
| 935 | > |
| 936 | {__('Building your form…', 'everest-forms')} |
| 937 | </Heading> |
| 938 | <Text fontSize="13px" color="#9a9a9a" margin="0"> |
| 939 | {__('This usually takes a few seconds', 'everest-forms')} |
| 940 | </Text> |
| 941 | </VStack> |
| 942 | |
| 943 | <Box |
| 944 | bg="#faf9ff" |
| 945 | borderRadius="12px" |
| 946 | border="1px solid #ede8f8" |
| 947 | p="20px 24px" |
| 948 | width="100%" |
| 949 | mb="20px" |
| 950 | > |
| 951 | <VStack align="stretch" spacing="14px"> |
| 952 | {GEN_STEPS.map((step, i) => { |
| 953 | const isDone = i < genStep; |
| 954 | const isActive = i === genStep; |
| 955 | const isPending = i > genStep; |
| 956 | return ( |
| 957 | <HStack |
| 958 | key={i} |
| 959 | spacing="12px" |
| 960 | opacity={isPending ? 0.3 : 1} |
| 961 | transition="opacity 0.4s" |
| 962 | > |
| 963 | <Flex |
| 964 | w="20px" |
| 965 | h="20px" |
| 966 | borderRadius="full" |
| 967 | flexShrink={0} |
| 968 | bg={isDone ? '#7545BB' : 'transparent'} |
| 969 | border={isDone ? 'none' : '2px solid'} |
| 970 | borderColor={isActive ? '#7545BB' : '#ddd'} |
| 971 | align="center" |
| 972 | justify="center" |
| 973 | transition="all 0.35s" |
| 974 | > |
| 975 | {isDone && ( |
| 976 | <Icon |
| 977 | as={FiCheck} |
| 978 | color="white" |
| 979 | sx={{ width: '9px', height: '9px', strokeWidth: 3 }} |
| 980 | /> |
| 981 | )} |
| 982 | {isActive && ( |
| 983 | <Box |
| 984 | w="6px" |
| 985 | h="6px" |
| 986 | borderRadius="full" |
| 987 | bg="#7545BB" |
| 988 | sx={{ |
| 989 | animation: `${dotBounce} 1.1s ease-in-out infinite`, |
| 990 | }} |
| 991 | /> |
| 992 | )} |
| 993 | </Flex> |
| 994 | <Text |
| 995 | flex={1} |
| 996 | fontSize="13px" |
| 997 | margin="0" |
| 998 | fontWeight={isActive ? '600' : isDone ? '500' : '400'} |
| 999 | color={ |
| 1000 | isActive ? '#7545BB' : isDone ? '#1a1a1a' : '#c0c0cc' |
| 1001 | } |
| 1002 | transition="all 0.35s" |
| 1003 | > |
| 1004 | {step} |
| 1005 | </Text> |
| 1006 | {isActive && ( |
| 1007 | <HStack spacing="3px"> |
| 1008 | {[0, 1, 2].map((d) => ( |
| 1009 | <Box |
| 1010 | key={d} |
| 1011 | w="4px" |
| 1012 | h="4px" |
| 1013 | borderRadius="full" |
| 1014 | bg="#7545BB" |
| 1015 | sx={{ |
| 1016 | animation: `${dotBounce} 1.1s ease-in-out ${d * 0.18}s infinite`, |
| 1017 | }} |
| 1018 | /> |
| 1019 | ))} |
| 1020 | </HStack> |
| 1021 | )} |
| 1022 | </HStack> |
| 1023 | ); |
| 1024 | })} |
| 1025 | </VStack> |
| 1026 | </Box> |
| 1027 | </Flex> |
| 1028 | |
| 1029 | <Box flex={1} bg="#f6f6f8" overflowY="auto" p="24px"> |
| 1030 | <Box |
| 1031 | bg="white" |
| 1032 | border="1px solid #e2e8f0" |
| 1033 | borderRadius="16px" |
| 1034 | overflow="hidden" |
| 1035 | sx={{ animation: `${fadeUp} 0.4s ease 0.15s both` }} |
| 1036 | > |
| 1037 | <Flex |
| 1038 | align="center" |
| 1039 | px="24px" |
| 1040 | py="16px" |
| 1041 | borderBottom="1px solid #e2e8f0" |
| 1042 | gap="10px" |
| 1043 | > |
| 1044 | <Box |
| 1045 | w="32px" |
| 1046 | h="32px" |
| 1047 | borderRadius="8px" |
| 1048 | sx={{ |
| 1049 | background: |
| 1050 | 'linear-gradient(90deg, #eeeeef 25%, #e4e4e8 50%, #eeeeef 75%)', |
| 1051 | backgroundSize: '400px 100%', |
| 1052 | animation: `${shimmer} 1.6s ease-in-out infinite`, |
| 1053 | }} |
| 1054 | /> |
| 1055 | <Box |
| 1056 | h="14px" |
| 1057 | w="40%" |
| 1058 | borderRadius="4px" |
| 1059 | sx={{ |
| 1060 | background: |
| 1061 | 'linear-gradient(90deg, #eeeeef 25%, #e4e4e8 50%, #eeeeef 75%)', |
| 1062 | backgroundSize: '400px 100%', |
| 1063 | animation: `${shimmer} 1.6s ease-in-out infinite`, |
| 1064 | }} |
| 1065 | /> |
| 1066 | </Flex> |
| 1067 | <Box p="24px"> |
| 1068 | <VStack spacing="16px" align="stretch"> |
| 1069 | <SkeletonField delay="0.1s" /> |
| 1070 | <SkeletonField delay="0.22s" /> |
| 1071 | <SkeletonField delay="0.34s" /> |
| 1072 | <SkeletonField delay="0.46s" /> |
| 1073 | <SkeletonField delay="0.58s" /> |
| 1074 | </VStack> |
| 1075 | </Box> |
| 1076 | </Box> |
| 1077 | </Box> |
| 1078 | </Flex> |
| 1079 | </PageShell> |
| 1080 | ); |
| 1081 | } |
| 1082 | |
| 1083 | if (genState === 'generated') { |
| 1084 | let lastAssistantIdx = -1; |
| 1085 | let useThisFormIdx = -1; |
| 1086 | messages.forEach((m, i) => { |
| 1087 | if (m.role === 'assistant' && !m.loading) { |
| 1088 | lastAssistantIdx = i; |
| 1089 | if (!m.error) useThisFormIdx = i; |
| 1090 | } |
| 1091 | }); |
| 1092 | return ( |
| 1093 | <PageShell |
| 1094 | onBack={handleBackToNewPrompt} |
| 1095 | backLabel={__('New Prompt', 'everest-forms')} |
| 1096 | headerRight={ |
| 1097 | <Box |
| 1098 | as="button" |
| 1099 | display="inline-flex" |
| 1100 | alignItems="center" |
| 1101 | gap="6px" |
| 1102 | h="32px" |
| 1103 | px="14px" |
| 1104 | borderRadius="8px" |
| 1105 | border="none" |
| 1106 | bg={isCreatingForm ? '#9660db' : '#7545BB'} |
| 1107 | color="white" |
| 1108 | fontSize="13px" |
| 1109 | fontWeight="600" |
| 1110 | cursor={isCreatingForm ? 'not-allowed' : 'pointer'} |
| 1111 | opacity={isCreatingForm ? 0.85 : 1} |
| 1112 | onClick={handleUseThisForm} |
| 1113 | _hover={{ bg: isCreatingForm ? '#9660db' : '#6a3daa' }} |
| 1114 | transition="background 0.2s, opacity 0.2s" |
| 1115 | > |
| 1116 | {isCreatingForm && ( |
| 1117 | <Spinner size="xs" color="white" thickness="2px" speed="0.65s" /> |
| 1118 | )} |
| 1119 | {isCreatingForm |
| 1120 | ? __('Creating…', 'everest-forms') |
| 1121 | : __('Use This Form', 'everest-forms')} |
| 1122 | </Box> |
| 1123 | } |
| 1124 | > |
| 1125 | <Modal |
| 1126 | isCentered |
| 1127 | isOpen={isDiscardOpen} |
| 1128 | onClose={isDiscarding ? () => {} : closeDiscardModal} |
| 1129 | size="sm" |
| 1130 | > |
| 1131 | <ModalOverlay bg="rgba(0,0,0,0.4)" backdropFilter="blur(2px)" zIndex={200000} /> |
| 1132 | <ModalContent |
| 1133 | borderRadius="12px" |
| 1134 | mx="16px" |
| 1135 | containerProps={{ zIndex: 200000 }} |
| 1136 | > |
| 1137 | <ModalHeader fontSize="16px" fontWeight="600" pb="4px"> |
| 1138 | {__('Discard this draft?', 'everest-forms')} |
| 1139 | </ModalHeader> |
| 1140 | {!isDiscarding && <ModalCloseButton />} |
| 1141 | <ModalBody pb="20px"> |
| 1142 | <Text fontSize="14px" color="#555" lineHeight="1.6" margin="0"> |
| 1143 | {__( |
| 1144 | "This form hasn't been used yet — it's saved as a draft. You can keep it and find it later in All Forms, or discard it now.", |
| 1145 | 'everest-forms', |
| 1146 | )} |
| 1147 | </Text> |
| 1148 | </ModalBody> |
| 1149 | <ModalFooter gap="8px" pt="0"> |
| 1150 | <Box |
| 1151 | as="button" |
| 1152 | onClick={isDiscarding ? undefined : handleKeepAsDraft} |
| 1153 | px="14px" |
| 1154 | h="36px" |
| 1155 | borderRadius="8px" |
| 1156 | border="1px solid #e2e8f0" |
| 1157 | bg="white" |
| 1158 | fontSize="13px" |
| 1159 | fontWeight="600" |
| 1160 | color="#383838" |
| 1161 | cursor={isDiscarding ? 'not-allowed' : 'pointer'} |
| 1162 | opacity={isDiscarding ? 0.6 : 1} |
| 1163 | _hover={isDiscarding ? {} : { bg: '#f8fafc' }} |
| 1164 | transition="background 0.2s" |
| 1165 | > |
| 1166 | {__('Keep as draft', 'everest-forms')} |
| 1167 | </Box> |
| 1168 | <Box |
| 1169 | as="button" |
| 1170 | onClick={handleDiscardConfirm} |
| 1171 | display="inline-flex" |
| 1172 | alignItems="center" |
| 1173 | gap="6px" |
| 1174 | px="14px" |
| 1175 | h="36px" |
| 1176 | borderRadius="8px" |
| 1177 | border="none" |
| 1178 | bg="#e53e3e" |
| 1179 | color="white" |
| 1180 | fontSize="13px" |
| 1181 | fontWeight="600" |
| 1182 | cursor={isDiscarding ? 'not-allowed' : 'pointer'} |
| 1183 | opacity={isDiscarding ? 0.75 : 1} |
| 1184 | _hover={isDiscarding ? {} : { bg: '#c53030' }} |
| 1185 | transition="background 0.2s, opacity 0.2s" |
| 1186 | > |
| 1187 | {isDiscarding && ( |
| 1188 | <Spinner size="xs" color="white" thickness="2px" speed="0.65s" /> |
| 1189 | )} |
| 1190 | {isDiscarding |
| 1191 | ? __('Discarding…', 'everest-forms') |
| 1192 | : __('Discard', 'everest-forms')} |
| 1193 | </Box> |
| 1194 | </ModalFooter> |
| 1195 | </ModalContent> |
| 1196 | </Modal> |
| 1197 | |
| 1198 | {hint.show && ( |
| 1199 | <Box |
| 1200 | position="fixed" |
| 1201 | top={`${hint.y + 14}px`} |
| 1202 | left={`${hint.x}px`} |
| 1203 | transform="translateX(-50%)" |
| 1204 | bg="rgba(18,18,18,0.93)" |
| 1205 | color="white" |
| 1206 | borderRadius="8px" |
| 1207 | px="14px" |
| 1208 | py="10px" |
| 1209 | textAlign="center" |
| 1210 | pointerEvents="none" |
| 1211 | zIndex={9999} |
| 1212 | boxShadow="0 4px 20px rgba(0,0,0,0.2)" |
| 1213 | maxW="260px" |
| 1214 | sx={{ animation: `${fadeUp} 0.15s ease` }} |
| 1215 | > |
| 1216 | <Box |
| 1217 | position="absolute" |
| 1218 | top="-5px" |
| 1219 | left="50%" |
| 1220 | transform="translateX(-50%)" |
| 1221 | width="0" |
| 1222 | height="0" |
| 1223 | borderLeft="5px solid transparent" |
| 1224 | borderRight="5px solid transparent" |
| 1225 | borderBottom="5px solid rgba(18,18,18,0.93)" |
| 1226 | /> |
| 1227 | <Text |
| 1228 | fontSize="12px" |
| 1229 | fontWeight="600" |
| 1230 | margin="0 0 3px" |
| 1231 | lineHeight="1.45" |
| 1232 | > |
| 1233 | {__('This is just a preview of your form.', 'everest-forms')} |
| 1234 | </Text> |
| 1235 | <Text |
| 1236 | fontSize="12px" |
| 1237 | color="rgba(255,255,255,0.65)" |
| 1238 | margin="0" |
| 1239 | lineHeight="1.45" |
| 1240 | > |
| 1241 | {__('Click "Use This Form" to start editing.', 'everest-forms')} |
| 1242 | </Text> |
| 1243 | </Box> |
| 1244 | )} |
| 1245 | |
| 1246 | <Flex |
| 1247 | flex="1" |
| 1248 | overflow="hidden" |
| 1249 | sx={{ animation: `${fadeUp} 0.35s ease` }} |
| 1250 | > |
| 1251 | <Flex |
| 1252 | w="480px" |
| 1253 | flexShrink={0} |
| 1254 | bg="white" |
| 1255 | borderRight="1px solid #e2e8f0" |
| 1256 | direction="column" |
| 1257 | overflow="hidden" |
| 1258 | > |
| 1259 | <Box flex={1} p="20px" overflowY="auto"> |
| 1260 | {messages.map((msg, idx) => { |
| 1261 | if (msg.role === 'user') { |
| 1262 | return ( |
| 1263 | <Flex key={idx} justify="flex-end" mb="16px"> |
| 1264 | <Box |
| 1265 | bg="#7545BB" |
| 1266 | color="white" |
| 1267 | borderRadius="16px 16px 4px 16px" |
| 1268 | px="14px" |
| 1269 | py="10px" |
| 1270 | fontSize="14px" |
| 1271 | lineHeight="1.6" |
| 1272 | maxW="340px" |
| 1273 | boxShadow="0 2px 8px rgba(117,69,187,0.2)" |
| 1274 | > |
| 1275 | {msg.text} |
| 1276 | </Box> |
| 1277 | </Flex> |
| 1278 | ); |
| 1279 | } |
| 1280 | |
| 1281 | const isLastAssistant = idx === lastAssistantIdx; |
| 1282 | const isUseThisFormMsg = idx === useThisFormIdx; |
| 1283 | return ( |
| 1284 | <HStack key={idx} align="flex-start" spacing="10px" mb="16px"> |
| 1285 | <Flex |
| 1286 | w="28px" |
| 1287 | h="28px" |
| 1288 | borderRadius="full" |
| 1289 | bg="rgba(117,69,187,0.1)" |
| 1290 | align="center" |
| 1291 | justify="center" |
| 1292 | flexShrink={0} |
| 1293 | mt="2px" |
| 1294 | > |
| 1295 | <Icon as={BsStars} boxSize="13px" color="#7545BB" /> |
| 1296 | </Flex> |
| 1297 | |
| 1298 | <Box |
| 1299 | flex={1} |
| 1300 | bg={msg.error || msg.notice ? '#fff8f8' : '#faf9ff'} |
| 1301 | border={ |
| 1302 | msg.error || msg.notice |
| 1303 | ? '1px solid #fcd5d5' |
| 1304 | : '1px solid #ede8f8' |
| 1305 | } |
| 1306 | borderRadius="4px 16px 16px 16px" |
| 1307 | p="16px" |
| 1308 | boxShadow={ |
| 1309 | msg.error || msg.notice |
| 1310 | ? 'none' |
| 1311 | : '0 2px 10px rgba(117,69,187,0.06)' |
| 1312 | } |
| 1313 | > |
| 1314 | {msg.loading ? ( |
| 1315 | <HStack spacing="8px"> |
| 1316 | <Spinner |
| 1317 | size="xs" |
| 1318 | color="#7545BB" |
| 1319 | thickness="2px" |
| 1320 | speed="0.65s" |
| 1321 | /> |
| 1322 | <Text |
| 1323 | fontSize="14px" |
| 1324 | color="#7545BB" |
| 1325 | margin="0" |
| 1326 | fontWeight="500" |
| 1327 | > |
| 1328 | {__('Updating your form…', 'everest-forms')} |
| 1329 | </Text> |
| 1330 | </HStack> |
| 1331 | ) : ( |
| 1332 | <> |
| 1333 | <Text |
| 1334 | fontSize="14px" |
| 1335 | color={msg.error || msg.notice ? '#c0392b' : '#444'} |
| 1336 | lineHeight="1.65" |
| 1337 | margin={ |
| 1338 | isUseThisFormMsg |
| 1339 | ? '0 0 14px' |
| 1340 | : msg.noticeUrl |
| 1341 | ? '0 0 10px' |
| 1342 | : '0' |
| 1343 | } |
| 1344 | > |
| 1345 | {msg.text} |
| 1346 | </Text> |
| 1347 | |
| 1348 | {msg.noticeUrl && ( |
| 1349 | <Box |
| 1350 | as="a" |
| 1351 | href={msg.noticeUrl} |
| 1352 | target="_blank" |
| 1353 | rel="noopener noreferrer" |
| 1354 | display="inline-block" |
| 1355 | mb={isUseThisFormMsg ? '10px' : '0'} |
| 1356 | color="#7545BB" |
| 1357 | fontSize="12px" |
| 1358 | fontWeight="600" |
| 1359 | _hover={{ textDecoration: 'underline' }} |
| 1360 | > |
| 1361 | {__('Upgrade to Pro →', 'everest-forms')} |
| 1362 | </Box> |
| 1363 | )} |
| 1364 | |
| 1365 | {isUseThisFormMsg && ( |
| 1366 | <Box |
| 1367 | as="button" |
| 1368 | w="100%" |
| 1369 | display="flex" |
| 1370 | alignItems="center" |
| 1371 | justifyContent="center" |
| 1372 | gap="8px" |
| 1373 | h="36px" |
| 1374 | borderRadius="8px" |
| 1375 | bg={isCreatingForm ? '#9660db' : '#7545BB'} |
| 1376 | color="white" |
| 1377 | fontSize="13px" |
| 1378 | fontWeight="600" |
| 1379 | border="none" |
| 1380 | cursor={ |
| 1381 | isCreatingForm ? 'not-allowed' : 'pointer' |
| 1382 | } |
| 1383 | mb="12px" |
| 1384 | opacity={isCreatingForm ? 0.85 : 1} |
| 1385 | onClick={handleUseThisForm} |
| 1386 | _hover={{ |
| 1387 | bg: isCreatingForm ? '#9660db' : '#6a3daa', |
| 1388 | }} |
| 1389 | transition="background 0.2s, opacity 0.2s" |
| 1390 | > |
| 1391 | {isCreatingForm && ( |
| 1392 | <Spinner |
| 1393 | size="xs" |
| 1394 | color="white" |
| 1395 | thickness="2px" |
| 1396 | speed="0.65s" |
| 1397 | /> |
| 1398 | )} |
| 1399 | {isCreatingForm |
| 1400 | ? __('Creating…', 'everest-forms') |
| 1401 | : __('Use This Form', 'everest-forms')} |
| 1402 | </Box> |
| 1403 | )} |
| 1404 | |
| 1405 | <HStack justify="flex-end" pt="2px"> |
| 1406 | {isRateLimited ? ( |
| 1407 | <Popover trigger="hover" placement="top" isLazy> |
| 1408 | <PopoverTrigger> |
| 1409 | <HStack |
| 1410 | spacing="5px" |
| 1411 | cursor="not-allowed" |
| 1412 | opacity={0.5} |
| 1413 | > |
| 1414 | <Icon |
| 1415 | as={FiRefreshCw} |
| 1416 | boxSize="12px" |
| 1417 | color="#9ca3af" |
| 1418 | /> |
| 1419 | <Text |
| 1420 | fontSize="12px" |
| 1421 | color="#9ca3af" |
| 1422 | margin="0" |
| 1423 | fontWeight="500" |
| 1424 | > |
| 1425 | {__('Redo', 'everest-forms')} |
| 1426 | </Text> |
| 1427 | </HStack> |
| 1428 | </PopoverTrigger> |
| 1429 | <PopoverContent |
| 1430 | w="auto" |
| 1431 | maxW="220px" |
| 1432 | zIndex={200000} |
| 1433 | _focus={{ outline: 'none' }} |
| 1434 | > |
| 1435 | <PopoverArrow /> |
| 1436 | <PopoverBody p={3}> |
| 1437 | <LimitReachedBody tier={limitTier} /> |
| 1438 | </PopoverBody> |
| 1439 | </PopoverContent> |
| 1440 | </Popover> |
| 1441 | ) : ( |
| 1442 | <HStack |
| 1443 | spacing="5px" |
| 1444 | cursor={ |
| 1445 | isRegenerating ? 'not-allowed' : 'pointer' |
| 1446 | } |
| 1447 | opacity={isRegenerating ? 0.5 : 1} |
| 1448 | _hover={{ |
| 1449 | opacity: isRegenerating ? 0.5 : 0.65, |
| 1450 | }} |
| 1451 | onClick={ |
| 1452 | isRegenerating ? undefined : handleRegenerate |
| 1453 | } |
| 1454 | > |
| 1455 | {isRegenerating && isLastAssistant ? ( |
| 1456 | <Spinner |
| 1457 | size="xs" |
| 1458 | color="#9ca3af" |
| 1459 | thickness="2px" |
| 1460 | speed="0.65s" |
| 1461 | /> |
| 1462 | ) : ( |
| 1463 | <Icon |
| 1464 | as={FiRefreshCw} |
| 1465 | boxSize="12px" |
| 1466 | color="#9ca3af" |
| 1467 | /> |
| 1468 | )} |
| 1469 | <Text |
| 1470 | fontSize="12px" |
| 1471 | color="#9ca3af" |
| 1472 | margin="0" |
| 1473 | fontWeight="500" |
| 1474 | > |
| 1475 | {__('Redo', 'everest-forms')} |
| 1476 | </Text> |
| 1477 | </HStack> |
| 1478 | )} |
| 1479 | </HStack> |
| 1480 | </> |
| 1481 | )} |
| 1482 | </Box> |
| 1483 | </HStack> |
| 1484 | ); |
| 1485 | })} |
| 1486 | </Box> |
| 1487 | |
| 1488 | <Box borderTop="1px solid #e2e8f0" p="16px"> |
| 1489 | <Box |
| 1490 | border="1px solid #e2e8f0" |
| 1491 | borderRadius="12px" |
| 1492 | bg="white" |
| 1493 | overflow="hidden" |
| 1494 | transition="border-color 0.2s" |
| 1495 | _focusWithin={{ borderColor: '#7545BB' }} |
| 1496 | > |
| 1497 | <Textarea |
| 1498 | placeholder={__('Refine or follow up…', 'everest-forms')} |
| 1499 | border="none" |
| 1500 | fontSize="14px" |
| 1501 | color="#333" |
| 1502 | _focus={{ boxShadow: 'none' }} |
| 1503 | p="12px 16px" |
| 1504 | minHeight="56px" |
| 1505 | resize="none" |
| 1506 | _placeholder={{ color: '#c0c0cc' }} |
| 1507 | value={refinePrompt} |
| 1508 | isDisabled={isRegenerating} |
| 1509 | onChange={(e) => |
| 1510 | setRefinePrompt(e.target.value.slice(0, MAX_CHARS)) |
| 1511 | } |
| 1512 | onKeyDown={(e) => { |
| 1513 | if (e.key === 'Enter' && !e.shiftKey && !isRateLimited) { |
| 1514 | e.preventDefault(); |
| 1515 | handleUpdate(refinePrompt); |
| 1516 | } |
| 1517 | }} |
| 1518 | /> |
| 1519 | <Flex justify="flex-end" px="12px" pb="10px"> |
| 1520 | {isRateLimited ? ( |
| 1521 | <Popover trigger="hover" placement="top" isLazy> |
| 1522 | <PopoverTrigger> |
| 1523 | <Box |
| 1524 | as="button" |
| 1525 | w="28px" |
| 1526 | h="28px" |
| 1527 | bg="#c9bce4" |
| 1528 | borderRadius="6px" |
| 1529 | display="inline-flex" |
| 1530 | alignItems="center" |
| 1531 | justifyContent="center" |
| 1532 | border="none" |
| 1533 | cursor="not-allowed" |
| 1534 | transition="background 0.2s" |
| 1535 | > |
| 1536 | <Icon as={FiArrowUp} boxSize="3.5" color="white" /> |
| 1537 | </Box> |
| 1538 | </PopoverTrigger> |
| 1539 | <PopoverContent |
| 1540 | w="auto" |
| 1541 | maxW="220px" |
| 1542 | zIndex={200000} |
| 1543 | _focus={{ outline: 'none' }} |
| 1544 | > |
| 1545 | <PopoverArrow /> |
| 1546 | <PopoverBody p={3}> |
| 1547 | <LimitReachedBody tier={limitTier} /> |
| 1548 | </PopoverBody> |
| 1549 | </PopoverContent> |
| 1550 | </Popover> |
| 1551 | ) : ( |
| 1552 | <Box |
| 1553 | as="button" |
| 1554 | w="28px" |
| 1555 | h="28px" |
| 1556 | bg={ |
| 1557 | refinePrompt.trim() && !isRegenerating |
| 1558 | ? '#7545BB' |
| 1559 | : '#c9bce4' |
| 1560 | } |
| 1561 | borderRadius="6px" |
| 1562 | display="inline-flex" |
| 1563 | alignItems="center" |
| 1564 | justifyContent="center" |
| 1565 | border="none" |
| 1566 | cursor={ |
| 1567 | refinePrompt.trim() && !isRegenerating |
| 1568 | ? 'pointer' |
| 1569 | : 'not-allowed' |
| 1570 | } |
| 1571 | _hover={{ |
| 1572 | bg: |
| 1573 | refinePrompt.trim() && !isRegenerating |
| 1574 | ? '#6a3daa' |
| 1575 | : '#c9bce4', |
| 1576 | }} |
| 1577 | transition="background 0.2s" |
| 1578 | onClick={() => handleUpdate(refinePrompt)} |
| 1579 | > |
| 1580 | {isRegenerating ? ( |
| 1581 | <Spinner |
| 1582 | size="xs" |
| 1583 | color="white" |
| 1584 | thickness="2px" |
| 1585 | speed="0.65s" |
| 1586 | /> |
| 1587 | ) : ( |
| 1588 | <Icon as={FiArrowUp} boxSize="3.5" color="white" /> |
| 1589 | )} |
| 1590 | </Box> |
| 1591 | )} |
| 1592 | </Flex> |
| 1593 | </Box> |
| 1594 | {(usage || usageLoading) && ( |
| 1595 | <Flex justify="flex-end" mt="10px" pr="2px"> |
| 1596 | <CreditsPill usage={usage} loading={usageLoading} /> |
| 1597 | </Flex> |
| 1598 | )} |
| 1599 | </Box> |
| 1600 | </Flex> |
| 1601 | |
| 1602 | <Box |
| 1603 | flex={1} |
| 1604 | bg="#f6f6f8" |
| 1605 | display="flex" |
| 1606 | flexDirection="column" |
| 1607 | overflow="hidden" |
| 1608 | opacity={isRegenerating ? 0.5 : 1} |
| 1609 | transition="opacity 0.3s" |
| 1610 | > |
| 1611 | <Box flex={1} overflowY="auto" p="24px"> |
| 1612 | <Box |
| 1613 | bg="white" |
| 1614 | border="1px solid #e2e8f0" |
| 1615 | borderRadius="16px" |
| 1616 | overflow="hidden" |
| 1617 | mb="0" |
| 1618 | position="relative" |
| 1619 | transition="border-color 0.3s" |
| 1620 | > |
| 1621 | <Flex |
| 1622 | align="center" |
| 1623 | px="24px" |
| 1624 | py="16px" |
| 1625 | borderBottom="1px solid #e2e8f0" |
| 1626 | justify="space-between" |
| 1627 | > |
| 1628 | <HStack spacing="10px"> |
| 1629 | <Box |
| 1630 | w="32px" |
| 1631 | h="32px" |
| 1632 | bg="rgba(117,69,187,0.1)" |
| 1633 | borderRadius="8px" |
| 1634 | display="flex" |
| 1635 | alignItems="center" |
| 1636 | justifyContent="center" |
| 1637 | > |
| 1638 | <Icon as={FiEdit3} boxSize="15px" color="#7545BB" /> |
| 1639 | </Box> |
| 1640 | <Text |
| 1641 | fontSize="16px" |
| 1642 | fontWeight="600" |
| 1643 | color="#0e0e0e" |
| 1644 | margin="0" |
| 1645 | > |
| 1646 | {formTitle || __('AI Generated Form', 'everest-forms')} |
| 1647 | </Text> |
| 1648 | </HStack> |
| 1649 | {isRegenerating && ( |
| 1650 | <HStack |
| 1651 | spacing="5px" |
| 1652 | sx={{ animation: `${fadeUp} 0.2s ease` }} |
| 1653 | > |
| 1654 | {[0, 1, 2].map((d) => ( |
| 1655 | <Box |
| 1656 | key={d} |
| 1657 | w="5px" |
| 1658 | h="5px" |
| 1659 | borderRadius="full" |
| 1660 | bg="#7545BB" |
| 1661 | sx={{ |
| 1662 | animation: `${dotBounce} 1.1s ease-in-out ${d * 0.2}s infinite`, |
| 1663 | }} |
| 1664 | /> |
| 1665 | ))} |
| 1666 | <Text |
| 1667 | fontSize="11px" |
| 1668 | color="#7545BB" |
| 1669 | margin="0" |
| 1670 | fontWeight="500" |
| 1671 | > |
| 1672 | {__('Regenerating', 'everest-forms')} |
| 1673 | </Text> |
| 1674 | </HStack> |
| 1675 | )} |
| 1676 | </Flex> |
| 1677 | |
| 1678 | <Box |
| 1679 | position="relative" |
| 1680 | minH={`${MIN_PREVIEW_HEIGHT}px`} |
| 1681 | pointerEvents={ |
| 1682 | isRegenerating || isCreatingForm ? 'none' : 'auto' |
| 1683 | } |
| 1684 | > |
| 1685 | {isPreviewLoading && ( |
| 1686 | <Box |
| 1687 | position="absolute" |
| 1688 | inset="0" |
| 1689 | bg="white" |
| 1690 | zIndex={1} |
| 1691 | p="24px" |
| 1692 | > |
| 1693 | <VStack spacing="20px" align="stretch"> |
| 1694 | {Array.from({ length: 5 }).map((_, idx) => ( |
| 1695 | <SkeletonField key={idx} delay={`${idx * 0.1}s`} /> |
| 1696 | ))} |
| 1697 | </VStack> |
| 1698 | </Box> |
| 1699 | )} |
| 1700 | {previewSrc && ( |
| 1701 | <iframe |
| 1702 | key={previewReloadKey} |
| 1703 | src={previewSrc} |
| 1704 | title={__('Form preview', 'everest-forms')} |
| 1705 | onLoad={(e) => { |
| 1706 | setIsPreviewLoading(false); |
| 1707 | previewResizeObserverRef.current?.disconnect(); |
| 1708 | try { |
| 1709 | const doc = (e.target as HTMLIFrameElement) |
| 1710 | .contentDocument; |
| 1711 | if (!doc) return; |
| 1712 | const measure = () => |
| 1713 | setPreviewHeight( |
| 1714 | Math.max( |
| 1715 | MIN_PREVIEW_HEIGHT, |
| 1716 | doc.documentElement.scrollHeight, |
| 1717 | ), |
| 1718 | ); |
| 1719 | measure(); |
| 1720 | // Catch late layout shifts (web fonts, images, |
| 1721 | // conditional-logic field toggles) — same-origin, |
| 1722 | // so this is safe DOM access, not a postMessage bridge. |
| 1723 | const ro = new ResizeObserver(measure); |
| 1724 | ro.observe(doc.documentElement); |
| 1725 | previewResizeObserverRef.current = ro; |
| 1726 | } catch (err) { |
| 1727 | // Cross-origin or blocked — keep the default height. |
| 1728 | } |
| 1729 | }} |
| 1730 | style={{ |
| 1731 | display: 'block', |
| 1732 | width: '100%', |
| 1733 | height: `${previewHeight}px`, |
| 1734 | border: 'none', |
| 1735 | }} |
| 1736 | /> |
| 1737 | )} |
| 1738 | {/* Real front-end render — deliberately "look, don't touch": this |
| 1739 | overlay blocks interaction with the iframe's actual form inputs |
| 1740 | and shows the same "just a preview" tooltip clicking always did. */} |
| 1741 | <Box |
| 1742 | position="absolute" |
| 1743 | inset="0" |
| 1744 | onClick={handleFieldClick} |
| 1745 | cursor="default" |
| 1746 | /> |
| 1747 | </Box> |
| 1748 | </Box> |
| 1749 | </Box> |
| 1750 | </Box> |
| 1751 | </Flex> |
| 1752 | </PageShell> |
| 1753 | ); |
| 1754 | } |
| 1755 | |
| 1756 | return ( |
| 1757 | <PageShell onBack={onBack}> |
| 1758 | <Flex |
| 1759 | flex="1" |
| 1760 | direction="column" |
| 1761 | align="center" |
| 1762 | justify="center" |
| 1763 | px="24px" |
| 1764 | py="16px" |
| 1765 | > |
| 1766 | <Box width="100%" maxW="896px"> |
| 1767 | <VStack spacing="8px" mb="14px" textAlign="center"> |
| 1768 | <Heading |
| 1769 | as="h1" |
| 1770 | fontSize={{ base: '24px', md: '32px' }} |
| 1771 | fontWeight="600" |
| 1772 | color="#0e0e0e" |
| 1773 | lineHeight="1.15" |
| 1774 | m="0" |
| 1775 | letterSpacing="-0.02em" |
| 1776 | > |
| 1777 | {__('What should we build today?', 'everest-forms')} |
| 1778 | </Heading> |
| 1779 | <Text fontSize="15px" color="#6b6b6b" m="0" lineHeight="1.55"> |
| 1780 | {__( |
| 1781 | "Describe your form in your own words. We'll handle the fields, logic and layout — in seconds.", |
| 1782 | 'everest-forms', |
| 1783 | )} |
| 1784 | </Text> |
| 1785 | </VStack> |
| 1786 | |
| 1787 | <Box |
| 1788 | borderRadius="16px" |
| 1789 | border="1px solid #e2e8f0" |
| 1790 | bg="white" |
| 1791 | boxShadow="0 1px 2px rgba(15,15,15,0.04)" |
| 1792 | mb="8px" |
| 1793 | > |
| 1794 | <Flex align="flex-start" gap="12px" px="20px" pt="20px"> |
| 1795 | <Icon |
| 1796 | as={BsStars} |
| 1797 | boxSize="5" |
| 1798 | color="#c5c0d0" |
| 1799 | mt="2px" |
| 1800 | flexShrink={0} |
| 1801 | /> |
| 1802 | <Textarea |
| 1803 | ref={promptInputRef} |
| 1804 | autoFocus |
| 1805 | value={prompt} |
| 1806 | onChange={(e) => setPrompt(e.target.value.slice(0, MAX_CHARS))} |
| 1807 | placeholder={__( |
| 1808 | 'A feedback form to learn how onboarding felt, with a 1–5 rating and one optional comment…', |
| 1809 | 'everest-forms', |
| 1810 | )} |
| 1811 | border="none" |
| 1812 | resize="none" |
| 1813 | fontSize="15px" |
| 1814 | lineHeight="1.6" |
| 1815 | color="#181818" |
| 1816 | p="0" |
| 1817 | minH="90px" |
| 1818 | bg="transparent" |
| 1819 | _focus={{ boxShadow: 'none', outline: 'none', border: 'none' }} |
| 1820 | _placeholder={{ color: '#9a9a9a' }} |
| 1821 | /> |
| 1822 | </Flex> |
| 1823 | |
| 1824 | <Box mx="20px" borderTop="1px solid #f1f5f9" /> |
| 1825 | |
| 1826 | <Flex align="center" justify="space-between" px="16px" py="12px" gap="12px"> |
| 1827 | <CreditsPill usage={usage} loading={usageLoading} /> |
| 1828 | {isRateLimited ? ( |
| 1829 | <Popover trigger="hover" placement="top" isLazy> |
| 1830 | <PopoverTrigger> |
| 1831 | <Box |
| 1832 | as="button" |
| 1833 | display="inline-flex" |
| 1834 | alignItems="center" |
| 1835 | gap="8px" |
| 1836 | fontSize="14px" |
| 1837 | fontWeight="500" |
| 1838 | pl="16px" |
| 1839 | pr="8px" |
| 1840 | py="8px" |
| 1841 | ml="auto" |
| 1842 | borderRadius="8px" |
| 1843 | bg="#e6e3ee" |
| 1844 | color="#9a9a9a" |
| 1845 | cursor="not-allowed" |
| 1846 | border="none" |
| 1847 | transition="background 0.2s" |
| 1848 | > |
| 1849 | <Text margin="0" color="#9a9a9a"> |
| 1850 | {__('Generate', 'everest-forms')} |
| 1851 | </Text> |
| 1852 | <Box |
| 1853 | w="24px" |
| 1854 | h="24px" |
| 1855 | borderRadius="6px" |
| 1856 | display="inline-flex" |
| 1857 | alignItems="center" |
| 1858 | justifyContent="center" |
| 1859 | bg="#d5d0e0" |
| 1860 | > |
| 1861 | <Icon as={FiArrowUp} boxSize="3.5" color="#9a9a9a" /> |
| 1862 | </Box> |
| 1863 | </Box> |
| 1864 | </PopoverTrigger> |
| 1865 | <PopoverContent |
| 1866 | w="auto" |
| 1867 | maxW="220px" |
| 1868 | zIndex={200000} |
| 1869 | _focus={{ outline: 'none' }} |
| 1870 | > |
| 1871 | <PopoverArrow /> |
| 1872 | <PopoverBody p={3}> |
| 1873 | <LimitReachedBody tier={limitTier} /> |
| 1874 | </PopoverBody> |
| 1875 | </PopoverContent> |
| 1876 | </Popover> |
| 1877 | ) : ( |
| 1878 | <Box |
| 1879 | as="button" |
| 1880 | display="inline-flex" |
| 1881 | alignItems="center" |
| 1882 | gap="8px" |
| 1883 | fontSize="14px" |
| 1884 | fontWeight="500" |
| 1885 | pl="16px" |
| 1886 | pr="8px" |
| 1887 | py="8px" |
| 1888 | ml="auto" |
| 1889 | borderRadius="8px" |
| 1890 | bg={hasPrompt ? '#7545BB' : '#e6e3ee'} |
| 1891 | color={hasPrompt ? 'white' : '#9a9a9a'} |
| 1892 | cursor={hasPrompt ? 'pointer' : 'not-allowed'} |
| 1893 | border="none" |
| 1894 | onClick={hasPrompt ? handleGenerate : undefined} |
| 1895 | _hover={hasPrompt ? { bg: '#6a3daa' } : {}} |
| 1896 | transition="background 0.2s" |
| 1897 | > |
| 1898 | <Text margin="0" color={hasPrompt ? 'white' : '#9a9a9a'}> |
| 1899 | {__('Generate', 'everest-forms')} |
| 1900 | </Text> |
| 1901 | <Box |
| 1902 | w="24px" |
| 1903 | h="24px" |
| 1904 | borderRadius="6px" |
| 1905 | display="inline-flex" |
| 1906 | alignItems="center" |
| 1907 | justifyContent="center" |
| 1908 | bg={hasPrompt ? '#6a3daa' : '#d5d0e0'} |
| 1909 | > |
| 1910 | <Icon |
| 1911 | as={FiArrowUp} |
| 1912 | boxSize="3.5" |
| 1913 | color={hasPrompt ? 'white' : '#9a9a9a'} |
| 1914 | /> |
| 1915 | </Box> |
| 1916 | </Box> |
| 1917 | )} |
| 1918 | </Flex> |
| 1919 | </Box> |
| 1920 | |
| 1921 | <Text |
| 1922 | textAlign="right" |
| 1923 | fontSize="11px" |
| 1924 | color="#9a9a9a" |
| 1925 | m="0" |
| 1926 | pr="4px" |
| 1927 | mb="14px" |
| 1928 | > |
| 1929 | {prompt.length}/{MAX_CHARS} |
| 1930 | </Text> |
| 1931 | |
| 1932 | <Box> |
| 1933 | <Flex align="flex-end" justify="space-between" mb="10px"> |
| 1934 | <Heading |
| 1935 | as="h2" |
| 1936 | fontSize="20px" |
| 1937 | fontWeight="600" |
| 1938 | color="#0e0e0e" |
| 1939 | m="0" |
| 1940 | letterSpacing="-0.01em" |
| 1941 | > |
| 1942 | {__('Need inspiration?', 'everest-forms')} |
| 1943 | </Heading> |
| 1944 | <Text fontSize="13px" fontWeight="500" color="#9a9a9a" m="0"> |
| 1945 | {__('Tap a card to use as starting point', 'everest-forms')} |
| 1946 | </Text> |
| 1947 | </Flex> |
| 1948 | |
| 1949 | <SimpleGrid columns={{ base: 1, sm: 2 }} spacing="8px"> |
| 1950 | {INSPIRATION_CARDS.map((card) => ( |
| 1951 | <Box |
| 1952 | key={card.title} |
| 1953 | as="button" |
| 1954 | textAlign="left" |
| 1955 | display="block" |
| 1956 | width="100%" |
| 1957 | border="1px solid rgba(226,232,240,0.8)" |
| 1958 | bg="white" |
| 1959 | px="16px" |
| 1960 | py="12px" |
| 1961 | borderRadius="8px" |
| 1962 | cursor="pointer" |
| 1963 | onClick={() => setPrompt(card.prompt)} |
| 1964 | _hover={{ |
| 1965 | borderColor: 'rgba(117,69,187,0.4)', |
| 1966 | bg: 'rgba(117,69,187,0.02)', |
| 1967 | }} |
| 1968 | > |
| 1969 | <Text |
| 1970 | fontSize="14px" |
| 1971 | fontWeight="600" |
| 1972 | color="#0e0e0e" |
| 1973 | m="0 0 4px" |
| 1974 | lineHeight="1.4" |
| 1975 | > |
| 1976 | {card.title} |
| 1977 | </Text> |
| 1978 | <Text |
| 1979 | fontSize="12.5px" |
| 1980 | color="#6b6b6b" |
| 1981 | m="0" |
| 1982 | lineHeight="1.4" |
| 1983 | > |
| 1984 | {card.description} |
| 1985 | </Text> |
| 1986 | </Box> |
| 1987 | ))} |
| 1988 | </SimpleGrid> |
| 1989 | </Box> |
| 1990 | </Box> |
| 1991 | </Flex> |
| 1992 | </PageShell> |
| 1993 | ); |
| 1994 | }; |
| 1995 | |
| 1996 | export default CreateWithAI; |
| 1997 |