import { ComponentPlugin, ComponentPluginFactory, UIDLForms, UIDLFormDefinition, UIDLFormBehavior, UIDLElement, ChunkDefinition, UIDLDependency, UIDLStaticValue, UIDLENVValue, } from '@teleporthq/teleport-types' import * as types from '@babel/types' import { UIDLUtils, GenericUtils } from '@teleporthq/teleport-shared' import { buildFieldErrorMessagesEffect, formHasFieldErrorMessages, isCaptchaEnabled, } from './field-error-messages' import { buildTranslationLookup, ensureRouterHook, ensureTranslateHook, } from '../internationalization/locale-mapper-component' const sanitizeFormId = (formId: string): string => { return formId.replace(/-/g, '_') } type FormAlertKind = 'success' | 'error' | 'limit' /** * The text an alert shows: on an internationalized project the copy of the * visitor's language, read from `locales/.json` by its key; otherwise * the main-language text. */ const alertMessage = ( formDefinition: UIDLFormDefinition, kind: FormAlertKind, fallback: string ): types.Expression => { const translationKey = formDefinition.messages?.translationKeys?.[kind] if (translationKey) { return buildTranslationLookup(translationKey) } return types.stringLiteral(String(formDefinition.messages?.[kind]?.content || fallback)) } const formHasTranslatedMessages = (formDefinition: UIDLFormDefinition): boolean => Object.values(formDefinition.messages?.translationKeys ?? {}).some(Boolean) const formRedirectsToPage = (formDefinition: UIDLFormDefinition): boolean => { const { onSuccess, onError, onLimit } = formDefinition.behaviors ?? {} return [onSuccess, onError, onLimit].some( (behavior) => behavior?.action === 'redirect-page' && !!behavior.details?.url?.content ) } /** * Opens a page of the site after a submission. On a multi-language site Next's * router keeps the visitor's language (`/es/thanks`); assigning * `window.location.href` would land on the main language's page. */ const buildPageRedirect = (path: string, localized: boolean): types.ExpressionStatement => localized ? types.expressionStatement( types.callExpression( types.memberExpression(types.identifier('router'), types.identifier('push')), [types.stringLiteral(path)] ) ) : types.expressionStatement( types.assignmentExpression( '=', types.memberExpression( types.memberExpression(types.identifier('window'), types.identifier('location')), types.identifier('href') ), types.stringLiteral(path) ) ) /** * This plugin handles form submissions for Next.js projects. * It looks for form elements with a 'data-form-id' attribute and adds: * 1. State management for form submission (loading, success, error) * 2. Form submission handler that posts to FORMS_API_URL * 3. Collects all form field values and sends them in the request * 4. Handles success/error behaviors based on form configuration */ export const createNextFormSubmissionPlugin: ComponentPluginFactory<{}> = () => { const nextFormSubmissionHandler: ComponentPlugin = async (structure) => { const { chunks, uidl, options } = structure // Get forms from options const forms = options.forms // More than one language: pages are served under a locale prefix. const localizedRedirects = !options.skipI18n && Object.keys(options.internationalization?.languages ?? {}).length > 1 // Skip if no forms are defined in the project if (!forms || !forms.items) { return structure } const jsxComponent = chunks.find( (chunk) => chunk.name === 'jsx-component' && typeof chunk.content === 'object' && 'type' in chunk.content && chunk.content.type === 'VariableDeclaration' ) if (!jsxComponent) { return structure } // Find all form elements with data-form-id attribute const formsInComponent: Array<{ formId: string formElement: UIDLElement }> = [] UIDLUtils.traverseElements(uidl.node, (element) => { if (element.elementType === 'form' && element.attrs?.['data-form-id']) { const formIdAttr = element.attrs['data-form-id'] if (formIdAttr.type === 'static' && typeof formIdAttr.content === 'string') { formsInComponent.push({ formId: formIdAttr.content, formElement: element, }) } } }) // If no forms found, return unchanged if (formsInComponent.length === 0) { return structure } const componentBody = ( ( (jsxComponent.content as types.VariableDeclaration) .declarations[0] as types.VariableDeclarator ).init as types.ArrowFunctionExpression ).body as types.BlockStatement // Add React import for useState if (!structure.dependencies.useState) { structure.dependencies.useState = { type: 'library', path: 'react', version: '^17.0.2', meta: { namedImport: true, }, } } // Process each form found in the component for (const { formId, formElement } of formsInComponent) { const formDefinition = forms.items[formId] if (!formDefinition) { console.warn(`Form definition not found for form-id: ${formId}`) continue } // Sanitize form ID for use in JavaScript identifiers const sanitizedFormId = sanitizeFormId(formId) // Check if any behavior uses component-ref for messages const successMessage = formDefinition.behaviors?.onSuccess?.details?.message const errorMessage = formDefinition.behaviors?.onError?.details?.message const limitMessage = formDefinition.behaviors?.onLimit?.details?.message // Helper function to calculate the correct relative path to a component const calculateComponentPath = (componentId: string): string | null => { const projectComponents = options.projectComponents || {} const component = projectComponents[componentId] if (!component) { console.warn(`Component ${componentId} not found in project components`) return null } // Get the strategy from options const strategy = options.strategy if (!strategy) { console.warn('Strategy not found in options, falling back to hardcoded path') return `../components/${UIDLUtils.getComponentFileName(component)}` } // Get the current page's path const pagePath = UIDLUtils.getComponentFolderPath(uidl) const fromPath = strategy.pages.path.concat(pagePath) // Get the component's path const componentPath = UIDLUtils.getComponentFolderPath(component) const toPath = strategy.components.path.concat(componentPath) // Calculate the relative path const relativePath = GenericUtils.generateLocalDependenciesPrefix(fromPath, toPath) const fileName = UIDLUtils.getComponentFileName(component) return `${relativePath}${fileName}` } // Helper function to get component info from project UIDL const getComponentInfo = (componentId: string) => { // Get the component from the project UIDL via options const projectComponents = options.projectComponents || {} const component = projectComponents[componentId] if (!component) { console.warn(`Component ${componentId} not found in project components`) return null } // Use the outputOptions if available, otherwise compute from component name const componentClassName = component.outputOptions?.componentClassName || component.name return { componentClassName, } } if (successMessage?.type === 'component-ref') { const componentId = successMessage.componentId?.content if (typeof componentId === 'string') { const componentInfo = getComponentInfo(componentId) const componentPath = calculateComponentPath(componentId) if ( componentInfo && componentPath && !structure.dependencies[componentInfo.componentClassName] ) { structure.dependencies[componentInfo.componentClassName] = { type: 'local', path: componentPath, } } } } if (errorMessage?.type === 'component-ref') { const componentId = errorMessage.componentId?.content if (typeof componentId === 'string') { const componentInfo = getComponentInfo(componentId) const componentPath = calculateComponentPath(componentId) if ( componentInfo && componentPath && !structure.dependencies[componentInfo.componentClassName] ) { structure.dependencies[componentInfo.componentClassName] = { type: 'local', path: componentPath, } } } } if (limitMessage?.type === 'component-ref') { const componentId = limitMessage.componentId?.content if (typeof componentId === 'string') { const componentInfo = getComponentInfo(componentId) const componentPath = calculateComponentPath(componentId) if ( componentInfo && componentPath && !structure.dependencies[componentInfo.componentClassName] ) { structure.dependencies[componentInfo.componentClassName] = { type: 'local', path: componentPath, } } } } // Add state for form submission status const stateDeclarations = [ // const [formSubmitting_${sanitizedFormId}, setFormSubmitting_${sanitizedFormId}] = useState(false) types.variableDeclaration('const', [ types.variableDeclarator( types.arrayPattern([ types.identifier(`formSubmitting_${sanitizedFormId}`), types.identifier(`setFormSubmitting_${sanitizedFormId}`), ]), types.callExpression(types.identifier('useState'), [types.booleanLiteral(false)]) ), ]), // const [formMessage_${sanitizedFormId}, setFormMessage_${sanitizedFormId}] = useState('') types.variableDeclaration('const', [ types.variableDeclarator( types.arrayPattern([ types.identifier(`formMessage_${sanitizedFormId}`), types.identifier(`setFormMessage_${sanitizedFormId}`), ]), types.callExpression(types.identifier('useState'), [types.stringLiteral('')]) ), ]), // const [showFormMessageComponent_${sanitizedFormId}, setShowFormMessageComponent_${sanitizedFormId}] = useState(false) types.variableDeclaration('const', [ types.variableDeclarator( types.arrayPattern([ types.identifier(`showFormMessageComponent_${sanitizedFormId}`), types.identifier(`setShowFormMessageComponent_${sanitizedFormId}`), ]), types.callExpression(types.identifier('useState'), [types.booleanLiteral(false)]) ), ]), // const [showSuccessComponent_${sanitizedFormId}, setShowSuccessComponent_${sanitizedFormId}] = useState(false) types.variableDeclaration('const', [ types.variableDeclarator( types.arrayPattern([ types.identifier(`showSuccessComponent_${sanitizedFormId}`), types.identifier(`setShowSuccessComponent_${sanitizedFormId}`), ]), types.callExpression(types.identifier('useState'), [types.booleanLiteral(false)]) ), ]), // const [showErrorComponent_${sanitizedFormId}, setShowErrorComponent_${sanitizedFormId}] = useState(false) types.variableDeclaration('const', [ types.variableDeclarator( types.arrayPattern([ types.identifier(`showErrorComponent_${sanitizedFormId}`), types.identifier(`setShowErrorComponent_${sanitizedFormId}`), ]), types.callExpression(types.identifier('useState'), [types.booleanLiteral(false)]) ), ]), // const [showLimitComponent_${sanitizedFormId}, setShowLimitComponent_${sanitizedFormId}] = useState(false) types.variableDeclaration('const', [ types.variableDeclarator( types.arrayPattern([ types.identifier(`showLimitComponent_${sanitizedFormId}`), types.identifier(`setShowLimitComponent_${sanitizedFormId}`), ]), types.callExpression(types.identifier('useState'), [types.booleanLiteral(false)]) ), ]), // const [formStartTime_${sanitizedFormId}, setFormStartTime_${sanitizedFormId}] = useState(null) types.variableDeclaration('const', [ types.variableDeclarator( types.arrayPattern([ types.identifier(`formStartTime_${sanitizedFormId}`), types.identifier(`setFormStartTime_${sanitizedFormId}`), ]), types.callExpression(types.identifier('useState'), [types.nullLiteral()]) ), ]), ] // Create form submission handler function (pass forms config and both original and sanitized IDs) const handleSubmitFunction = createFormSubmitHandler( formId, sanitizedFormId, formDefinition, forms, localizedRedirects ) // Add useEffect to track form interaction start time const useEffectForFormStart = types.expressionStatement( types.callExpression(types.identifier('useEffect'), [ types.arrowFunctionExpression( [], types.blockStatement([ types.variableDeclaration('const', [ types.variableDeclarator( types.identifier('handleFirstInteraction'), types.arrowFunctionExpression( [], types.blockStatement([ types.ifStatement( types.binaryExpression( '===', types.identifier(`formStartTime_${sanitizedFormId}`), types.nullLiteral() ), types.blockStatement([ types.expressionStatement( types.callExpression( types.identifier(`setFormStartTime_${sanitizedFormId}`), [ types.callExpression( types.memberExpression( types.identifier('Date'), types.identifier('now') ), [] ), ] ) ), ]) ), ]) ) ), ]), types.variableDeclaration('const', [ types.variableDeclarator( types.identifier('formElement'), types.callExpression( types.memberExpression( types.identifier('document'), types.identifier('querySelector') ), [types.stringLiteral(`form[data-form-id="${formId}"]`)] ) ), ]), types.ifStatement( types.identifier('formElement'), types.blockStatement([ types.expressionStatement( types.callExpression( types.memberExpression( types.identifier('formElement'), types.identifier('addEventListener') ), [ types.stringLiteral('focusin'), types.identifier('handleFirstInteraction'), types.objectExpression([ types.objectProperty( types.identifier('once'), types.booleanLiteral(true) ), ]), ] ) ), types.returnStatement( types.arrowFunctionExpression( [], types.blockStatement([ types.expressionStatement( types.callExpression( types.memberExpression( types.identifier('formElement'), types.identifier('removeEventListener') ), [ types.stringLiteral('focusin'), types.identifier('handleFirstInteraction'), ] ) ), ]) ) ), ]) ), ]) ), types.arrayExpression([types.identifier(`formStartTime_${sanitizedFormId}`)]), ]) ) // Add useEffect dependency if (!structure.dependencies.useEffect) { structure.dependencies.useEffect = { type: 'library', path: 'react', version: '^17.0.2', meta: { namedImport: true, }, } } componentBody.body.unshift( ...stateDeclarations, useEffectForFormStart, ...(formHasFieldErrorMessages(formElement) ? [buildFieldErrorMessagesEffect(formId)] : []), handleSubmitFunction ) // Find the form JSX element in the AST and add onSubmit attribute // Also find and disable the submit button while form is submitting const returnStatement = componentBody.body.find( (statement) => statement.type === 'ReturnStatement' && statement.argument && 'type' in statement.argument ) as types.ReturnStatement | undefined if (returnStatement && returnStatement.argument) { let isInsideTargetForm = false const traverseJSX = (node: types.Node): void => { if (node.type === 'JSXElement') { const jsxElement = node as types.JSXElement const openingElement = jsxElement.openingElement if ( openingElement && openingElement.name.type === 'JSXIdentifier' && openingElement.name.name === 'form' ) { // Check if this is our form by looking for data-form-id attribute const hasFormId = openingElement.attributes?.some( (attr) => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === 'data-form-id' && attr.value?.type === 'StringLiteral' && 'value' in attr.value && attr.value.value === formId ) if (hasFormId) { // Add onSubmit handler openingElement.attributes.push( types.jsxAttribute( types.jsxIdentifier('onSubmit'), types.jsxExpressionContainer( types.identifier(`handleFormSubmit_${sanitizedFormId}`) ) ) ) // Mark that we're now inside the target form isInsideTargetForm = true // Traverse children to find submit button if (jsxElement.children) { jsxElement.children.forEach((child) => traverseJSX(child as types.Node)) } // Mark that we've exited the form isInsideTargetForm = false return // Don't continue traversing after we've handled the form } } // If we're inside the target form, look for submit buttons if ( isInsideTargetForm && openingElement && openingElement.name.type === 'JSXIdentifier' && openingElement.name.name === 'button' ) { // Check if this is a submit button const isSubmitButton = openingElement.attributes?.some( (attr) => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === 'type' && attr.value?.type === 'StringLiteral' && 'value' in attr.value && attr.value.value === 'submit' ) if (isSubmitButton) { // Add disabled attribute based on form submitting state openingElement.attributes.push( types.jsxAttribute( types.jsxIdentifier('disabled'), types.jsxExpressionContainer( types.identifier(`formSubmitting_${sanitizedFormId}`) ) ) ) } } } // Recursively traverse children only if we haven't already handled them if (!isInsideTargetForm && 'children' in node && Array.isArray(node.children)) { node.children.forEach((child) => traverseJSX(child as types.Node)) } if ('argument' in node && node.argument) { traverseJSX(node.argument as types.Node) } if ('expression' in node && node.expression) { traverseJSX(node.expression as types.Node) } } traverseJSX(returnStatement.argument) } // Add success and error component rendering after the form if needed if (successMessage?.type === 'component-ref' || errorMessage?.type === 'component-ref') { // Find the JSX component chunk and modify it to add components after the form const jsxChunk = chunks.find( (chunk) => chunk.name === 'jsx-component' && typeof chunk.content === 'object' && 'type' in chunk.content && chunk.content.type === 'VariableDeclaration' ) if (jsxChunk && jsxChunk.content) { const componentBodyForAddingComponents = ( ( (jsxChunk.content as types.VariableDeclaration) .declarations[0] as types.VariableDeclarator ).init as types.ArrowFunctionExpression ).body as types.BlockStatement const returnStatementForAddingComponents = componentBodyForAddingComponents.body.find( (statement) => statement.type === 'ReturnStatement' && statement.argument && 'type' in statement.argument ) as types.ReturnStatement | undefined if (returnStatementForAddingComponents && returnStatementForAddingComponents.argument) { // Find the form element and add components after it // We need to track parents manually during traversal const findAndAddComponents = ( node: types.Node, parent: types.Node | null = null ): boolean => { if (node.type === 'JSXElement') { const jsxElement = node as types.JSXElement const openingElement = jsxElement.openingElement if ( openingElement && openingElement.name.type === 'JSXIdentifier' && openingElement.name.name === 'form' ) { // Check if this is our form by looking for data-form-id attribute const hasFormId = openingElement.attributes?.some( (attr) => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === 'data-form-id' && attr.value?.type === 'StringLiteral' && 'value' in attr.value && attr.value.value === formId ) if (hasFormId) { // Create components to add after the form const componentsToAdd: types.JSXElement[] = [] // Add success component if (successMessage?.type === 'component-ref') { const componentId = successMessage.componentId?.content as string const componentInfo = getComponentInfo(componentId) if (!componentInfo) { console.warn(`Cannot render success component: ${componentId} not found`) return false } const successComponent = types.jsxElement( types.jsxOpeningElement( types.jsxIdentifier(componentInfo.componentClassName), [], true ), null, [], true ) const successConditional = types.jsxElement( types.jsxOpeningElement( types.jsxIdentifier('div'), [ types.jsxAttribute( types.jsxIdentifier('style'), types.jsxExpressionContainer( types.objectExpression([ types.objectProperty( types.identifier('display'), types.conditionalExpression( types.identifier(`showSuccessComponent_${sanitizedFormId}`), types.stringLiteral('block'), types.stringLiteral('none') ) ), ]) ) ), ], false ), types.jsxClosingElement(types.jsxIdentifier('div')), [successComponent], false ) componentsToAdd.push(successConditional) } // Add error component if (errorMessage?.type === 'component-ref') { const componentId = errorMessage.componentId?.content as string const componentInfo = getComponentInfo(componentId) if (!componentInfo) { console.warn(`Cannot render error component: ${componentId} not found`) return false } const errorComponent = types.jsxElement( types.jsxOpeningElement( types.jsxIdentifier(componentInfo.componentClassName), [], true ), null, [], true ) const errorConditional = types.jsxElement( types.jsxOpeningElement( types.jsxIdentifier('div'), [ types.jsxAttribute( types.jsxIdentifier('style'), types.jsxExpressionContainer( types.objectExpression([ types.objectProperty( types.identifier('display'), types.conditionalExpression( types.identifier(`showErrorComponent_${sanitizedFormId}`), types.stringLiteral('block'), types.stringLiteral('none') ) ), ]) ) ), ], false ), types.jsxClosingElement(types.jsxIdentifier('div')), [errorComponent], false ) componentsToAdd.push(errorConditional) } // Add limit component if (limitMessage?.type === 'component-ref') { const componentId = limitMessage.componentId?.content as string const componentInfo = getComponentInfo(componentId) if (!componentInfo) { console.warn(`Cannot render limit component: ${componentId} not found`) return false } const limitComponent = types.jsxElement( types.jsxOpeningElement( types.jsxIdentifier(componentInfo.componentClassName), [], true ), null, [], true ) const limitConditional = types.jsxElement( types.jsxOpeningElement( types.jsxIdentifier('div'), [ types.jsxAttribute( types.jsxIdentifier('style'), types.jsxExpressionContainer( types.objectExpression([ types.objectProperty( types.identifier('display'), types.conditionalExpression( types.identifier(`showLimitComponent_${sanitizedFormId}`), types.stringLiteral('block'), types.stringLiteral('none') ) ), ]) ) ), ], false ), types.jsxClosingElement(types.jsxIdentifier('div')), [limitComponent], false ) componentsToAdd.push(limitConditional) } // Add components after the form by finding it in the parent's children array if ( componentsToAdd.length > 0 && parent && 'children' in parent && Array.isArray(parent.children) ) { const formIndex = parent.children.indexOf(node) if (formIndex >= 0) { // Insert components after the form parent.children.splice(formIndex + 1, 0, ...componentsToAdd) } } return true // Found and processed the form } } } // Recursively traverse children, passing current node as parent if ('children' in node && Array.isArray(node.children)) { for (const child of node.children) { if (findAndAddComponents(child as types.Node, node)) { return true // Found the form, stop searching } } } if ('argument' in node && node.argument) { return findAndAddComponents(node.argument as types.Node, node) } if ('expression' in node && node.expression) { return findAndAddComponents(node.expression as types.Node, node) } return false } findAndAddComponents(returnStatementForAddingComponents.argument, null) } } } } // Per-page captcha loading: inject the provider