';
+ $output = '
';
+ $mapped_name = isset( $attributes['mappedName'] ) ? $attributes['mappedName'] : 'field-' . $id;
- $output .= '' . $label . $this->render_required_sign( $is_required ) . ' ';
+ $output .= '' . $label . $this->render_required_sign( $is_required ) . ' ';
- $output .= ' ';
+
+ $output .= '' . $label . ' ';
+
+ $output .= ' ';
+
+ $output .= '
';
+
+
+ return $output;
+ }
+
+
+}
diff --git a/plugins/otter-pro/inc/render/class-form-stripe-block.php b/plugins/otter-pro/inc/render/class-form-stripe-block.php
new file mode 100644
index 000000000..b1c78d0eb
--- /dev/null
+++ b/plugins/otter-pro/inc/render/class-form-stripe-block.php
@@ -0,0 +1,96 @@
+create_request( 'product', $attributes['product'] );
+
+ if ( is_wp_error( $product ) ) {
+ return sprintf(
+ '
',
+ get_block_wrapper_attributes(),
+ __( 'An error occurred! Could not retrieve product information!', 'otter-blocks' ) . $this->format_error( $product )
+ );
+ }
+
+ $details_markup = '';
+
+ if ( 0 < count( $product['images'] ) ) {
+ $details_markup .= '
';
+ }
+
+ $price = $stripe->create_request( 'price', $attributes['price'] );
+
+ if ( is_wp_error( $price ) ) {
+ return sprintf(
+ '
',
+ get_block_wrapper_attributes(),
+ __( 'An error occurred! Could not retrieve the price of the product!', 'otter-blocks' ) . $this->format_error( $price )
+ );
+ }
+
+ $currency = Review_Block::get_currency( $price['currency'] );
+ $amount = number_format( $price['unit_amount'] / 100, 2, '.', ' ' );
+
+ $details_markup .= '
';
+ $details_markup .= '
' . $product['name'] . ' ';
+ $details_markup .= '' . $currency . $amount . ' ';
+ $details_markup .= '';
+
+ $html_attributes = 'id="' . $attributes['id'] . '" ' .
+ ( isset( $attributes['mappedName'] ) ? ( ' name="' . $attributes['mappedName'] . '"' ) : '' ) .
+ ( isset( $attributes['fieldOptionName'] ) ? ( ' data-field-option-name="' . $attributes['fieldOptionName'] . '"' ) : '' );
+
+ return sprintf(
+ '
',
+ get_block_wrapper_attributes() . $html_attributes,
+ $details_markup
+ );
+ }
+
+ /**
+ * Format the error message.
+ *
+ * @param \WP_Error $error The error.
+ * @return string
+ */
+ private function format_error( $error ) {
+ return defined( 'WP_DEBUG' ) && WP_DEBUG ? (
+ '
' . __( 'Error message: ', 'otter-blocks' ) . ' ' . $error->get_error_message() . ''
+ ) : '';
+ }
+}
diff --git a/src/animation/frontend.js b/src/animation/frontend.js
index 82beb019d..d289b2fb0 100644
--- a/src/animation/frontend.js
+++ b/src/animation/frontend.js
@@ -208,24 +208,26 @@ window.addEventListener( 'load', () => {
}
window.addEventListener( 'scroll', () => {
- for ( const element of elements ) {
- if (
- element.getBoundingClientRect().top <=
- window.innerHeight * 0.95 &&
- 0 < element.getBoundingClientRect().top
- ) {
+ requestAnimationFrame( () => {
+ for ( const element of elements ) {
if (
- element.animationClasses &&
- 0 < element.animationClasses.length
+ element.getBoundingClientRect().top <=
+ window.innerHeight * 0.95 &&
+ 0 < element.getBoundingClientRect().top
) {
- const classes = element.animationClasses;
- classes.forEach( ( i ) => element.classList.add( i ) );
-
- element.classList.remove( 'hidden-animated' );
- delete element.animationClasses;
+ if (
+ element.animationClasses &&
+ 0 < element.animationClasses.length
+ ) {
+ const classes = element.animationClasses;
+ classes.forEach( ( i ) => element.classList.add( i ) );
+
+ element.classList.remove( 'hidden-animated' );
+ delete element.animationClasses;
+ }
}
}
- }
+ });
});
});
diff --git a/src/blocks/blocks/circle-counter/inspector.js b/src/blocks/blocks/circle-counter/inspector.js
index 93d14132f..3cdccbd11 100644
--- a/src/blocks/blocks/circle-counter/inspector.js
+++ b/src/blocks/blocks/circle-counter/inspector.js
@@ -1,20 +1,35 @@
+/**
+ * External dependencies
+ */
+import { clamp } from 'lodash';
+
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
-import { clamp } from 'lodash';
import {
- __experimentalColorGradientControl as ColorGradientControl,
- InspectorControls
+ InspectorControls,
+ PanelColorSettings
} from '@wordpress/block-editor';
import {
PanelBody,
RangeControl,
- SelectControl
+ SelectControl,
+ TextControl,
+ ToggleControl,
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption
} from '@wordpress/components';
+import { Fragment, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+*/
+import { InspectorHeader } from '../../components';
+import { useTabSwitch } from '../../helpers/block-utility';
/**
*
@@ -25,6 +40,9 @@ const Inspector = ({
attributes,
setAttributes
}) => {
+
+ const [ tab, setTab ] = useTabSwitch( attributes.id, 'settings' );
+
const onPercentageChange = value => {
if ( value === undefined ) {
return;
@@ -44,102 +62,152 @@ const Inspector = ({
return (
-
-
-
-
-
- setAttributes({ titleStyle }) }
- />
-
-
-
- setAttributes({ height }) }
- min={ 20 }
- max={ 240 }
- />
-
- setAttributes({ strokeWidth }) }
- initialPosition={ 10 }
- min={ 0 }
- max={ 20 }
- />
-
- setAttributes({ fontSizeTitle }) }
- initialPosition={ 37 }
- min={ 0 }
- max={ 100 }
- />
-
- setAttributes({ fontSizePercent }) }
- initialPosition={ 27 }
- min={ 0 }
- max={ 80 }
- />
-
- { ( 'hide' !== attributes.titleStyle ) && (
- setAttributes({ titleColor }) }
- />
- ) }
-
- setAttributes({ progressColor }) }
- />
-
- setAttributes({ backgroundColor }) }
- />
-
+
+
+
+ {
+ 'settings' === tab && (
+
+
+ setAttributes({ title }) }
+ />
+
+
+
+ )
+ }
+ {
+ 'style' === tab && (
+
+
+ setAttributes({ titleStyle: 'hide' !== attributes.titleStyle ? 'hide' : 'default' }) }
+ />
+
+ { 'hide' !== attributes.titleStyle && (
+ setAttributes({ titleStyle }) }
+ isBlock
+ >
+
+
+
+ )}
+
+ setAttributes({ titleColor }),
+ label: __( 'Title Color', 'otter-blocks' )
+ },
+ {
+ value: attributes.progressColor,
+ onChange: progressColor => setAttributes({ progressColor }),
+ label: __( 'Progress Color', 'otter-blocks' )
+ },
+ {
+ value: attributes.backgroundColor,
+ onChange: backgroundColor => setAttributes({ backgroundColor }),
+ label: __( 'Background Color', 'otter-blocks' )
+ }
+ ] }
+ />
+
+ setAttributes({ height }) }
+ min={ 20 }
+ max={ 240 }
+ />
+
+ setAttributes({ strokeWidth }) }
+ initialPosition={ 10 }
+ min={ 0 }
+ max={ 20 }
+ />
+
+
+
+
+ setAttributes({ fontSizeTitle }) }
+ initialPosition={ 37 }
+ min={ 0 }
+ max={ 100 }
+ />
+
+ setAttributes({ fontSizePercent }) }
+ initialPosition={ 27 }
+ min={ 0 }
+ max={ 80 }
+ />
+
+
+
+
+ )
+ }
+
);
};
diff --git a/src/blocks/blocks/content-generator/block.json b/src/blocks/blocks/content-generator/block.json
new file mode 100644
index 000000000..65ac1576c
--- /dev/null
+++ b/src/blocks/blocks/content-generator/block.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "name": "themeisle-blocks/content-generator",
+ "title": "AI Block (Beta)",
+ "category": "themeisle-blocks",
+ "description": "Generate content for your block with AI.",
+ "keywords": [
+ "content",
+ "generator",
+ "ai",
+ "layout"
+ ],
+ "textdomain": "otter-blocks",
+ "attributes": {
+ "promptID": {
+ "type": "string"
+ },
+ "resultHistory": {
+ "type": "array",
+ "default": []
+ }
+ }
+}
diff --git a/src/blocks/blocks/content-generator/edit.js b/src/blocks/blocks/content-generator/edit.js
new file mode 100644
index 000000000..2e6e2e43b
--- /dev/null
+++ b/src/blocks/blocks/content-generator/edit.js
@@ -0,0 +1,299 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { get } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { Button, Disabled, Dropdown, ResizableBox, SelectControl } from '@wordpress/components';
+
+import {
+ __experimentalBlockVariationPicker as VariationPicker,
+ InnerBlocks,
+ RichText,
+ useBlockProps
+} from '@wordpress/block-editor';
+
+import {
+ Fragment,
+ useEffect, useMemo,
+ useRef,
+ useState
+} from '@wordpress/element';
+
+import { createBlock, rawHandler } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Inspector from './inspector.js';
+import PromptPlaceholder from '../../components/prompt';
+import { parseFormPromptResponseToBlocks } from '../../helpers/prompt';
+import { useDispatch, useSelect } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+import { chevronDown, Icon } from '@wordpress/icons';
+import { insertBlockBelow } from '../../helpers/block-utility';
+
+const { attributes: defaultAttributes } = metadata;
+
+function formatNameBlock( name ) {
+ const namePart = name.split( '/' )[1];
+ return namePart.split( ' ' ).map( word => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) ).join( ' ' );
+}
+
+/**
+ * AI Block
+ * @param {import('./types').ContentGeneratorProps} props
+ */
+const ContentGenerator = ({
+ attributes,
+ setAttributes,
+ isSelected,
+ clientId,
+ toggleSelection,
+ name
+}) => {
+
+ const blockProps = useBlockProps();
+
+ const [ prompt, setPrompt ] = useState( '' );
+
+ const {
+ insertBlock,
+ removeBlock,
+ replaceInnerBlocks,
+ selectBlock,
+ moveBlockToPosition,
+ insertBlocks,
+ replaceBlock,
+ replaceBlocks
+ } = useDispatch( 'core/block-editor' );
+
+ /**
+ * On success callback
+ *
+ * @type {import('../../components/prompt').PromptOnSuccess}
+ */
+ const onPreview = ( result ) => {
+ if ( 'form' === attributes.promptID ) {
+
+ const formFields = parseFormPromptResponseToBlocks( result );
+
+ const form = createBlock( 'themeisle-blocks/form', {}, formFields );
+
+ replaceInnerBlocks( clientId, [ form ]);
+ }
+
+ if ( 'textTransformation' === attributes.promptID ) {
+ const blocks = rawHandler({
+ HTML: result
+ });
+
+ replaceInnerBlocks( clientId, blocks );
+ }
+ };
+
+ const { hasInnerBlocks, containerId, getBlocks, getBlock, getBlockOrder, getBlockRootClientId } = useSelect(
+ select => {
+
+ const { getBlocks, getBlock, getBlockRootClientId, getBlockOrder } = select( 'core/block-editor' );
+
+ const blocks = getBlocks?.( clientId ) ?? [];
+
+ return {
+ hasInnerBlocks: getBlocks?.( clientId ).length,
+ containerId: blocks[0]?.clientId,
+ getBlocks,
+ getBlock,
+ getBlockRootClientId,
+ getBlockOrder
+ };
+ },
+ [ clientId ]
+ );
+
+ /**
+ * Replace the block with the blocks generated from the prompt response
+ */
+ const selfReplaceWithContent = () => {
+ const blocks = getBlocks( clientId );
+
+ replaceBlocks( clientId, blocks );
+ };
+
+ /**
+ * Insert the blocks generated from the prompt response below the current block
+ */
+ const insertContentIntoPage = () => {
+ const blocks = getBlocks( clientId );
+ const copy = blocks.map( blockRoot => {
+ return createBlock(
+ blockRoot.name,
+ blockRoot.attributes,
+ blockRoot.innerBlocks?.map( block => {
+ return createBlock( block.name, block.attributes, block.innerBlocks );
+ })
+ );
+ });
+
+ insertBlockBelow( clientId, copy );
+ };
+
+ const { blockType, defaultVariation, variations } = useSelect(
+ select => {
+ const {
+ getBlockVariations,
+ getBlockType,
+ getDefaultBlockVariation
+ } = select( 'core/blocks' );
+
+ return {
+ blockType: getBlockType( name ),
+ defaultVariation: getDefaultBlockVariation( name, 'block' ),
+ variations: getBlockVariations( name, 'block' )
+ };
+ },
+ [ name ]
+ );
+
+ const PRESETS = {
+ form: {
+ title: __( 'AI Form generator', 'otter-blocks' ),
+ placeholder: __( 'Start describing what form you need...', 'otter-blocks' ),
+ actions: ( props ) => {
+ return (
+
+
+ {__( 'Replace', 'otter-blocks' )}
+
+
+ {__( 'Insert below', 'otter-blocks' )}
+
+
+ );
+ }
+ },
+ textTransformation: {
+ title: __( 'AI Content generator', 'otter-blocks' ),
+ placeholder: __( 'Start describing what content you need...', 'otter-blocks' ),
+ actions: ( props ) => {
+ return (
+
+
+ {__( 'Replace', 'otter-blocks' )}
+
+
+ {__( 'Insert below', 'otter-blocks' )}
+
+
+ );
+ }
+ }
+ };
+
+ return (
+
+
+
+
+
+ {
+ attributes.promptID === undefined ? (
+
{
+ if ( nextVariation ) {
+ setAttributes( nextVariation.attributes );
+ }
+ selectBlock( clientId );
+ } }
+ />
+ ) : (
+ removeBlock( clientId )}
+ promptPlaceholder={PRESETS?.[attributes.promptID]?.placeholder}
+ resultHistory={attributes.resultHistory}
+ >
+ {
+ hasInnerBlocks ? (
+
+
+
+ ) : ''
+ }
+
+ )
+ }
+
+
+ );
+};
+
+export default ContentGenerator;
+
+// INFO: those are function for changing the content of an existing block.
+// const [ showDropdown, setShowDropdown ] = useState( false );
+// const [ containerClientId, setContainerClientId ] = useState( '' );
+//
+// const canReplaceBlock = useSelect( select => {
+// const { getBlocks } = select( 'core/block-editor' );
+//
+// return ( getBlocks?.() ?? []).some( block => block.clientId === attributes.blockToReplace );
+// }, [ clientId, attributes.blockToReplace ]);
+//
+// const replaceTargetBlock = () => {
+//
+// const blocksToAdd = getBlocks?.( containerId )?.map( block => {
+// return createBlock( block.name, block.attributes, block.innerBlocks );
+// }) ?? [];
+//
+// if ( ! blocksToAdd.length ) {
+// return;
+// }
+//
+// replaceInnerBlocks( attributes.blockToReplace, blocksToAdd );
+// };
+//
+// const appendToTargetBlock = () => {
+// const blocksToAdd = getBlocks?.( containerId )?.map( block => {
+// return createBlock( block.name, block.attributes, block.innerBlocks );
+// }) ?? [];
+//
+// const targetBlock = getBlock( attributes.blockToReplace );
+//
+// if ( ! blocksToAdd.length ) {
+// return;
+// }
+//
+// insertBlocks( blocksToAdd, targetBlock.innerBlocks?.length ?? 0, attributes.blockToReplace );
+// };
diff --git a/src/blocks/blocks/content-generator/editor.scss b/src/blocks/blocks/content-generator/editor.scss
new file mode 100644
index 000000000..96c7670ad
--- /dev/null
+++ b/src/blocks/blocks/content-generator/editor.scss
@@ -0,0 +1,61 @@
+.wp-block-themeisle-blocks-content-generator {
+ padding: 10px;
+ border-radius: 10px;
+
+ .o-preview {
+ padding: 10px;
+ border-radius: 5px;
+ background-color: #fff;
+ margin: 10px 0px;
+ border: 1px solid #1E1E1E;
+ }
+
+ .prompt-result__content {
+ .wp-block-themeisle-blocks-form .wp-block-button {
+ display: none;
+ }
+
+ &> div:not(.prompt-input__container):last-child {
+ padding: 10px;
+ }
+ }
+
+ .o-actions {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ align-items: baseline;
+
+ padding: 10px;
+ border: 1px dashed #1E1E1E;
+ border-radius: 3px;
+ }
+}
+
+.o-tracking-consent-toggle {
+ display: flex;
+ flex-direction: row;
+ gap: 5px;
+ align-items: center;
+ padding: 4px;
+ border-radius: 2px;
+ background-color: #f7f7f7;
+ font-size: 12px;
+ margin-top: 8px;
+
+ &__label {
+ flex-grow: 1;
+ }
+
+ &__close {
+ height: 24px;
+ &> button.components-button.is-tertiary.has-icon {
+ padding: 0;
+ height: 24px;
+
+ svg {
+ fill: #1E1E1E;
+ }
+ }
+ }
+}
diff --git a/src/blocks/blocks/content-generator/index.js b/src/blocks/blocks/content-generator/index.js
new file mode 100644
index 000000000..36d6a3f2a
--- /dev/null
+++ b/src/blocks/blocks/content-generator/index.js
@@ -0,0 +1,50 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import { registerBlockType } from '@wordpress/blocks';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import { aiGeneration as icon, formAiGeneration } from '../../helpers/icons.js';
+import edit from './edit.js';
+import './editor.scss';
+
+const { name } = metadata;
+
+registerBlockType( name, {
+ ...metadata,
+ icon,
+ keywords: [
+ 'content',
+ 'ai',
+ 'layout'
+ ],
+ edit,
+ save: () => null,
+ variations: [
+ {
+ name: 'themeisle-blocks/content-generator-form',
+ description: __( 'Generate Form with OpenAI.', 'otter-blocks' ),
+ icon: formAiGeneration,
+ title: __( 'AI Form Generator', 'otter-blocks' ),
+ scope: [ 'block' ],
+ attributes: {
+ promptID: 'form'
+ }
+ },
+ {
+ name: 'themeisle-blocks/content-generator-content',
+ description: __( 'Generate new content with OpenAI.', 'otter-blocks' ),
+ icon: formAiGeneration,
+ title: __( 'AI Content Generator', 'otter-blocks' ),
+ scope: [ 'block' ],
+ attributes: {
+ promptID: 'textTransformation'
+ }
+ }
+ ]
+});
diff --git a/src/blocks/blocks/content-generator/inspector.js b/src/blocks/blocks/content-generator/inspector.js
new file mode 100644
index 000000000..948f8f815
--- /dev/null
+++ b/src/blocks/blocks/content-generator/inspector.js
@@ -0,0 +1,24 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ InspectorControls
+} from '@wordpress/block-editor';
+
+/**
+ *
+ * @param {import('./types').ContentGeneratorInspectorProps} props
+ * @returns
+ */
+const Inspector = ({
+ attributes,
+ setAttributes
+}) => {
+
+ return (
+
+
+ );
+};
+
+export default Inspector;
diff --git a/src/blocks/blocks/content-generator/types.d.ts b/src/blocks/blocks/content-generator/types.d.ts
new file mode 100644
index 000000000..913b80578
--- /dev/null
+++ b/src/blocks/blocks/content-generator/types.d.ts
@@ -0,0 +1,10 @@
+import { BlockProps, InspectorProps } from '../../helpers/blocks';
+
+type Attributes = {
+ promptID: string;
+ resultHistory: {result: string, meta: { usedToken: number, prompt: string }}[]
+}
+
+export type ContentGeneratorAttrs = Partial
+export type ContentGeneratorProps = BlockProps
+export interface ContentGeneratorInspectorProps extends InspectorProps {}
diff --git a/src/blocks/blocks/countdown/inspector.js b/src/blocks/blocks/countdown/inspector.js
index 089badc9a..276d5a54b 100644
--- a/src/blocks/blocks/countdown/inspector.js
+++ b/src/blocks/blocks/countdown/inspector.js
@@ -497,7 +497,7 @@ const Inspector = ({
) }
{
setAttributes({
- boxShadowColor: ( 100 > attributes.boxShadowColorOpacity && attributes.boxShadowColor?.includes( 'var(' ) ) ?
+ boxShadowColor: ( 100 > attributes.boxShadowColorOpacity && value?.includes( 'var(' ) ) ?
getComputedStyle( document.documentElement, null ).getPropertyValue( value?.replace( 'var(', '' )?.replace( ')', '' ) ) :
value
});
@@ -290,7 +290,7 @@ const Inspector = ({
( 'front' === currentSide && ! Boolean( attributes.isInverted ) ) || ( 'back' === currentSide && Boolean( attributes.isInverted ) ) ? (
) : (
setAttributes({ borderColor }),
- label: __( 'Border Color', 'otter-blocks' ),
+ label: __( 'Border', 'otter-blocks' ),
isShownByDefault: false
},
{
value: attributes.titleColor,
onChange: titleColor => setAttributes({ titleColor }),
- label: __( 'Title Color', 'otter-blocks' ),
+ label: __( 'Title', 'otter-blocks' ),
isShownByDefault: false
},
{
value: attributes.descriptionColor,
onChange: descriptionColor => setAttributes({ descriptionColor }),
- label: __( 'Description Color', 'otter-blocks' ),
+ label: __( 'Description', 'otter-blocks' ),
isShownByDefault: false
}
] }
@@ -567,7 +567,7 @@ const Inspector = ({
initialOpen={ false }
>
{
@@ -65,6 +67,11 @@ export type FieldOption = {
saveFiles?: string
maxFilesNumber?: number
}
+ stripe?: {
+ product: string,
+ price: string,
+ quantity: number,
+ }
}
export type FormInputCommonProps = {
@@ -94,6 +101,10 @@ export const fieldTypesOptions = () => ([
label: ( Boolean( window.otterPro?.isActive ) && ! Boolean( window.otterPro?.isExpired ) ) ? __( 'File', 'otter-blocks' ) : __( 'File (Pro)', 'otter-blocks' ),
value: 'file'
},
+ {
+ label: ( Boolean( window.otterPro?.isActive ) && ! Boolean( window.otterPro?.isExpired ) ) ? __( 'Hidden', 'otter-blocks' ) : __( 'Hidden (Pro)', 'otter-blocks' ),
+ value: 'hidden'
+ },
{
label: __( 'Number', 'otter-blocks' ),
value: 'number'
@@ -106,6 +117,10 @@ export const fieldTypesOptions = () => ([
label: __( 'Select', 'otter-blocks' ),
value: 'select'
},
+ {
+ label: ( Boolean( window.otterPro?.isActive ) && ! Boolean( window.otterPro?.isExpired ) ) ? __( 'Stripe', 'otter-blocks' ) : __( 'Stripe (Pro)', 'otter-blocks' ),
+ value: 'stripe'
+ },
{
label: __( 'Text', 'otter-blocks' ),
value: 'text'
@@ -115,7 +130,7 @@ export const fieldTypesOptions = () => ([
value: 'textarea'
},
{
- label: __( 'Url', 'otter-blocks' ),
+ label: __( 'URL', 'otter-blocks' ),
value: 'url'
}
]);
@@ -132,6 +147,8 @@ export const switchFormFieldTo = ( type?: string, clientId ?:string, attributes?
[ 'textarea' === type, 'form-textarea' ],
[ 'select' === type || 'checkbox' === type || 'radio' === type, 'form-multiple-choice' ],
[ 'file' === type, 'form-file' ],
+ [ 'hidden' === type, 'form-hidden-field' ],
+ [ 'stripe' === type, 'form-stripe-field' ],
[ 'form-input' ]
]);
@@ -168,7 +185,7 @@ export const HideFieldLabelToggle = ( props: Partial ( name?.startsWith( 'themeisle-blocks/form-input' ) || name?.startsWith( 'themeisle-blocks/form-textarea' ) || name?.startsWith( 'themeisle-blocks/form-multiple-choice' ) || name?.startsWith( 'themeisle-blocks/form-file' ) );
+export const hasFormFieldName = ( name?: string ) => [ 'input', 'textarea', 'multiple-choice', 'file', 'hidden-field', 'stripe-field' ].some( ( type ) => name?.startsWith( `themeisle-blocks/form-${ type }` ) );
export const getFormFieldsFromInnerBlock = ( block: any ) : ( any | undefined )[] => {
return block?.innerBlocks?.map( ( child: any ) => {
@@ -209,4 +226,11 @@ export const selectAllFieldsFromForm = ( children: any[]) : ({ parentClientId: s
}).flat().filter( c => c !== undefined ) ?? []) as ({ parentClientId: string, inputField: any })[];
};
+export const mappedNameInfo = (
+
+ {__( 'Allow easy identification of the field.', 'otter-blocks' )}
+ { __( 'Learn More', 'otter-blocks' ) }
+
+);
+
export default { switchFormFieldTo, HideFieldLabelToggle, FieldInputWidth };
diff --git a/src/blocks/blocks/form/edit.js b/src/blocks/blocks/form/edit.js
index b9d029969..c7fa34897 100644
--- a/src/blocks/blocks/form/edit.js
+++ b/src/blocks/blocks/form/edit.js
@@ -3,7 +3,7 @@
*/
import classnames from 'classnames';
-import { get, isEqual } from 'lodash';
+import { debounce, get, isEqual } from 'lodash';
import hash from 'object-hash';
@@ -18,6 +18,7 @@ import apiFetch from '@wordpress/api-fetch';
import {
__experimentalBlockVariationPicker as VariationPicker,
+ BlockControls,
InnerBlocks,
RichText,
useBlockProps
@@ -41,19 +42,29 @@ import {
createContext
} from '@wordpress/element';
+import {
+ Icon
+} from '@wordpress/icons';
+
+import { Button, Notice, ToolbarGroup } from '@wordpress/components';
+
/**
* Internal dependencies
*/
import metadata from './block.json';
import {
blockInit,
- getDefaultValueByField
+ getDefaultValueByField,
+ insertBlockBelow
} from '../../helpers/block-utility.js';
import Inspector from './inspector.js';
import Placeholder from './placeholder.js';
import { useResponsiveAttributes } from '../../helpers/utility-hooks';
import { renderBoxOrNumWithUnit, _cssBlock, _px, findInnerBlocks } from '../../helpers/helper-functions';
-import { Notice } from '@wordpress/components';
+import PromptPlaceholder from '../../components/prompt';
+import { parseFormPromptResponseToBlocks, sendPromptToOpenAI } from '../../helpers/prompt';
+import { aiGeneration, formAiGeneration } from '../../helpers/icons';
+import DeferredWpOptionsSave from '../../helpers/defered-wp-options-save';
const { attributes: defaultAttributes } = metadata;
@@ -69,7 +80,9 @@ const formOptionsMap = {
cc: 'cc',
bcc: 'bcc',
autoresponder: 'autoresponder',
- submissionsSaveLocation: 'submissionsSaveLocation'
+ submissionsSaveLocation: 'submissionsSaveLocation',
+ webhookId: 'webhookId',
+ requiredFields: 'requiredFields'
};
/**
@@ -107,7 +120,7 @@ const Edit = ({
* @param {import('../../common').SyncAttrs} field
* @returns
*/
- const getSyncValue = field =>{
+ const getSyncValue = field => {
if ( attributes?.isSynced?.includes( field ) ) {
return getDefaultValueByField({ name, field, defaultAttributes, attributes });
}
@@ -143,21 +156,29 @@ const Edit = ({
moveBlockToPosition
} = useDispatch( 'core/block-editor' );
- const {
- unlockPostSaving
- } = useDispatch( 'core/editor' );
-
const setFormOption = option => {
setFormOptions( options => ({ ...options, ...option }) );
};
+ /**
+ * This mark the block as dirty which allow us to use the save button to trigger the update of the form options tied to WP Options.
+ *
+ * @type {DebouncedFunc<(function(): void)|*>}
+ */
+ const enableSaveBtn = debounce( () => {
+ const dummyBlock = createBlock( 'core/spacer', { height: '0px' });
+ insertBlock( dummyBlock, 0, clientId, false );
+ removeBlock( dummyBlock.clientId, false );
+ }, 3000 );
+
const setFormOptionAndSaveUnlock = option => {
setFormOption( option );
- unlockPostSaving?.();
+ enableSaveBtn();
};
const [ savedFormOptions, setSavedFormOptions ] = useState( true );
const [ showAutoResponderNotice, setShowAutoResponderNotice ] = useState( false );
+ const [ showDuplicatedMappedName, setShowDuplicatedMappedName ] = useState( false );
const [ listIDOptions, setListIDOptions ] = useState([{ label: __( 'None', 'otter-blocks' ), value: '' }]);
@@ -188,12 +209,17 @@ const Edit = ({
const [ hasEmailField, setHasEmailField ] = useState( false );
- const { children, hasProtection } = useSelect( select => {
+ const { children, hasProtection, currentBlockPosition } = useSelect( select => {
const {
- getBlock
+ getBlock,
+ getBlockOrder
} = select( 'core/block-editor' );
const children = getBlock( clientId ).innerBlocks;
+
+ const currentBlockPosition = getBlockOrder().indexOf( clientId );
+
return {
+ currentBlockPosition,
children,
hasProtection: 0 < children?.filter( ({ name }) => 'themeisle-blocks/form-nonce' === name )?.length
};
@@ -204,16 +230,29 @@ const Edit = ({
const isPublishingPost = select( 'core/editor' )?.isPublishingPost();
const isAutosaving = select( 'core/editor' )?.isAutosavingPost();
const widgetSaving = select( 'core/edit-widgets' )?.isSavingWidgetAreas();
+ const nonPostEntitySaving = select( 'core/editor' )?.isSavingNonPostEntityChanges();
return {
- canSaveData: ( ! isAutosaving && ( isSavingPost || isPublishingPost ) ) || widgetSaving
+ canSaveData: ( ! isAutosaving && ( isSavingPost || isPublishingPost || nonPostEntitySaving ) ) || widgetSaving
};
});
+ /**
+ * Prevent saving data if the block is inside an AI block. This will prevent polluting the wp_options table.
+ */
+ const isInsideAiBlock = useSelect( select => {
+ const {
+ getBlockParentsByBlockName
+ } = select( 'core/block-editor' );
+
+ const parents = getBlockParentsByBlockName( clientId, 'themeisle-blocks/content-generator' );
+ return 0 < parents?.length;
+ }, [ clientId ]);
+
const hasEssentialData = attributes.optionName && hasProtection;
useEffect( () => {
- if ( canSaveData ) {
+ if ( canSaveData && ! isInsideAiBlock ) {
saveFormEmailOptions();
}
}, [ canSaveData ]);
@@ -266,13 +305,42 @@ const Edit = ({
}
);
-
setHasEmailField( 0 < emailFields?.length );
setShowAutoResponderNotice( 0 === emailFields?.length );
}
- }, [ children, formOptions.autoresponder, formOptions.action ]);
+ if ( formOptions.webhookId ) {
+ const allFields = findInnerBlocks(
+ children,
+ block => {
+ return block?.name?.startsWith( 'themeisle-blocks/form-' );
+ },
+ block => {
+
+ // Do not find email field inside inner Form blocks.
+ return 'themeisle-blocks/form' !== block?.name;
+ }
+ );
+
+
+ const mappedNames = [];
+ let hasDuplicateMappedNames = false;
+
+ for ( const block of allFields ) {
+ if ( block?.attributes?.mappedName ) {
+ if ( mappedNames.includes( block?.attributes?.mappedName ) ) {
+ hasDuplicateMappedNames = block.clientId;
+ break;
+ }
+ mappedNames.push( block?.attributes?.mappedName );
+ }
+ }
+
+ setShowDuplicatedMappedName( hasDuplicateMappedNames );
+ }
+
+ }, [ children, formOptions.autoresponder, formOptions.action, formOptions.webhookId ]);
/**
* Get the data from the WP Options for the current form.
@@ -303,7 +371,9 @@ const Edit = ({
hasCaptcha: wpOptions?.hasCaptcha,
autoresponder: wpOptions?.autoresponder,
autoresponderSubject: wpOptions?.autoresponderSubject,
- submissionsSaveLocation: wpOptions?.submissionsSaveLocation
+ submissionsSaveLocation: wpOptions?.submissionsSaveLocation,
+ webhookId: wpOptions?.webhookId,
+ requiredFields: wpOptions?.requiredFields
});
};
@@ -355,75 +425,46 @@ const Edit = ({
const saveFormEmailOptions = () => {
setLoading({ formOptions: 'saving' });
- try {
- ( new api.models.Settings() ).fetch().done( res => {
- const emails = res.themeisle_blocks_form_emails ? res.themeisle_blocks_form_emails : [];
- let isMissing = true;
- let hasUpdated = false;
-
- emails?.forEach( ({ form }, index ) => {
- if ( form !== attributes.optionName ) {
- return;
- }
-
- hasUpdated = Object.keys( formOptionsMap ).reduce( ( acc, key ) => {
- return acc || ! isEqual( emails[index][key], formOptions[formOptionsMap[key]]);
- }, false );
- hasUpdated = Object.keys( formOptionsMap ).some( key => ! isEqual( emails[index][key], formOptions[formOptionsMap[key]]) );
+ const data = { form: attributes.optionName };
+ formOptions.requiredFields = extractRequiredFields();
- // Update the values
- if ( hasUpdated ) {
- Object.keys( formOptionsMap ).forEach( key => emails[index][key] = formOptions[formOptionsMap[key]]);
- }
-
- isMissing = false;
- });
-
- if ( isMissing ) {
- const data = { form: attributes.optionName };
-
- Object.keys( formOptionsMap ).forEach( key => {
- data[key] = formOptions[formOptionsMap[key]];
- });
-
- emails.push( data );
- }
-
- if ( isMissing || hasUpdated ) {
- const model = new api.models.Settings({
- // eslint-disable-next-line camelcase
- themeisle_blocks_form_emails: emails
- });
+ Object.keys( formOptionsMap ).forEach( key => {
+ data[key] = formOptions[formOptionsMap[key]];
+ });
- model.save().then( response => {
- const formOptions = extractDataFromWpOptions( response.themeisle_blocks_form_emails );
- if ( formOptions ) {
- parseDataFormOptions( formOptions );
- setSavedFormOptions( formOptions );
- setLoading({ formOptions: 'done' });
- createNotice(
- 'info',
- __( 'Form options have been saved.', 'otter-blocks' ),
- {
- isDismissible: true,
- type: 'snackbar'
- }
- );
- } else {
- setLoading({ formOptions: 'error' });
- }
- });
+ try {
+ ( new DeferredWpOptionsSave() ).save( 'form_options', data, ( res, error ) => {
+ if ( error ) {
+ setLoading({ formOptions: 'error' });
} else {
setLoading({ formOptions: 'done' });
+ createNotice(
+ 'info',
+ __( 'Form options have been saved.', 'otter-blocks' ),
+ {
+ isDismissible: true,
+ type: 'snackbar'
+ }
+ );
}
- }).catch( () => setLoading({ formOptions: 'error' }) );
+ });
} catch ( e ) {
- console.error( e );
setLoading({ formOptions: 'error' });
}
};
+ const extractRequiredFields = () => {
+
+ const stripeFields = findInnerBlocks(
+ children,
+ block => 'themeisle-blocks/form-stripe-field' === block.name,
+ block => 'themeisle-blocks/form' !== block?.name
+ );
+
+ return stripeFields?.map( block => block.attributes.fieldOptionName ) || [];
+ };
+
/**
* Save integration data.
*/
@@ -553,7 +594,7 @@ const Edit = ({
} else {
createNotice(
'error',
- res?.error,
+ res?.error ?? res?.reasons?.join( '. ' ) ?? __( 'An error has occurred.', 'otter-blocks' ),
{
isDismissible: true,
type: 'snackbar',
@@ -902,7 +943,26 @@ const Edit = ({
setAttributes={ setAttributes }
/>
+
+
+ {
+ const generator = createBlock( 'themeisle-blocks/content-generator', {
+ promptID: 'form'
+ });
+
+ insertBlockBelow( clientId, generator );
+ }}
+ >
+ { __( 'Create Form With AI', 'otter-blocks' ) }
+
+
+
+
+
+
+
{
( hasInnerBlocks ) ? (
+ undefined !== formOptions.webhookId }
+ label={ __( 'Webhook', 'otter-blocks' ) }
+ onDeselect={ () => setFormOption({ webhookId: undefined }) }
+ isShownByDefault={ true }
+ >
+
+ < br />
+
+ { __( 'Edit Webhooks', 'otter-blocks' ) }
+
+
+ < br />
+
+ { __( 'Learn more about webhooks.', 'otter-blocks' ) }
+
+
+
+
{ __( 'Unlock this with Otter Pro.', 'otter-blocks' ) } }
+ variant="upsell"
+ />
+ { __( 'Trigger webhooks on Form submit action.', 'otter-blocks' ) }
+
+
) }
@@ -984,7 +1028,7 @@ const Inspector = ({
setAttributes({ inputBorderRadius }) }
id="o-border-raduis-box"
@@ -993,7 +1037,7 @@ const Inspector = ({
setAttributes({ inputBorderWidth }) }
/>
@@ -1021,7 +1065,7 @@ const Inspector = ({
{
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-hidden-field', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-stripe-field' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-stripe-field', {
+ ...attrs
+ });
+ }
}
]
}
diff --git a/src/blocks/blocks/form/multiple-choice/inspector.js b/src/blocks/blocks/form/multiple-choice/inspector.js
index b7d130482..e4d9c3b1b 100644
--- a/src/blocks/blocks/form/multiple-choice/inspector.js
+++ b/src/blocks/blocks/form/multiple-choice/inspector.js
@@ -16,11 +16,15 @@ import {
TextControl,
ToggleControl
} from '@wordpress/components';
+import { Fragment, useContext } from '@wordpress/element';
+/**
+ * Internal dependencies
+ */
import { getActiveStyle, changeActiveStyle } from '../../../helpers/helper-functions.js';
-import { fieldTypesOptions, HideFieldLabelToggle, switchFormFieldTo } from '../common';
-import { useContext } from '@wordpress/element';
+import { fieldTypesOptions, HideFieldLabelToggle, mappedNameInfo, switchFormFieldTo } from '../common';
import { FormContext } from '../edit.js';
+import { HTMLAnchorControl } from '../../../components';
const styles = [
{
@@ -45,95 +49,109 @@ const Inspector = ({
} = useContext( FormContext );
return (
-
-
- selectForm?.() }
+
+
+
- { __( 'Back to the Form', 'otter-blocks' ) }
-
-
- {
- if ( 'radio' === type || 'checkbox' === type || 'select' === type ) {
- setAttributes({ type });
- return;
- }
- switchFormFieldTo( type, clientId, attributes );
- }}
- />
-
- setAttributes({ label }) }
- />
-
-
-
- setAttributes({ options }) }
- />
+ selectForm?.() }
+ >
+ { __( 'Back to the Form', 'otter-blocks' ) }
+
+
+ {
+ if ( 'radio' === type || 'checkbox' === type || 'select' === type ) {
+ setAttributes({ type });
+ return;
+ }
+ switchFormFieldTo( type, clientId, attributes );
+ }}
+ />
+
+ setAttributes({ label }) }
+ />
+
+
+
+ setAttributes({ options }) }
+ />
+
+ setAttributes({ helpText }) }
+ />
- setAttributes({ helpText }) }
- />
-
- {
- 'select' !== attributes?.type && (
- {
- const classes = changeActiveStyle( attributes.className, styles, value ? 'inline-list' : undefined );
- setAttributes({ className: classes });
- } }
- />
- )
- }
-
- {
- 'select' === attributes?.type && (
- setAttributes({ multipleSelection }) }
- />
- )
- }
-
- setAttributes({ isRequired }) }
- />
-
+ {
+ 'select' !== attributes?.type && (
+ {
+ const classes = changeActiveStyle( attributes.className, styles, value ? 'inline-list' : undefined );
+ setAttributes({ className: classes });
+ } }
+ />
+ )
+ }
- setAttributes({ labelColor }),
- label: __( 'Label Color', 'otter-blocks' )
+ 'select' === attributes?.type && (
+ setAttributes({ multipleSelection }) }
+ />
+ )
}
- ] }
+
+ setAttributes({ isRequired }) }
+ />
+
+ setAttributes({ mappedName }) }
+ placeholder={ __( 'car_type', 'otter-blocks' ) }
+ />
+
+
+ {},
+ label: __( 'Label Color', 'otter-blocks' )
+ }
+ ] }
+ />
+
+ setAttributes({ id: value }) }
/>
-
+
);
};
diff --git a/src/blocks/blocks/form/sortable-input-fields.tsx b/src/blocks/blocks/form/sortable-input-fields.tsx
index 57d8c1454..d3e1b6339 100644
--- a/src/blocks/blocks/form/sortable-input-fields.tsx
+++ b/src/blocks/blocks/form/sortable-input-fields.tsx
@@ -40,7 +40,9 @@ const fieldNames: Record = {
'checkbox': __( 'Checkbox Field', 'otter-blocks' ),
'radio': __( 'Radio Field', 'otter-blocks' ),
'file': __( 'File Field', 'otter-blocks' ),
- 'url': __( 'URL Field', 'otter-blocks' )
+ 'url': __( 'URL Field', 'otter-blocks' ),
+ 'hidden': __( 'Hidden Field', 'otter-blocks' ),
+ 'stripe': __( 'Stripe Field', 'otter-blocks' )
};
const extractFieldName = ( input: FormInputProps ) => {
@@ -54,6 +56,14 @@ const extractFieldName = ( input: FormInputProps ) => {
return 'file';
}
+ if ( 'form-hidden-field' === tag ) {
+ return 'hidden';
+ }
+
+ if ( 'form-stripe-field' === tag ) {
+ return 'stripe';
+ }
+
return 'textarea';
};
@@ -66,7 +76,7 @@ export const SortableInputField = SortableElement( ({ item, actions } : Sortable
- { inputField?.attributes?.label ?? fieldNames[fieldName] ?? __( 'Invalid Field', 'otter-blocks' ) }
+ { 'stripe' === fieldName ? fieldNames.stripe : ( inputField?.attributes?.label ?? fieldNames[fieldName] ?? __( 'Invalid Field', 'otter-blocks' ) ) }
{
+ return (
+
+ );
+ },
+ save: () => null,
+ transforms: {
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-input' ],
+ transform: ( attributes ) => {
+
+ return createBlock( 'themeisle-blocks/form-input', {
+ ...attributes
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-textarea' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-textarea', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-multiple-choice' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-multiple-choice', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-file' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-file', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-hidden-field' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-hidden-field', {
+ ...attrs
+ });
+ }
+ }
+ ]
+ }
+ });
+
+}
diff --git a/src/blocks/blocks/form/stripe-field/inspector.js b/src/blocks/blocks/form/stripe-field/inspector.js
new file mode 100644
index 000000000..182f245fd
--- /dev/null
+++ b/src/blocks/blocks/form/stripe-field/inspector.js
@@ -0,0 +1,79 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import {
+ InspectorControls
+} from '@wordpress/block-editor';
+
+import {
+ Button,
+ ExternalLink,
+ PanelBody,
+ SelectControl,
+ TextControl
+} from '@wordpress/components';
+
+import { useContext } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+
+import { FormContext } from '../edit';
+import { Notice } from '../../../components';
+import { setUtm } from '../../../helpers/helper-functions';
+import { fieldTypesOptions, switchFormFieldTo } from '../common';
+
+
+/**
+ *
+ * @param {FormStripeFieldInspectorPros} props
+ * @returns {JSX.Element}
+ */
+const Inspector = ({
+ attributes,
+ setAttributes,
+ clientId
+}) => {
+
+ const {
+ selectForm
+ } = useContext( FormContext );
+
+ return (
+
+
+ selectForm?.() }
+ >
+ { __( 'Back to the Form', 'otter-blocks' ) }
+
+
+ {
+ if ( 'stripe' !== type ) {
+ switchFormFieldTo( type, clientId, attributes );
+ }
+ }}
+ />
+
+ { __( 'Get more options with Otter Pro. ', 'otter-blocks' ) } }
+ variant="upsell"
+ />
+
+
+
+ );
+};
+
+export default Inspector;
diff --git a/src/blocks/blocks/form/style.scss b/src/blocks/blocks/form/style.scss
index fec3f8149..e98e3274a 100644
--- a/src/blocks/blocks/form/style.scss
+++ b/src/blocks/blocks/form/style.scss
@@ -23,6 +23,10 @@
--help-font-size: 13px;
--input-bg-color: initial;
+ --stripe-border-radius: 6px;
+ --stripe-border-color: #e0e0e0;
+ --stripe-border-width: 1px;
+
// Responsive
--btn-pad: 10px 20px;
--btn-pad-tablet: var(--btn-pad);
@@ -316,7 +320,13 @@
display: none;
}
}
+ }
+ .wp-block-themeisle-blocks-form-input,
+ .wp-block-themeisle-blocks-form-textarea,
+ .wp-block-themeisle-blocks-form-multiple-choice,
+ .wp-block-themeisle-blocks-form-file,
+ .wp-block-themeisle-blocks-form-stripe-field {
@media (min-width: 640px) {
flex-grow: 1;
@@ -325,27 +335,27 @@
margin-left: 0px;
margin-right: 0px;
}
-
+
&.is-style-o-c-three-quarters {
flex-basis: calc( 75% - var( --inputs-gap ) );
max-width: 75%;
}
-
+
&.is-style-o-c-two-thirds {
flex-basis: calc( 66.66666666666666% - var( --inputs-gap ) );
max-width: 66.66666666666666%;
}
-
+
&.is-style-o-c-half {
flex-basis: calc( 50% - var( --inputs-gap ) );
max-width: 50%;
}
-
+
&.is-style-o-c-one-third {
flex-basis: calc( 33.33333333333333% - var( --inputs-gap ) );
max-width: 33.33333333333333%;
}
-
+
&.is-style-o-c-one-quarter {
flex-basis: calc( 25% - var( --inputs-gap ) );
max-width: 25%;
@@ -360,27 +370,27 @@
margin-left: 0px;
margin-right: 0px;
}
-
+
&.is-style-o-c-three-quarters {
flex-basis: calc( 75% - var( --inputs-gap ) );
max-width: 75%;
}
-
+
&.is-style-o-c-two-thirds {
flex-basis: calc( 66.66666666666666% - var( --inputs-gap ) );
max-width: 66.66666666666666%;
}
-
+
&.is-style-o-c-half {
flex-basis: calc( 50% - var( --inputs-gap ) );
max-width: 50%;
}
-
+
&.is-style-o-c-one-third {
flex-basis: calc( 33.33333333333333% - var( --inputs-gap ) );
max-width: 33.33333333333333%;
}
-
+
&.is-style-o-c-one-quarter {
flex-basis: calc( 25% - var( --inputs-gap ) );
max-width: 25%;
@@ -406,6 +416,10 @@
}
}
+ .o-form-redirect-link {
+ color: var(--submit-msg-color);
+ }
+
.o-form-server-response {
text-align: left;
padding: 1rem;
@@ -485,6 +499,63 @@
.wp-block-themeisle-blocks-form-file input[type="file"] {
border: 0px;
}
+
+ .wp-block-themeisle-blocks-form-stripe-field {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ border-width: var(--stripe-border-width);
+ border-style: solid;
+ border-color: var(--stripe-border-color);
+ border-radius: var(--stripe-border-radius);
+
+ .o-stripe-checkout {
+ display: flex;
+ padding: 10px;
+
+ img {
+ border-radius: 4px;
+ margin: 10px;
+ width: 56px;
+ height: 56px;
+ border: 0;
+ }
+
+ .o-stripe-checkout-description {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ flex-grow: 1;
+
+ h3 {
+ font-style: normal;
+ font-weight: 500;
+ font-size: calc( var(--label-font-size) * 1.5 );
+ line-height: 20px;
+ letter-spacing: -0.154px;
+ margin: 0;
+ padding-top: 0;
+ color: var(--label-color);
+ }
+
+ h5 {
+ opacity: .5;
+ margin-top: 0;
+ padding-top: 0;
+ font-style: normal;
+ font-weight: 500;
+ font-size: calc( var(--label-font-size) * 1.5 );
+ line-height: 20px;
+ letter-spacing: -0.154px;
+ margin: 0;
+ color: var(--label-color);
+ }
+ }
+ }
+ }
+
}
.o-form-multiple-choice-field {
diff --git a/src/blocks/blocks/form/textarea/index.js b/src/blocks/blocks/form/textarea/index.js
index 836e24485..f58e3b748 100644
--- a/src/blocks/blocks/form/textarea/index.js
+++ b/src/blocks/blocks/form/textarea/index.js
@@ -68,6 +68,26 @@ registerBlockType( name, {
...attrs
});
}
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-hidden-field' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-hidden-field', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-stripe-field' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-stripe-field', {
+ ...attrs
+ });
+ }
}
]
}
diff --git a/src/blocks/blocks/form/textarea/inspector.js b/src/blocks/blocks/form/textarea/inspector.js
index a324c7da9..42188c15a 100644
--- a/src/blocks/blocks/form/textarea/inspector.js
+++ b/src/blocks/blocks/form/textarea/inspector.js
@@ -12,9 +12,14 @@ import {
TextControl,
ToggleControl
} from '@wordpress/components';
-import { FieldInputWidth, fieldTypesOptions, HideFieldLabelToggle, switchFormFieldTo } from '../common';
+import { Fragment, useContext } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { FieldInputWidth, fieldTypesOptions, HideFieldLabelToggle, mappedNameInfo, switchFormFieldTo } from '../common';
import { FormContext } from '../edit';
-import { useContext } from '@wordpress/element';
+import { HTMLAnchorControl } from '../../../components';
const Inspector = ({
attributes,
@@ -27,60 +32,75 @@ const Inspector = ({
} = useContext( FormContext );
return (
-
-
- selectForm?.() }
+
+
+
- { __( 'Back to the Form', 'otter-blocks' ) }
-
-
- {
- if ( 'textarea' === type ) {
- return;
- }
- switchFormFieldTo( type, clientId, attributes );
- }}
- />
-
- setAttributes({ label }) }
- />
-
-
-
-
-
- setAttributes({ placeholder }) }
- />
-
- setAttributes({ helpText }) }
- />
-
- setAttributes({ isRequired }) }
- />
-
-
+ selectForm?.() }
+ >
+ { __( 'Back to the Form', 'otter-blocks' ) }
+
+
+ {
+ if ( 'textarea' === type ) {
+ return;
+ }
+ switchFormFieldTo( type, clientId, attributes );
+ }}
+ />
+
+ setAttributes({ label }) }
+ />
+
+
+
+
+
+ setAttributes({ placeholder }) }
+ />
+
+ setAttributes({ helpText }) }
+ />
+
+ setAttributes({ isRequired }) }
+ />
+
+ setAttributes({ mappedName }) }
+ placeholder={ __( 'message', 'otter-blocks' ) }
+ />
+
+
+ setAttributes({ id: value }) }
+ />
+
+
);
};
diff --git a/src/blocks/blocks/form/type.d.ts b/src/blocks/blocks/form/type.d.ts
index b8e4534a5..1a178e459 100644
--- a/src/blocks/blocks/form/type.d.ts
+++ b/src/blocks/blocks/form/type.d.ts
@@ -63,7 +63,8 @@ export type FormOptions = {
subject?: string
body?: string
}
- submissionSaveLocation?: string
+ submissionsSaveLocation?: string
+ webhookId?: string
}
export type FormAttrs = Partial
diff --git a/src/blocks/blocks/icon-list/inspector.js b/src/blocks/blocks/icon-list/inspector.js
index 3062bc6d2..10e20e24c 100644
--- a/src/blocks/blocks/icon-list/inspector.js
+++ b/src/blocks/blocks/icon-list/inspector.js
@@ -221,7 +221,7 @@ const Inspector = ({
initialOpen={ false }
>
setAttributes({ contentColor }),
- label: __( 'Content Color', 'otter-blocks' ),
+ label: __( 'Content', 'otter-blocks' ),
isShownByDefault: true
},
...( 'image' !== attributes.library ? [
{
value: attributes.iconColor,
onChange: iconColor => setAttributes({ iconColor }),
- label: __( 'Icon Color', 'otter-blocks' ),
+ label: __( 'Icon', 'otter-blocks' ),
isShownByDefault: true
}
] : [])
diff --git a/src/blocks/blocks/index.js b/src/blocks/blocks/index.js
index 79bee1911..b51b0798e 100644
--- a/src/blocks/blocks/index.js
+++ b/src/blocks/blocks/index.js
@@ -23,3 +23,4 @@ import './slider/index.js';
import './stripe-checkout/index.js';
import './tabs/index.js';
import './deprecated/index.js';
+import './content-generator/index.js';
diff --git a/src/blocks/blocks/popup/inspector.js b/src/blocks/blocks/popup/inspector.js
index 42e56c1da..4afeb0fec 100644
--- a/src/blocks/blocks/popup/inspector.js
+++ b/src/blocks/blocks/popup/inspector.js
@@ -394,7 +394,7 @@ const Inspector = ({
{
setAttributes({
diff --git a/src/blocks/blocks/progress-bar/inspector.js b/src/blocks/blocks/progress-bar/inspector.js
index 211115bf1..4f97894f7 100644
--- a/src/blocks/blocks/progress-bar/inspector.js
+++ b/src/blocks/blocks/progress-bar/inspector.js
@@ -1,9 +1,13 @@
+/**
+ * External dependencies
+ */
+import { clamp } from 'lodash';
+
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
-import { clamp } from 'lodash';
import {
InspectorControls,
@@ -18,6 +22,14 @@ import {
TextControl,
FontSizePicker
} from '@wordpress/components';
+import { Fragment, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+*/
+import { InspectorHeader } from '../../components';
+import { useTabSwitch } from '../../helpers/block-utility';
+
const defaultFontSizes = [
{
@@ -54,6 +66,9 @@ const Inspector = ({
heightMode,
setHeightMode
}) => {
+
+ const [ tab, setTab ] = useTabSwitch( attributes.id, 'settings' );
+
const onPercentageChange = value => {
if ( value === undefined ) {
return ;
@@ -83,137 +98,163 @@ const Inspector = ({
return (
-
- setAttributes({ title }) }
- />
-
-
-
-
-
-
-
- setAttributes({ titleColor }),
- label: __( 'Title', 'otter-blocks' ),
- isShownByDefault: false
- },
- {
- value: attributes.barBackgroundColor,
- onChange: barBackgroundColor => setAttributes({ barBackgroundColor }),
- label: __( 'Progress', 'otter-blocks' ),
- isShownByDefault: false
- },
+ setAttributes({ percentageColor }),
- label: __( 'Percentage', 'otter-blocks' ),
- isShownByDefault: false
+ label: __( 'Settings', 'otter-blocks' ),
+ value: 'settings'
},
{
- value: attributes.backgroundColor,
- onChange: backgroundColor => setAttributes({ backgroundColor }),
- label: __( 'Background', 'otter-blocks' ),
- isShownByDefault: false
+ label: __( 'Style', 'otter-blocks' ),
+ value: 'style'
}
- ] }
- >
-
-
-
-
- { 30 <= attributes.height && (
- setAttributes({ titleStyle }) }
- />
- ) }
-
-
-
-
-
- setAttributes({ borderRadius }) }
- step={ 0.1 }
- initialPosition={ 5 }
- min={ 0 }
- max={ 35 }
- />
+ ]}
+ onChange={ setTab }
+ />
+
{
- ( ( 'outer' === attributes.titleStyle ) || ( 'tooltip' === attributes.percentagePosition && 'outer' === attributes.percentagePosition ) ) && (
-
- setAttributes({ titleFontSize }) }
+ setAttributes({ title }) }
+ />
+
+
-
+
+ )
+ }
+ {
+ 'style' === tab && (
+
+
+
+
+ { 30 <= attributes.height && (
+ setAttributes({ titleStyle }) }
+ />
+ ) }
+
+
+
+ {
+ ( ( 'outer' === attributes.titleStyle ) || ( 'tooltip' === attributes.percentagePosition && 'outer' === attributes.percentagePosition ) ) && (
+
+ setAttributes({ titleFontSize }) }
+ />
+
+ )
+ }
+
+ setAttributes({ titleColor }),
+ label: __( 'Title', 'otter-blocks' )
+ },
+ {
+ value: attributes.barBackgroundColor,
+ onChange: barBackgroundColor => setAttributes({ barBackgroundColor }),
+ label: __( 'Progress', 'otter-blocks' )
+ },
+ {
+ value: attributes.percentageColor,
+ onChange: percentageColor => setAttributes({ percentageColor }),
+ label: __( 'Percentage', 'otter-blocks' )
+ },
+ {
+ value: attributes.backgroundColor,
+ onChange: backgroundColor => setAttributes({ backgroundColor }),
+ label: __( 'Background', 'otter-blocks' )
+ }
+ ] }
+ >
+
+
+
+
+
+
+
+ setAttributes({ borderRadius }) }
+ step={ 0.1 }
+ initialPosition={ 5 }
+ min={ 0 }
+ max={ 35 }
+ />
+
+
)
}
+
-
);
};
diff --git a/src/blocks/blocks/review/block.json b/src/blocks/blocks/review/block.json
index 5c1935128..653b7c894 100644
--- a/src/blocks/blocks/review/block.json
+++ b/src/blocks/blocks/review/block.json
@@ -70,16 +70,18 @@
"links": {
"type": "array",
"default": [
- {
- "label": "Buy on Amazon",
- "href": "",
- "isSponsored": false
- },
- {
- "label": "Buy on eBay",
- "href": "",
- "isSponsored": false
- }
+ {
+ "label": "Buy on Amazon",
+ "href": "",
+ "isSponsored": false,
+ "target": "_blank"
+ },
+ {
+ "label": "Buy on eBay",
+ "href": "",
+ "isSponsored": false,
+ "target": "_blank"
+ }
]
},
"prosLabel": {
diff --git a/src/blocks/blocks/review/inspector.js b/src/blocks/blocks/review/inspector.js
index 14d423e74..c91a00753 100644
--- a/src/blocks/blocks/review/inspector.js
+++ b/src/blocks/blocks/review/inspector.js
@@ -598,6 +598,12 @@ const Inspector = ({
checked={ link.isSponsored }
disabled={ attributes.product }
/>
+
+
) ) }
@@ -630,6 +636,12 @@ const Inspector = ({
checked={ link.isSponsored }
onChange={ () => onChangeLink({ action: 'update', index, value: { isSponsored: ! link.isSponsored }}) }
/>
+
+ onChangeLink({ action: 'update', index, value: { target: '_blank' === link.target || undefined === link.target ? '_self' : '_blank' }}) }
+ />
) ) }
@@ -704,7 +716,7 @@ const Inspector = ({
/>
setAttributes({ boxShadowColor: value }) }
+ onColorChange={ value => setAttributes({
+ boxShadowColor: ( 100 > attributes.boxShadowColorOpacity && value?.includes( 'var(' ) ) ?
+ getComputedStyle( document.documentElement, null ).getPropertyValue( value?.replace( 'var(', '' )?.replace( ')', '' ) ) :
+ value
+ }) }
/>
setAttributes({ boxShadowColorOpacity: value }) }
+ onChange={ value => {
+ const changes = { boxShadowColorOpacity: value };
+
+ /**
+ * If the value is less than 100 and the color is CSS a variable, then replace the CSS variable with the computed value.
+ * This is needed because the way calculate the opacity of the color is by using the HEX value since we are doing in the server side.
+ */
+ if ( 100 > value && attributes.boxShadowColor?.includes( 'var(' ) ) {
+ changes.boxShadowColor = getComputedStyle( document.documentElement, null ).getPropertyValue( attributes.boxShadowColor.replace( 'var(', '' ).replace( ')', '' ) );
+ }
+
+ setAttributes( changes );
+ } }
min={ 0 }
max={ 100 }
/>
diff --git a/src/blocks/blocks/section/columns/edit.js b/src/blocks/blocks/section/columns/edit.js
index 995dd8567..813cfa52a 100644
--- a/src/blocks/blocks/section/columns/edit.js
+++ b/src/blocks/blocks/section/columns/edit.js
@@ -308,7 +308,7 @@ const Edit = ({
if ( true === attributes.boxShadow ) {
boxShadowStyle = {
- boxShadow: `${ attributes.boxShadowHorizontal }px ${ attributes.boxShadowVertical }px ${ attributes.boxShadowBlur }px ${ attributes.boxShadowSpread }px ${ hexToRgba( ( attributes.boxShadowColor ? attributes.boxShadowColor : '#000000' ), attributes.boxShadowColorOpacity ) }`
+ boxShadow: `${ attributes.boxShadowHorizontal }px ${ attributes.boxShadowVertical }px ${ attributes.boxShadowBlur }px ${ attributes.boxShadowSpread }px ${ attributes.boxShadowColor.includes( 'var(' ) && ( attributes.boxShadowColorOpacity === undefined || 100 === attributes.boxShadowColorOpacity ) ? attributes.boxShadowColor : hexToRgba( ( attributes.boxShadowColor ? attributes.boxShadowColor : '#000000' ), attributes.boxShadowColorOpacity ) }`
};
}
diff --git a/src/blocks/blocks/section/columns/inspector.js b/src/blocks/blocks/section/columns/inspector.js
index 0da7a788c..770e61ced 100644
--- a/src/blocks/blocks/section/columns/inspector.js
+++ b/src/blocks/blocks/section/columns/inspector.js
@@ -917,13 +917,30 @@ const Inspector = ({
setAttributes({ boxShadowColor: value }) }
+ onColorChange={ value => setAttributes({
+ boxShadowColor: ( 100 > attributes.boxShadowColorOpacity && value?.includes( 'var(' ) ) ?
+ getComputedStyle( document.documentElement, null ).getPropertyValue( value?.replace( 'var(', '' )?.replace( ')', '' ) ) :
+ value
+ }) }
/>
setAttributes({ boxShadowColorOpacity: value }) }
+ onChange={ value => {
+
+ const changes = { boxShadowColorOpacity: value };
+
+ /**
+ * If the value is less than 100 and the color is CSS a variable, then replace the CSS variable with the computed value.
+ * This is needed because the way calculate the opacity of the color is by using the HEX value since we are doing in the server side.
+ */
+ if ( 100 > value && attributes.boxShadowColor?.includes( 'var(' ) ) {
+ changes.boxShadowColor = getComputedStyle( document.documentElement, null ).getPropertyValue( attributes.boxShadowColor.replace( 'var(', '' ).replace( ')', '' ) );
+ }
+
+ setAttributes( changes );
+ } }
min={ 0 }
max={ 100 }
/>
diff --git a/src/blocks/blocks/slider/edit.js b/src/blocks/blocks/slider/edit.js
index 9fdcd9cd7..578f5e429 100644
--- a/src/blocks/blocks/slider/edit.js
+++ b/src/blocks/blocks/slider/edit.js
@@ -66,6 +66,7 @@ const Edit = ({
const initObserver = useRef( null );
const sliderRef = useRef( null );
+ const containerRef = useRef( null );
useEffect( () => {
try {
@@ -89,37 +90,34 @@ const Edit = ({
useEffect( () => {
- const container = document.querySelector( `#${ attributes.id }` ) ?? getEditorIframe()?.contentDocument?.querySelector( `#${ attributes.id }` );
-
- if ( container ) {
+ if ( containerRef.current ) {
initObserver.current = new IntersectionObserver( ( entries ) => {
entries.forEach( entry => {
if ( entry.isIntersecting && 0 <= entry.intersectionRect.height ) {
- if ( attributes.images.length ) {
+ if ( attributes.images && 0 < attributes.images.length ) {
initSlider();
- initObserver.current?.unobserve( container );
+ initObserver.current?.unobserve( containerRef.current );
}
}
});
}, options );
- initObserver.current?.observe( container );
+ initObserver.current?.observe( containerRef.current );
}
return () => {
if ( attributes?.images?.length ) {
sliderRef?.current?.destroy();
}
+ initObserver.current?.disconnect();
};
}, [ attributes.id ]);
useEffect( () => {
- if ( attributes.images.length ) {
+ if ( attributes.images && 0 < attributes.images.length && attributes.id ) {
setSelectedImage( null );
- if ( null !== sliderRef.current && attributes.id ) {
- initSlider();
- }
+ initSlider();
}
}, [ isSelected, attributes.id, sliderRef.current, attributes.images, attributes.width ]);
@@ -136,12 +134,12 @@ const Edit = ({
const initSlider = () => {
// Clean up old references.
- if ( null !== sliderRef.current ) {
+ if ( Boolean( sliderRef.current ) ) {
sliderRef.current?.destroy?.();
+ sliderRef.current = undefined;
}
const iframe = getEditorIframe();
- const container = document?.querySelector( `#${ attributes.id }` ) ?? iframe?.contentDocument?.querySelector( `#${ attributes.id }` );
const config = {
type: 'carousel',
@@ -161,25 +159,37 @@ const Edit = ({
}
};
+ // This will prevent the slider from initializing if the block is not in the DOM that it can reach (like Inserter block preview)
+ if ( ! Boolean( document.querySelector( `#${ attributes.id }` ) ?? iframe?.contentDocument?.querySelector( `#${ attributes.id }` ) ) ) {
+ return;
+ }
+
/**
* Init the Slider inside the iframe.
*/
- if ( Boolean( iframe ) ) {
- const initFrame = () => {
- if ( iframe?.contentWindow?.Glide ) {
- sliderRef.current = new iframe.contentWindow.Glide( container, config ).mount();
- }
- };
+ try {
+ if ( Boolean( iframe ) ) {
+ const initFrame = () => {
+ if ( iframe?.contentWindow?.Glide ) {
+ sliderRef.current = new iframe.contentWindow.Glide( containerRef.current, config ).mount();
+ }
+ };
- if ( ! Boolean( iframe.contentDocument?.querySelector( '#glidejs-js' ) ) ) {
+ if ( ! Boolean( iframe.contentDocument?.querySelector( '#glidejs-js' ) ) ) {
- // Load the JS file into the iframe.
- copyScriptAssetToIframe( '#glidejs-js', initFrame );
+ // Load the JS file into the iframe.
+ copyScriptAssetToIframe( '#glidejs-js', initFrame );
+ } else {
+ initFrame();
+ }
} else {
- initFrame();
+ sliderRef.current = new window.Glide( containerRef.current, config ).mount();
}
- } else {
- sliderRef.current = new window.Glide( container, config ).mount();
+ } catch ( e ) {
+
+ // We don't want to break the block if the slider fails to init.
+ // The main cause is when the block runs inside an iFrame and can not access the root node.
+ console.warn( e );
}
};
@@ -192,8 +202,6 @@ const Edit = ({
caption: image.caption
}) )
});
-
- initSlider();
};
const changePerView = value => {
@@ -257,6 +265,7 @@ const Edit = ({
id={ attributes.id }
className="glide"
style={ inlineStyles }
+ ref={ containerRef }
>
{
return new Promise( resolve => {
@@ -50,6 +51,20 @@ const GridList = ({
setSortingItemKey( null );
setSelectedItems([]);
onSelectImages( newItems );
+
+ // Remove all extra nodes that react-sortable-hoc adds to the DOM but doesn't remove after sorting is done.
+ document.querySelectorAll( '.o-images-grid-component__image' ).forEach( node => {
+ if ( ! containerRef.current?.container.contains?.( node ) ) {
+
+ // Hide the node until it can be removed to prevent a flash of unstyled content.
+ node.style.display = 'none';
+ setTimeout( () => {
+
+ // Remove the node after a short delay to allow the transition to finish.
+ node.remove();
+ }, 250 );
+ }
+ });
};
const handleItemSelect = item => {
@@ -96,6 +111,7 @@ const GridList = ({
onSortEnd={ onSortEnd }
distance={ 3 }
axis="xy"
+ ref={ containerRef }
/>
);
};
diff --git a/src/blocks/components/image-grid/SortableList.js b/src/blocks/components/image-grid/SortableList.js
index b3f1d5d25..53539784f 100644
--- a/src/blocks/components/image-grid/SortableList.js
+++ b/src/blocks/components/image-grid/SortableList.js
@@ -27,12 +27,14 @@ const SortableList = SortableContainer( ({
selectedItems,
isSorting,
sortingItemKey,
- open
+ open,
+ ref
}) => {
return (
{ items.map( ( item, index ) => {
const isSelected = selectedItems.includes( item );
diff --git a/src/blocks/components/index.js b/src/blocks/components/index.js
index 9b58ceca7..9f38d1c21 100644
--- a/src/blocks/components/index.js
+++ b/src/blocks/components/index.js
@@ -42,3 +42,5 @@ export {
export { default as SyncColorPanel } from './sync-color-panel/';
export { default as SyncControlDropdown } from './sync-control-dropdown/index';
export { default as ToogleGroupControl } from './toogle-group-control/index.js';
+
+export { default as PrmptPlaceholder } from './prompt';
diff --git a/src/blocks/components/prompt/editor.scss b/src/blocks/components/prompt/editor.scss
new file mode 100644
index 000000000..b939833b2
--- /dev/null
+++ b/src/blocks/components/prompt/editor.scss
@@ -0,0 +1,128 @@
+.prompt-placeholder {
+ fieldset {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .history-display {
+ padding: 0px 20px;
+ font-size: 1.2em;
+ }
+
+ .o-info-row {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ }
+}
+
+.prompt-fields {
+ margin: 10px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ padding: 10px;
+ border-radius: 10px;
+ gap: 10px;
+ font-size: 13px;
+
+ .prompt-column-title {
+ text-transform: uppercase;
+ }
+
+ .prompt-field__label, .prompt-field__type {
+ padding: 12px 16px;
+ }
+
+ .prompt-field__label {
+ border-radius: 3px;
+ background: rgba(159, 171, 178, 0.20);
+ }
+}
+
+.prompt-input__container {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ align-items: center;
+ justify-content: center;
+ margin-top: 10px;
+
+ .prompt-input__input__container {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ flex-grow: 1;
+ align-items: center;
+
+ border: 1px solid #949494;
+ padding: 6px 6px 6px 14px;
+ }
+
+ .prompt-input__input {
+ margin: 0;
+ font-size: 18px;
+ border: unset;
+ flex-grow: 1;
+ outline: unset;
+ max-height: 500px;
+ appearance: none;
+ padding: 13px;
+ min-height: 52px;
+ resize: none;
+ }
+
+ .prompt-input__submit__container button {
+ font-size: 18px;
+ padding: 26px;
+ }
+}
+
+.prompt-result__container {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 10px;
+
+ background-color: white;
+ border-radius: 2px;
+ border: 1px solid #1E1E1E;
+ width: 100%;
+ max-width: 800px;
+ padding: 10px;
+
+ .prompt-result__header {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ align-items: center;
+
+ .prompt-result__header__title {
+ font-size: 1.2em;
+ font-weight: bold;
+ flex-grow: 1;
+ }
+ }
+
+ .prompt-result__actions {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ align-items: center;
+ border-top: 1px solid #DDD;
+ padding-top: 10px;
+
+ .prompt-result__actions__navigation {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ align-items: center;
+ flex-grow: 1;
+ justify-content: flex-end;
+ }
+ }
+
+ .prompt-token-usage {
+ color: #757575;
+ font-size: 14px;
+ }
+}
diff --git a/src/blocks/components/prompt/index.tsx b/src/blocks/components/prompt/index.tsx
new file mode 100644
index 000000000..a56059266
--- /dev/null
+++ b/src/blocks/components/prompt/index.tsx
@@ -0,0 +1,484 @@
+/**
+ * WordPress dependencies
+ */
+
+import { __ } from '@wordpress/i18n';
+import { closeSmall, redo, undo } from '@wordpress/icons';
+import { ReactNode } from 'react';
+import { Button, ExternalLink, Notice, Placeholder, Spinner, TextControl } from '@wordpress/components';
+import { Fragment, useEffect, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+
+import useSettings from '../../helpers/use-settings';
+import {
+ PromptsData,
+ injectActionIntoPrompt,
+ retrieveEmbeddedPrompt,
+ sendPromptToOpenAI, sendPromptToOpenAIWithRegenerate
+} from '../../helpers/prompt';
+import PromptInput from './prompt-input';
+import './editor.scss';
+
+type PromptPlaceholderProps = {
+ promptID?: string
+ promptPlaceholder?: string
+ title?: string
+ value: string
+ onValueChange: ( text: string ) => void
+ children?: ReactNode
+ onClose?: () => void
+ mainActionLabel?: string
+ onPreview?: ( result: string ) => void
+ actionButtons?: ( props: {status?: string}) => ReactNode
+ resultHistory?: {result: string, meta: { usedToken: number, prompt: string }}[]
+};
+
+export const openAiAPIKeyName = 'themeisle_open_ai_api_key';
+
+const PromptBlockEditor = (
+ props: {
+ children?: ReactNode
+ onRegenerate?: () => void
+ onPrevResult?: () => void
+ onNextResult?: () => void
+ currentResultIndex?: number
+ totalResults: number
+ title?: string
+ onClose?: () => void
+ tokenUsageDescription?: string
+ actionButtons?: ReactNode
+ status?: string
+ currentPrompt?: string
+ showRegenerate?: boolean
+ }
+) => {
+ return (
+
+
+
+ { props?.title ?? __( 'Result', 'otter-blocks' ) }
+
+
+
+
+
+
+ { props.children }
+
+
+
+ { props.actionButtons }
+
+ {
+ props.showRegenerate && (
+
+ { __( 'Regenerate', 'otter-blocks' ) }
+
+ )
+ }
+
+
+ {
+ 0 < props.totalResults && (
+
+
+
+
+ { props.currentResultIndex } / { props.totalResults }
+
+
+
+
+ )
+ }
+
+
+
+ {
+ props.tokenUsageDescription
+ }
+
+
+ );
+};
+
+const TrackingConsentToggle = ( props: {onToggle: ( value: boolean ) => void, value: boolean, onClose: () => void}) => {
+ return (
+
+
+ {
+ props.onToggle( event.target.checked );
+ }}
+ name="o-tracking-consent-toggle"
+ />
+
+
+ { __( 'Help us improve the AI block by allowing anonymous usage tracking.', 'otter-blocks' ) }
+
+
+
+
+
+ );
+};
+
+const PromptPlaceholder = ( props: PromptPlaceholderProps ) => {
+ const { title, value, onValueChange, promptID } = props;
+
+ const [ getOption, updateOption, status ] = useSettings();
+ const [ apiKey, setApiKey ] = useState
( null );
+
+ const [ showTrackingConsent, setShowTrackingConsent ] = useState( false );
+ const [ trackingConsent, setTrackingConsent ] = useState( false );
+
+ const [ generationStatus, setGenerationStatus ] = useState<'loading' | 'loaded' | 'error'>( 'loaded' );
+
+ const [ apiKeyStatus, setApiKeyStatus ] = useState<'checking' | 'missing' | 'present' | 'error'>( 'checking' );
+ const [ embeddedPrompts, setEmbeddedPrompts ] = useState([]);
+ const [ result, setResult ] = useState( undefined );
+
+ const [ resultHistory, setResultHistory ] = useState<{result: string, meta: { usedToken: number, prompt: string }}[]>( props.resultHistory ?? []);
+ const [ resultHistoryIndex, setResultHistoryIndex ] = useState( 0 );
+
+ const [ showResultArea, setShowResultArea ] = useState( false );
+
+ const [ showError, setShowError ] = useState( false );
+ const [ errorMessage, setErrorMessage ] = useState( '' );
+ const [ tokenUsageDescription, setTokenUsageDescription ] = useState( '' );
+
+ const onSuccessActions = {
+ clearHistory: () => {
+ setResult( undefined );
+ setResultHistory([]);
+ setResultHistoryIndex( 0 );
+ }
+ };
+
+ const onToggleTrackingConsent = ( value: boolean ) => {
+ updateOption( 'otter_blocks_logger_flag', value ? 'yes' : '', __( 'Tracking consent saved.', 'otter-blocks' ), 'o-tracking-consent', () => {
+ if ( value ) {
+ setShowTrackingConsent( false );
+ }
+ });
+
+ setTrackingConsent( value );
+ };
+
+ useEffect( () => {
+ setShowTrackingConsent( ! Boolean( localStorage.getItem( 'o-tracking-consent' ) ) );
+ }, []);
+
+ useEffect( () => {
+ const getEmbeddedPrompt = async() => {
+ retrieveEmbeddedPrompt( promptID ).then( ( promptServer ) => {
+ setEmbeddedPrompts( promptServer.prompts );
+ });
+ };
+
+ getEmbeddedPrompt();
+ }, []);
+
+ useEffect( () => {
+ if ( 'loading' === status ) {
+ return;
+ }
+
+ if ( 'loaded' === status ) {
+ if ( getOption( openAiAPIKeyName ) ) {
+ setApiKeyStatus( 'present' );
+ setApiKey( getOption( openAiAPIKeyName ) );
+ } else {
+ setApiKeyStatus( 'missing' );
+ }
+ if ( 'yes' === getOption( 'otter_blocks_logger_flag' ) ) {
+ setTrackingConsent( true );
+ setShowTrackingConsent( false );
+ }
+ }
+
+ if ( 'error' === status ) {
+ setApiKeyStatus( 'error' );
+ }
+ }, [ status, getOption ]);
+
+ useEffect( () => {
+ setResultHistoryIndex( resultHistory.length - 1 );
+ }, [ resultHistory ]);
+
+ useEffect( () => {
+
+ if ( 0 > resultHistoryIndex ) {
+ return;
+ }
+
+ if ( resultHistoryIndex > resultHistory.length - 1 ) {
+ return;
+ }
+
+ setResult( resultHistory?.[ resultHistoryIndex ].result );
+ setTokenUsageDescription( __( 'Used tokens: ', 'otter-blocks' ) + resultHistory[ resultHistoryIndex ].meta.usedToken );
+ props.onPreview?.( resultHistory[ resultHistoryIndex ].result );
+
+ }, [ resultHistoryIndex, resultHistory ]);
+
+ function onPromptSubmit( regenerate = false ) {
+
+ let embeddedPrompt = embeddedPrompts?.find( ( prompt ) => prompt.otter_name === promptID );
+
+ if ( ! embeddedPrompt ) {
+ setShowError( true );
+ setErrorMessage( __( 'Prompt not found. Reload the page. If the error still persist the server might be down.', 'otter-blocks' ) );
+ return;
+ }
+
+ // TODO: refactor this into a more reusable way
+ if ( 'textTransformation' === promptID ) {
+ const action = embeddedPrompt?.['otter_action_prompt'] ?? '';
+ embeddedPrompt = injectActionIntoPrompt( embeddedPrompt, action );
+ }
+
+ if ( ! apiKey ) {
+ setShowError( true );
+ setErrorMessage( __( 'API Key not found. Please add your API Key in the settings page.', 'otter-blocks' ) );
+ return;
+ }
+
+ setGenerationStatus( 'loading' );
+
+ const sendPrompt = regenerate ? sendPromptToOpenAIWithRegenerate : sendPromptToOpenAI;
+
+ sendPrompt?.( value, embeddedPrompt, {
+ 'otter_used_action': 'textTransformation' === promptID ? 'textTransformation::otter_action_prompt' : ( promptID ?? '' ),
+ 'otter_user_content': value
+ }).then ( ( data ) => {
+ if ( data?.error ) {
+ setGenerationStatus( 'error' );
+ setShowError( true );
+ setErrorMessage( `Error ${data.error.code} - ${data.error.message}` ?? __( 'Something went wrong. Please try again.', 'otter-blocks' ) );
+ return;
+ }
+
+ const result = data?.choices?.[0]?.message?.function_call?.arguments ?? data?.choices?.[0]?.message?.content;
+
+ setGenerationStatus( 'loaded' );
+
+ if ( ! result ) {
+ setShowError( true );
+ setErrorMessage( __( 'Empty response from OpenAI. Please try again.', 'otter-blocks' ) );
+ return;
+ }
+
+ setResult( result );
+ if ( regenerate ) {
+ const newResultHistory = [ ...resultHistory ];
+ newResultHistory[ resultHistoryIndex ] = {
+ result,
+ meta: {
+ usedToken: data.usage.total_tokens,
+ prompt: value
+ }
+ };
+ setResultHistory( newResultHistory );
+ } else {
+ setResultHistory([ ...resultHistory, {
+ result,
+ meta: {
+ usedToken: data.usage.total_tokens,
+ prompt: value
+ }
+ }]);
+ setResultHistoryIndex( resultHistory.length );
+
+ }
+ setShowResultArea( true );
+ setTokenUsageDescription( __( 'Token used: ', 'otter-blocks' ) + data.usage.total_tokens );
+ props.onPreview?.( result );
+ });
+ }
+
+ if ( 'present' !== apiKeyStatus ) {
+ return (
+
+ {
+ 'checking' === apiKeyStatus && (
+
+
+ { __( 'Checking the api key...', 'otter-blocks' ) }
+
+ )
+ }
+
+ {
+ 'missing' === apiKeyStatus && (
+
+ { __( 'API Key not found. Please introduce the API Key', 'otter-blocks' ) }
+
+ {
+ setApiKey( text );
+ }}
+ />
+
+ {
+
+ if ( ! apiKey ) {
+ return;
+ }
+
+ updateOption( openAiAPIKeyName, apiKey.slice(), __( 'Open AI API Key saved.', 'otter-blocks' ), 'o-api-key', () => {
+ setApiKey( '' );
+ });
+ setApiKeyStatus( 'checking' );
+
+ }}
+ isBusy={'loading' === status}
+ >
+
+ { 'loading' !== status && __( 'Save', 'otter-blocks' ) }
+ { 'loading' === status && (
+
+ { __( 'Saving', 'otter-blocks' ) }
+
+ ) }
+
+
+
+
+
+
+ { __( 'Get your API Key', 'otter-blocks' ) }
+
+
+
+ { __( 'More Info', 'otter-blocks' ) }
+
+
+
+
+ )
+ }
+
+ );
+
+ }
+
+ return (
+
+ {
+ ( 0 < resultHistory?.length ) ? (
+
{
+ setResultHistoryIndex( resultHistoryIndex - 1 );
+ }}
+ onNextResult={() => {
+ setResultHistoryIndex( resultHistoryIndex + 1 );
+ }}
+ onClose={() => {
+ props.onClose?.();
+ }}
+ tokenUsageDescription={tokenUsageDescription}
+ onRegenerate={() => onPromptSubmit( true )}
+ actionButtons={props.actionButtons?.({
+ status: generationStatus
+ })}
+ status={generationStatus}
+ currentPrompt={value}
+ showRegenerate={ Boolean( resultHistory?.[ resultHistoryIndex ]?.meta?.prompt ) }
+ >
+
+ {
+ setShowError( false );
+ onPromptSubmit();
+ }}
+ status={generationStatus}
+ placeholder={ props.promptPlaceholder }
+ />
+
+ {/*{*/}
+ {/* showTrackingConsent && (*/}
+ {/* {*/}
+ {/* setShowTrackingConsent( false );*/}
+ {/* localStorage.setItem( 'o-tracking-consent', 'true' );*/}
+ {/* }}*/}
+ {/* />*/}
+ {/* )*/}
+ {/*}*/}
+
+ {props.children}
+
+ ) : (
+
onPromptSubmit()}
+ status={generationStatus}
+ placeholder={ props.promptPlaceholder }
+ />
+ )
+ }
+
+ {
+ showError && (
+ setShowError( false )}
+ >
+ {
+ errorMessage
+ }
+
+ )
+ }
+
+
+ );
+};
+
+export default PromptPlaceholder;
diff --git a/src/blocks/components/prompt/prompt-input.tsx b/src/blocks/components/prompt/prompt-input.tsx
new file mode 100644
index 000000000..be8ff257e
--- /dev/null
+++ b/src/blocks/components/prompt/prompt-input.tsx
@@ -0,0 +1,73 @@
+/**
+ * WordPress dependencies
+ */
+import { Button } from '@wordpress/components';
+import { Icon } from '@wordpress/icons';
+import { __ } from '@wordpress/i18n';
+import { Fragment, useEffect, useRef } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { aiGeneration } from '../../helpers/icons';
+
+type PromptInputProps = {
+ value: string;
+ onValueChange: ( value: string ) => void;
+ placeholder?: string;
+ status: string;
+ onGenerate: () => void;
+}
+const PromptInput = ( props: PromptInputProps ) => {
+ const inputRef = useRef( null );
+
+ /**
+ * Handle keydown event.
+ */
+ const handleKeyDown = ( e: React.KeyboardEvent ) => {
+ if ( 'Enter' === e.key && ! e.shiftKey ) {
+ e.preventDefault();
+ props.onGenerate();
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {__( 'Generate', 'otter-blocks' )}
+
+
+
+
+ );
+};
+
+export default PromptInput;
diff --git a/src/blocks/editor.scss b/src/blocks/editor.scss
index a4233aebc..5fa86caf8 100644
--- a/src/blocks/editor.scss
+++ b/src/blocks/editor.scss
@@ -149,3 +149,66 @@ svg.o-block-icon {
.o-autoresponder-margin {
margin-top: 10px;
}
+
+.o-webhook-headers {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ .o-webhook-header {
+ display: grid;
+ grid-template-columns: 1fr 1fr 35px;
+ gap: 10px;
+ align-items: center;
+
+ .components-base-control__field {
+ margin: 0;
+ }
+ }
+
+ button {
+ display: flex;
+ justify-content: center;
+ }
+}
+
+.o-webhook-actions {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+
+ .o-webhook-actions__left {
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ }
+}
+
+.o-webhooks {
+ display: flex;
+ flex-direction: column;
+
+ &> .o-options-global-defaults {
+ max-height: 500px;
+ overflow-y: auto;
+
+ .o-options-block-item .o-options-block-item-label {
+ margin-left: 10px;
+ flex-grow: 1;
+ }
+ }
+
+ .o-webhook-add {
+ display: flex;
+ justify-content: center;
+ margin-top: 10px;
+ }
+}
+
+.o-license-warning {
+ &.components-placeholder.is-medium {
+ legend.components-placeholder__instructions {
+ display: block;
+ }
+ }
+}
diff --git a/src/blocks/frontend/form/index.js b/src/blocks/frontend/form/index.js
index 9aae3f195..609da443a 100644
--- a/src/blocks/frontend/form/index.js
+++ b/src/blocks/frontend/form/index.js
@@ -8,6 +8,27 @@ import { domReady } from '../../helpers/frontend-helper-functions.js';
let startTimeAntiBot = null;
let METADATA_VERSION = 1;
+let saveMode = 'permanent';
+
+const hasStripeConfirmation = () => {
+ const urlParams = new URLSearchParams( window.location.search );
+ return urlParams.has( 'stripe_checkout' );
+};
+
+const confirmRecord = async() => {
+
+ // Get the record id from the URL
+ const urlParams = new URLSearchParams( window.location.search );
+ const stripeSessionId = urlParams.get( 'stripe_checkout' );
+
+ const formURlEndpoint = ( window?.themeisleGutenbergForm?.root || ( window.location.origin + '/wp-json/' ) ) + 'otter/v1/form/confirm';
+
+ return await fetch( formURlEndpoint + `?stripe_checkout=${stripeSessionId}`, {
+ method: 'GET',
+ credentials: 'include'
+ });
+};
+
/**
* Get the form fields.
* @param {HTMLDivElement} form The form.
@@ -23,7 +44,7 @@ const getFormFieldInputs = ( form ) => {
*
* @type {Array.}
*/
- return [ ...form?.querySelectorAll( ':scope > .otter-form__container .wp-block-themeisle-blocks-form-input, :scope > .otter-form__container .wp-block-themeisle-blocks-form-textarea, :scope > .otter-form__container .wp-block-themeisle-blocks-form-multiple-choice, :scope > .otter-form__container .wp-block-themeisle-blocks-form-file' ) ].filter( input => {
+ return [ ...form?.querySelectorAll( ':scope > .otter-form__container .wp-block-themeisle-blocks-form-input, :scope > .otter-form__container .wp-block-themeisle-blocks-form-textarea, :scope > .otter-form__container .wp-block-themeisle-blocks-form-multiple-choice, :scope > .otter-form__container .wp-block-themeisle-blocks-form-file, :scope > .otter-form__container .wp-block-themeisle-blocks-form-hidden-field, :scope > .otter-form__container .wp-block-themeisle-blocks-form-stripe-field' ) ].filter( input => {
return ! innerForms?.some( innerForm => innerForm?.contains( input ) );
});
};
@@ -51,24 +72,34 @@ const extractFormFields = async( form ) => {
const labelContainer = input.querySelector( '.otter-form-input-label' );
const labelElem = ( labelContainer ?? input ).querySelector( '.otter-form-input-label__label, .otter-form-textarea-label__label' );
- const label = `(Field ${index + 1}) ${( labelElem ?? labelContainer )?.innerHTML?.replace( /<[^>]*>?/gm, '' )}`;
+ const fieldNumberLabel = `(Field ${index + 1})`;
+ let label = `${fieldNumberLabel} ${( labelElem ?? labelContainer )?.innerHTML?.replace( /<[^>]*>?/gm, '' )}`;
let value = undefined;
let fieldType = undefined;
+ let mappedName = undefined;
+ let metadata = {};
const { id } = input;
- const valueElem = input.querySelector( '.otter-form-input:not([type="checkbox"], [type="radio"], [type="file"]), .otter-form-textarea-input' );
+ const valueElem = input.querySelector( '.otter-form-input:not([type="checkbox"], [type="radio"], [type="file"], [type="hidden"]), .otter-form-textarea-input' );
if ( null !== valueElem ) {
value = valueElem?.value;
fieldType = valueElem?.type;
+ mappedName = valueElem?.name;
} else {
const select = input.querySelector( 'select' );
+ mappedName = select?.name;
/** @type{HTMLInputElement} */
const fileInput = input.querySelector( 'input[type="file"]' );
+ const hiddenInput = input.querySelector( 'input[type="hidden"]' );
+
+ const stripeField = input.classList.contains( 'wp-block-themeisle-blocks-form-stripe-field' );
+
if ( fileInput ) {
const files = fileInput?.files;
+ const mappedName = fileInput?.name;
for ( let i = 0; i < files.length; i++ ) {
formFieldsData.push({
@@ -82,16 +113,38 @@ const extractFormFields = async( form ) => {
size: files[i].size,
file: files[i],
fieldOptionName: fileInput?.dataset?.fieldOptionName,
- position: index + 1
+ position: index + 1,
+ mappedName: mappedName
}
});
}
} else if ( select ) {
value = [ ...select.selectedOptions ].map( o => o?.label )?.filter( l => Boolean( l ) ).join( ', ' );
fieldType = 'multiple-choice';
+ } else if ( hiddenInput ) {
+ const paramName = hiddenInput?.dataset?.paramName;
+ mappedName = hiddenInput?.name;
+
+ if ( paramName ) {
+ const urlParams = new URLSearchParams( window.location.search );
+ value = urlParams.get( paramName );
+ fieldType = 'hidden';
+ }
+ } else if ( stripeField ) {
+
+ // Find more proper selectors instead of h3 and h5
+ label = `${fieldNumberLabel} ${input.querySelector( '.o-stripe-checkout-description h3' )?.innerHTML?.replace( /<[^>]*>?/gm, '' )}`;
+ value = input.querySelector( '.o-stripe-checkout-description h5' )?.innerHTML?.replace( /<[^>]*>?/gm, '' );
+ fieldType = 'stripe-field';
+ mappedName = input.name;
+ metadata = {
+ fieldOptionName: input?.dataset?.fieldOptionName
+ };
+ saveMode = 'temporary';
} else {
const labels = input.querySelectorAll( '.o-form-multiple-choice-field > label' );
const valuesElem = input.querySelectorAll( '.o-form-multiple-choice-field > input' );
+ mappedName = valuesElem[0]?.name;
value = [ ...labels ].filter( ( label, index ) => valuesElem[index]?.checked ).map( label => label.innerHTML ).join( ', ' );
fieldType = 'multiple-choice';
}
@@ -104,8 +157,10 @@ const extractFormFields = async( form ) => {
type: fieldType,
id: id,
metadata: {
+ ...metadata,
version: METADATA_VERSION,
- position: index + 1
+ position: index + 1,
+ mappedName: mappedName
}
});
}
@@ -196,19 +251,23 @@ function validateInputs( form ) {
*/
const createFormData = ( data ) => {
var formData = new FormData();
- var filesPairs = [];
+ /**
+ * For simple data, we will encode them as JSON in 'form_data' key.
+ * This gives the flexibility to have the same data shape like in backend without creating complex serializers.
+ * For complex data like files, we will use FormData way to handle them.
+ */
data?.payload?.formInputsData?.forEach( ( field, index ) => {
if ( 'file' === field.type ) {
let key = 'file__' + field.metadata.position + '_' + index;
- filesPairs.push([ key, field.metadata.file ]);
+
+ formData.append( key, field.metadata.file );
data.payload.formInputsData[index].metadata.file = undefined;
- data.payload.formInputsData[index].metadata.data = key;
+ data.payload.formInputsData[index].metadata.data = key; // Create a link with the file which will be used in backend via $_FILES.
}
});
formData.append( 'form_data', JSON.stringify( data ) );
- filesPairs.forEach( pair => formData.append( pair[0], pair[1]) );
return formData;
};
@@ -232,6 +291,74 @@ const getCurrentPostId = () => {
return 0;
};
+/**
+ * Handle the response after the form is submitted.
+ *
+ * @param {Promise} request
+ * @param {DisplayFormMessage} displayMsg
+ * @param {(response: import('./types.js').IFormResponse, displayMsg:DisplayFormMessage) => void} onSuccess
+ * @param {(response: import('./types.js').IFormResponse, displayMsg:DisplayFormMessage) => void} onFail
+ * @param {() => void} onCleanUp
+ */
+const handleAfterSubmit = ( request, displayMsg, onSuccess, onFail, onCleanUp ) => {
+ request.then( r => r.json() ).then( response => {
+
+ /**
+ * @type {import('./types.js').IFormResponse} The response from the server.
+ */
+ const res = response;
+
+ if ( '0' === res?.code || '1' === res?.code || res?.success ) {
+ onSuccess?.( res, displayMsg );
+ } else {
+ let errorMsgSlug = '';
+
+ // TODO: Write pattern to display a more useful error message.
+ if ( '110' === res.code ) {
+ displayMsg.setMsg( res?.reasons?.join( '' ), 'error' ).show();
+ } else if ( '12' === res.code || '13' === res.code ) {
+ displayMsg.pullMsg( 'invalid-file', 'error' ).show();
+ } else if ( 0 < res?.displayError?.length ) {
+ errorMsgSlug = res?.displayError;
+ displayMsg.setMsg( errorMsgSlug, 'error' ).show();
+ } else {
+ displayMsg.setMsg( res?.reasons?.join( '' ), 'error' ).show();
+ }
+
+ onFail?.( res, displayMsg );
+
+ // eslint-disable-next-line no-console
+ console.error( `(${res?.code}) ${res?.reasons?.join( '' )}` );
+ }
+
+ /**
+ * Reset the form.
+ */
+
+ onCleanUp?.();
+
+ })?.catch( ( error ) => {
+ console.error( error );
+ displayMsg.pullMsg( 'try-again', 'error' ).show();
+
+ onFail?.( error, displayMsg );
+ });
+};
+
+const makeSpinner = ( anchor ) => {
+ const spinner = document.createElement( 'span' );
+ spinner.classList.add( 'spinner' );
+
+ return {
+ show: () => {
+ anchor.appendChild( spinner );
+ },
+ hide: () => {
+ anchor.removeChild( spinner );
+ }
+ };
+};
+
/**
* Send the date from the form to the server
*
@@ -250,14 +377,12 @@ const collectAndSendInputFormData = async( form, btn, displayMsg ) => {
const hasCaptcha = form?.classList?.contains( 'has-captcha' );
const hasValidToken = id && window.themeisleGutenberg?.tokens?.[id]?.token;
+ const spinner = makeSpinner( btn );
- const spinner = document.createElement( 'span' );
- spinner.classList.add( 'spinner' );
- btn.appendChild( spinner );
if ( formIsEmpty ) {
btn.disabled = false;
- btn.removeChild( spinner );
+ spinner.hide();
return;
}
@@ -326,23 +451,30 @@ const collectAndSendInputFormData = async( form, btn, displayMsg ) => {
payload
});
- fetch( formURlEndpoint, {
- method: 'POST',
- headers: {
- 'X-WP-Nonce': window?.themeisleGutenbergForm?.nonce
- },
- credentials: 'include',
- body: formData
- })
- .then( r => r.json() )
- .then( ( response ) => {
-
- /**
- * @type {import('./types.js').IFormResponse}
- */
- const res = response;
-
- if ( '0' === res?.code || '1' === res?.code || res?.success ) {
+ try {
+ const request = fetch( formURlEndpoint, {
+ method: 'POST',
+ headers: {
+ 'X-WP-Nonce': window?.themeisleGutenbergForm?.nonce,
+ 'O-Form-Save-Mode': saveMode
+ },
+ credentials: 'include',
+ body: formData
+ });
+
+ spinner.show();
+ handleAfterSubmit(
+ request,
+ displayMsg,
+ ( res, displayMsg ) => {
+
+ if ( 0 < res?.frontend_external_confirmation_url?.length ) {
+
+ // Redirect to the external confirmation URL in a new tab.
+ window.open( res.frontend_external_confirmation_url, '_blank' );
+ return;
+ }
+
const msg = res?.submitMessage ? res.submitMessage : 'Success';
displayMsg.setMsg( msg ).show();
@@ -350,55 +482,28 @@ const collectAndSendInputFormData = async( form, btn, displayMsg ) => {
if ( 0 < res?.redirectLink?.length ) {
form.setAttribute( 'data-redirect', res.redirectLink );
- }
- setTimeout( () => {
- if ( 0 < res?.redirectLink?.length ) {
- let a = document.createElement( 'a' );
- a.target = '_blank';
- a.href = res.redirectLink;
- a.click();
- }
- }, 1000 );
- } else {
- let errorMsgSlug = '';
-
- // TODO: Write pattern to display a more useful error message.
- if ( '110' === res.code ) {
- displayMsg.setMsg( res?.reasons?.join( '' ), 'error' ).show();
- } else if ( '12' === res.code || '13' === res.code ) {
- displayMsg.pullMsg( 'invalid-file', 'error' ).show();
- } else if ( 0 < res?.displayError?.length ) {
- errorMsgSlug = res?.displayError;
- displayMsg.setMsg( errorMsgSlug, 'error' ).show();
- } else {
- displayMsg.setMsg( res?.reasons?.join( '' ), 'error' ).show();
+ setTimeout( () => {
+ window.location.href = res.redirectLink;
+ }, 1000 );
}
-
- // eslint-disable-next-line no-console
- console.error( `(${res?.code}) ${res?.reasons?.join( '' )}` );
- }
-
- /**
- * Reset the form.
- */
-
- if ( window.themeisleGutenberg?.tokens?.[ id ].reset ) {
- window.themeisleGutenberg?.tokens?.[ id ].reset();
- }
- btn.disabled = false;
- btn.removeChild( spinner );
- })?.catch( ( error ) => {
- console.error( error );
- displayMsg.pullMsg( 'try-again', 'error' ).show();
-
- if ( window.themeisleGutenberg?.tokens?.[ id ].reset ) {
- window.themeisleGutenberg?.tokens?.[ id ].reset();
+ },
+ ( res, displayMsg ) => {},
+ () => {
+ if ( window.themeisleGutenberg?.tokens?.[ id ].reset ) {
+ window.themeisleGutenberg?.tokens?.[ id ].reset();
+ }
+ btn.disabled = false;
+ spinner.hide();
}
- btn.disabled = false;
- btn.removeChild( spinner );
- });
+ );
+ } catch ( e ) {
+ console.error( e );
+ displayMsg.pullMsg( 'try-again', 'error' ).show();
+ btn.disabled = false;
+ spinner.hide();
+ }
}
};
@@ -445,6 +550,34 @@ domReady( () => {
const sendBtn = form.querySelector( 'button' );
const displayMsg = new DisplayFormMessage( form );
+ if ( hasStripeConfirmation() ) {
+ sendBtn.disabled = true;
+
+ const btnText = sendBtn.innerHTML;
+ sendBtn.innerHTML = displayMsg.getMsgBySlug( 'confirmingSubmission' );
+
+ const spinner = makeSpinner( sendBtn );
+ spinner.show();
+
+ handleAfterSubmit( confirmRecord(), displayMsg, ( res, displayMsg ) => {
+ const msg = res?.submitMessage ? res.submitMessage : 'Success';
+ displayMsg.setMsg( msg ).show();
+
+ if ( 0 < res?.redirectLink?.length ) {
+ form.setAttribute( 'data-redirect', res.redirectLink );
+
+ setTimeout( () => {
+ window.location.href = res.redirectLink;
+ }, 1000 );
+ }
+ }, () => {}, () => {
+ sendBtn.disabled = false;
+ spinner.hide();
+ sendBtn.innerHTML = btnText;
+ });
+ }
+
+
if ( form.querySelector( ':scope > form > button[type="submit"]' ) ) {
form?.addEventListener( 'submit', ( event ) => {
event.preventDefault();
diff --git a/src/blocks/frontend/form/message.js b/src/blocks/frontend/form/message.js
index 6862bc88b..86f76f47f 100644
--- a/src/blocks/frontend/form/message.js
+++ b/src/blocks/frontend/form/message.js
@@ -27,6 +27,15 @@ class DisplayFormMessage {
this.msgElem.style.display = this.isVisible ? 'block' : 'none';
}
+ /**
+ * Get the message from global themeisleGutenbergForm?.messages
+ * @param {string} msgSlug
+ * @returns {*|string}
+ */
+ getMsgBySlug( msgSlug ) {
+ return window?.themeisleGutenbergForm?.messages[msgSlug] || 'Messages are missing!';
+ }
+
/**
* Set the message from global themeisleGutenbergForm?.messages
* @param {string} msgSlug
@@ -35,7 +44,7 @@ class DisplayFormMessage {
*/
pullMsg( msgSlug, type ) {
return this.setMsg(
- window?.themeisleGutenbergForm?.messages[msgSlug] || 'Messages are missing!',
+ this.getMsgBySlug( msgSlug ),
type
);
}
diff --git a/src/blocks/frontend/form/types.d.ts b/src/blocks/frontend/form/types.d.ts
index 75600abf2..ac774c517 100644
--- a/src/blocks/frontend/form/types.d.ts
+++ b/src/blocks/frontend/form/types.d.ts
@@ -6,6 +6,7 @@ export interface IFormResponse {
error_source?: string
submitMessage: string
provider: string
+ frontend_external_confirmation_url?: string
}
export interface FormFieldData {
diff --git a/src/blocks/helpers/block-utility.js b/src/blocks/helpers/block-utility.js
index 79e4da665..9002de069 100644
--- a/src/blocks/helpers/block-utility.js
+++ b/src/blocks/helpers/block-utility.js
@@ -477,6 +477,42 @@ export function openOtterSidebarMenu() {
document?.querySelector( '.interface-pinned-items button[aria-label~="Otter"]' )?.click();
}
+/**
+ * Insert a block below the given block.
+ *
+ * @param {string} clientId The client id of the reference block.
+ * @param {any} block The block to insert.
+ * @see https://github.com/WordPress/gutenberg/blob/e448fa70163ce936eae9aec454ca99f5a6287f15/packages/block-editor/src/store/actions.js#L1604-L1622
+ */
+export function insertBlockBelow( clientId, block ) {
+ const {
+ getBlockRootClientId,
+ getTemplateLock,
+ getBlockIndex
+ } = select( 'core/block-editor' );
+
+ const {
+ insertBlock,
+ insertBlocks
+ } = dispatch( 'core/block-editor' );
+
+ const rootClientId = getBlockRootClientId( clientId );
+ const isLocked = getTemplateLock( rootClientId );
+
+ if ( isLocked ) {
+ return;
+ }
+
+ const index = getBlockIndex( clientId, rootClientId );
+
+ // If the block is an array of blocks, insert them all.
+ if ( Array.isArray( block ) ) {
+ return insertBlocks( block, index + 1, rootClientId );
+ }
+
+ insertBlock( block, index + 1, rootClientId );
+}
+
export class GlobalStateMemory {
constructor() {
this.states = {};
@@ -537,3 +573,5 @@ export function useTabSwitch( key, defaultValue ) {
return [ tab, setTab ];
}
+
+
diff --git a/src/blocks/helpers/blocks.d.ts b/src/blocks/helpers/blocks.d.ts
index 3ab361d1a..5c778cd8c 100644
--- a/src/blocks/helpers/blocks.d.ts
+++ b/src/blocks/helpers/blocks.d.ts
@@ -1,16 +1,17 @@
import { Dispatch, SetStateAction } from 'react';
export type BlockProps = {
- attributes: Partial< T & { className?: string }>
+ attributes: Partial< T & { className?: string }> & Record
setAttributes: ( attributes: Partial ) => void
isSelected: boolean
clientId: string
name: string
toggleSelection: ( value: boolean ) => void
+ innerBlocks: BlockProps[]
}
export interface InspectorProps {
- attributes: Partial & { className?: string }
+ attributes: Partial & { className?: string } & Record
setAttributes: ( attributes: Partial ) => void
}
diff --git a/src/blocks/helpers/defered-wp-options-save.js b/src/blocks/helpers/defered-wp-options-save.js
new file mode 100644
index 000000000..8f6976107
--- /dev/null
+++ b/src/blocks/helpers/defered-wp-options-save.js
@@ -0,0 +1,117 @@
+import api from '@wordpress/api';
+import { pick } from 'lodash';
+
+class DeferredWpOptionsSave {
+ constructor() {
+
+ /**
+ * We will create a global variable to store the instance of this class.
+ * This will prevent multiple instances of this class to be created.
+ */
+ if ( window?.deferredWpOptionsSave ) {
+ return window?.deferredWpOptionsSave;
+ }
+
+ this.changes = [];
+ this.wpOptions = {};
+ this.timeout = null;
+ this.timeoutTime = 1500;
+ this.abort = null;
+
+ window.deferredWpOptionsSave = this;
+
+ return this;
+ }
+
+ createSettings() {
+ return ( new api.models.Settings() );
+ }
+
+ save( optionType, value, callback = () => {}) {
+ this.changes.push({ optionType, value, callback });
+
+ if ( this.timeout ) {
+
+ this.abort?.abort?.();
+ clearTimeout( this.timeout );
+ }
+
+ this.timeout = setTimeout( () => {
+ this.commitChanges();
+ }, this.timeoutTime );
+ }
+
+ commitChanges() {
+ this.abort = new AbortController();
+ this.createSettings().fetch({
+ signal: this.abort.signal
+ }).done( ( response ) => {
+
+ this.wpOptions = response;
+ const optionsChanged = new Set();
+ this.changes.forEach( ( change ) => {
+ if ( 'field_options' === change.optionType && this.wpOptions['themeisle_blocks_form_fields_option']) {
+ const fieldOptions = this.wpOptions['themeisle_blocks_form_fields_option'];
+
+ if ( ! fieldOptions ) {
+ return;
+ }
+
+ const fieldIndex = fieldOptions.findIndex( ( field ) => {
+
+ // console.log( field.fieldOptionName, change.value.fieldOptionName );
+ return field.fieldOptionName === change.value.fieldOptionName;
+ });
+
+ if ( -1 !== fieldIndex ) {
+ fieldOptions[fieldIndex] = change.value;
+ } else {
+ fieldOptions.push( change.value );
+ }
+
+ optionsChanged.add( 'themeisle_blocks_form_fields_option' );
+ }
+
+ if ( 'form_options' === change.optionType && this.wpOptions['themeisle_blocks_form_emails']) {
+ const formOptions = this.wpOptions['themeisle_blocks_form_emails'];
+
+ if ( ! formOptions ) {
+ return;
+ }
+
+ const formIndex = formOptions.findIndex( ({ form }) => form === change.value.form );
+
+ if ( -1 !== formIndex ) {
+ formOptions[formIndex] = change.value;
+ } else {
+ formOptions.push( change.value );
+ }
+
+ optionsChanged.add( 'themeisle_blocks_form_emails' );
+ }
+ });
+
+ const dataToSave = pick( this.wpOptions, Array.from( optionsChanged ) );
+
+ console.log( 'Data send', { data: dataToSave }); // TODO: Remove after QA
+
+ ( new api.models.Settings( dataToSave ) )
+ .save( )
+ .then( ( response ) => {
+ this.wpOptions = response;
+ this.changes.forEach( ( change ) => {
+ change?.callback?.( response, null );
+ });
+ this.changes = [];
+ })
+ .catch( ( error ) => {
+ this.changes.forEach( ( change ) => {
+ change?.callback?.( this.wpOptions, error );
+ });
+ this.changes = [];
+ });
+ });
+ }
+}
+
+export default DeferredWpOptionsSave;
diff --git a/src/blocks/helpers/icons.js b/src/blocks/helpers/icons.js
index c2626bc83..37168c5b0 100644
--- a/src/blocks/helpers/icons.js
+++ b/src/blocks/helpers/icons.js
@@ -11,7 +11,8 @@ import {
G,
Path,
Rect,
- SVG
+ SVG,
+ LinearGradient
} from '@wordpress/primitives';
import { Circle } from '@wordpress/components';
@@ -655,3 +656,102 @@ export const popupWithImageAndText = (
);
+
+export const aiGeneration = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const formAiGeneration = (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/src/blocks/helpers/prompt.ts b/src/blocks/helpers/prompt.ts
new file mode 100644
index 000000000..97dfb550d
--- /dev/null
+++ b/src/blocks/helpers/prompt.ts
@@ -0,0 +1,257 @@
+import { createBlock } from '@wordpress/blocks';
+import apiFetch from '@wordpress/api-fetch';
+import { addQueryArgs } from '@wordpress/url';
+
+type PromptResponse = {
+ result: string
+ error?: string
+}
+
+type OpenAiSettings = {
+ model?: string
+ temperature?: number
+ max_tokens?: number
+ top_p?: number
+ stream?: boolean
+ logprobs?: number
+ presence_penalty?: number
+ frequency_penalty?: number
+ stop?: string|string[]
+}
+
+type ChatResponse = {
+ choices: {
+ finish_reason: string,
+ index: number,
+ message: {
+ content: string,
+ role: string
+ function_call?: {
+ name: string
+ arguments: string
+ }
+ }
+ }[]
+ created: number
+ id: string
+ model: string
+ object: string
+ usage: {
+ completion_tokens: number,
+ prompt_tokens: number,
+ total_tokens: number
+ },
+ error?: {
+ code: string | null,
+ message: string
+ param: string | null
+ type: string
+ }
+}
+
+type FormResponse = {
+ fields: {
+ label: string
+ type: string
+ placeholder?: string
+ helpText?: string
+ choices?: string[]
+ allowedFileTypes?: string[]
+ required?: boolean
+ }[]
+}
+
+export type PromptData = {
+ otter_name: string
+ model: string
+ messages: {
+ role: string
+ content: string
+ }[]
+ functions: {
+ name: string
+ description: string
+ parameters: any
+ }
+ function_call: {
+ [key: string]: string
+ }
+} & Record
+
+export type PromptsData = PromptData[]
+
+type PromptServerResponse = {
+ code: string
+ error: string
+ prompts: PromptsData
+}
+
+/**
+ * Create a prompt request emebdded with the given settings.
+ *
+ * @param settings
+ */
+function promptRequestBuilder( settings?: OpenAiSettings ) {
+
+ settings ??= {
+ stream: false
+ };
+
+ // TODO: remove the apiKey from the function definition.
+ return async( prompt: string, embeddedPrompt: PromptData, metadata: Record ) => {
+ const body = {
+ ...embeddedPrompt,
+ messages: embeddedPrompt.messages.map( ( message ) => {
+ if ( 'user' === message.role && message.content.includes( '{INSERT_TASK}' ) ) {
+ return {
+ role: 'user',
+ content: message.content.replace( '{INSERT_TASK}', prompt )
+ };
+ }
+
+ return message;
+ })
+ };
+
+ function removeOtterKeys( obj ) {
+ for ( let key in obj ) {
+ if ( key.startsWith( 'otter_' ) ) {
+ delete obj[key];
+ }
+ }
+ return obj;
+ }
+
+ try {
+ const response = await apiFetch({
+ path: addQueryArgs( '/otter/v1/generate', {}),
+ method: 'POST',
+ body: JSON.stringify({
+ ...( metadata ?? {}),
+ ...( removeOtterKeys( body ) ),
+ ...settings
+ })
+ });
+
+ return response as ChatResponse;
+ } catch ( e ) {
+ return {
+ error: {
+ code: 'system',
+ message: e.error?.message ?? e.error
+ }
+ };
+ }
+
+ };
+}
+
+/**
+ * Send the prompt to OpenAI. This will be the default function.
+ */
+export const sendPromptToOpenAI = promptRequestBuilder();
+
+/**
+ * Send the prompt to OpenAI. This will have more randomness.
+ */
+export const sendPromptToOpenAIWithRegenerate = promptRequestBuilder({
+ temperature: 1.3,
+ // eslint-disable-next-line camelcase
+ stream: false
+});
+
+const fieldMapping = {
+ 'text': 'themeisle-blocks/form-input',
+ 'email': 'themeisle-blocks/form-input',
+ 'password': 'themeisle-blocks/form-input',
+ 'number': 'themeisle-blocks/form-input',
+ 'tel': 'themeisle-blocks/form-input',
+ 'url': 'themeisle-blocks/form-input',
+ 'date': 'themeisle-blocks/form-input',
+ 'time': 'themeisle-blocks/form-input',
+ 'select': 'themeisle-blocks/form-multiple-choice',
+ 'checkbox': 'themeisle-blocks/form-multiple-choice',
+ 'radio': 'themeisle-blocks/form-multiple-choice',
+ 'file': 'themeisle-blocks/form-file',
+ 'textarea': 'themeisle-blocks/form-textarea'
+
+};
+
+export function parseToDisplayPromptResponse( promptResponse: string ) {
+ const response = tryParseResponse( promptResponse ) as FormResponse|undefined;
+
+ if ( ! response ) {
+ return [];
+ }
+
+ return response?.fields.map( ( field ) => {
+ return {
+ label: field?.label,
+ type: field?.type,
+ placeholder: field?.placeholder,
+ helpText: field?.helpText,
+ options: field?.choices?.join( '\n' ),
+ allowedFileTypes: field?.allowedFileTypes
+ };
+ }).filter( Boolean );
+}
+
+function tryParseResponse( promptResponse: string ) {
+ try {
+ return JSON.parse( promptResponse );
+ } catch ( e ) {
+ return undefined;
+ }
+}
+
+export function parseFormPromptResponseToBlocks( promptResponse: string ) {
+ if ( ! promptResponse ) {
+ return [];
+ }
+
+ const formResponse = tryParseResponse( promptResponse ) as FormResponse|undefined;
+
+ if ( ! formResponse ) {
+ return [];
+ }
+
+ return formResponse?.fields?.map( ( field ) => {
+
+ if ( ! fieldMapping?.[field.type]) {
+ return undefined;
+ }
+
+ return createBlock( fieldMapping[field.type], {
+ label: field.label,
+ placeholder: field.placeholder,
+ helpText: field.helpText,
+ options: field.choices?.join( '\n' ),
+ allowedFileTypes: field.allowedFileTypes
+ });
+ }).filter( Boolean );
+}
+
+export function retrieveEmbeddedPrompt( promptName ?: string ) {
+ return apiFetch({
+ path: addQueryArgs( '/otter/v1/prompt', {
+ name: promptName
+ }),
+ method: 'GET'
+ });
+}
+
+export function injectActionIntoPrompt( embeddedPrompt: PromptData, actionPrompt: string ): PromptData {
+ return {
+ ...embeddedPrompt,
+ messages: embeddedPrompt.messages.map( ( message ) => {
+ if ( 'user' === message.role && message.content.includes( '{ACTION}' ) ) {
+ return {
+ role: 'user',
+ content: message.content.replace( '{ACTION}', actionPrompt )
+ };
+ }
+
+ return message;
+ })
+ } as PromptData;
+}
diff --git a/src/blocks/helpers/use-settings.js b/src/blocks/helpers/use-settings.js
index fe0e1aca8..5fec66137 100644
--- a/src/blocks/helpers/use-settings.js
+++ b/src/blocks/helpers/use-settings.js
@@ -105,8 +105,8 @@ const useSettings = () => {
}
);
}
- onSuccess?.();
getSettings();
+ onSuccess?.( response );
});
save.error( ( response ) => {
diff --git a/src/blocks/plugins/ai-content/editor.scss b/src/blocks/plugins/ai-content/editor.scss
new file mode 100644
index 000000000..4e3e0e663
--- /dev/null
+++ b/src/blocks/plugins/ai-content/editor.scss
@@ -0,0 +1,10 @@
+.o-menu-item-alignment {
+ padding-left: 8px;
+}
+
+.o-menu-item-header {
+ font-family: monospace;
+ font-size: 13px;
+ color: #828282;
+ text-transform: uppercase;
+}
\ No newline at end of file
diff --git a/src/blocks/plugins/ai-content/index.tsx b/src/blocks/plugins/ai-content/index.tsx
new file mode 100644
index 000000000..654a7f90b
--- /dev/null
+++ b/src/blocks/plugins/ai-content/index.tsx
@@ -0,0 +1,322 @@
+/**
+ * WordPress dependencies.
+ */
+import { __ } from '@wordpress/i18n';
+
+// @ts-ignore
+import {
+ DropdownMenu,
+ MenuGroup,
+ MenuItem,
+ Toolbar,
+ Spinner,
+ ExternalLink,
+ Disabled
+} from '@wordpress/components';
+
+import { createHigherOrderComponent } from '@wordpress/compose';
+
+import { Fragment, useEffect, useState } from '@wordpress/element';
+
+import {
+ addFilter,
+ applyFilters
+} from '@wordpress/hooks';
+
+import { useDispatch, useSelect, dispatch } from '@wordpress/data';
+import { rawHandler, createBlock } from '@wordpress/blocks';
+import { BlockControls } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies.
+ */
+import { aiGeneration } from '../../helpers/icons';
+import './editor.scss';
+import { PromptsData, injectActionIntoPrompt, retrieveEmbeddedPrompt, sendPromptToOpenAI } from '../../helpers/prompt';
+import useSettings from '../../helpers/use-settings';
+import { openAiAPIKeyName } from '../../components/prompt';
+import { insertBlockBelow } from '../../helpers/block-utility';
+import { BlockProps } from '../../helpers/blocks';
+
+const isValidBlock = ( blockName: string|undefined ) => {
+ if ( ! blockName ) {
+ return false;
+ }
+
+ return [
+ 'core/paragraph',
+ 'core/heading'
+ ].some( ( name ) => name === blockName );
+};
+
+/**
+ * Extract the content from a block or blocks.
+ *
+ * @param source The block or blocks to extract the content from.
+ */
+const extractContent = ( source: BlockProps | BlockProps[]): string => {
+
+ if ( Array.isArray( source ) ) {
+ return source.reduce( ( content: string, block: BlockProps ) => {
+ return content + extractContent( block );
+ }, '' );
+ }
+
+ if (
+ 'core/paragraph' === source.name ||
+ 'core/heading' === source.name
+ ) {
+ return source.attributes.content as string;
+ }
+
+ return '';
+};
+
+let embeddedPromptsCache: PromptsData|null = null;
+
+const withConditions = createHigherOrderComponent( BlockEdit => {
+ return props => {
+ const [ getOption, _, status ] = useSettings();
+ const [ hasAPIKey, setHasAPIKey ] = useState( false );
+ const [ isProcessing, setIsProcessing ] = useState>({});
+ const [ displayError, setDisplayError ] = useState( undefined );
+
+ // Get the create notice function from the hooks api.
+ const { createNotice } = useDispatch( 'core/notices' );
+
+ const {
+ isMultipleSelection,
+ areValidBlocks,
+ selectedBlocks
+ } = useSelect( ( select ) => {
+ const selectedBlocks = select( 'core/block-editor' ).getMultiSelectedBlocks();
+
+ return {
+ isMultipleSelection: 1 < selectedBlocks.length,
+ areValidBlocks: selectedBlocks.every( ( block ) => isValidBlock( block.name ) ),
+ selectedBlocks
+ };
+ }, []);
+
+ useEffect( () => {
+ if ( 'loading' === status ) {
+ return;
+ }
+
+ if ( 'loaded' === status && ! hasAPIKey ) {
+ const key = getOption( openAiAPIKeyName ) as string;
+ setHasAPIKey( Boolean( key ) && 0 < key.length );
+ }
+ }, [ status, getOption ]);
+
+ useEffect( () => {
+ if ( ! displayError ) {
+ return;
+ }
+
+ createNotice(
+ 'error',
+ displayError,
+ {
+ type: 'snackbar',
+ isDismissible: true
+ }
+ );
+
+ setDisplayError( undefined );
+ }, [ displayError ]);
+
+ const generateContent = async( content: string, actionKey: string, callback: Function = () =>{}) => {
+
+ if ( ! content ) {
+ setDisplayError( __( 'No content detected in selected block.', 'otter-blocks' ) );
+ return;
+ }
+
+ if ( ! embeddedPromptsCache ) {
+ const response = await retrieveEmbeddedPrompt( 'textTransformation' );
+ embeddedPromptsCache = response?.prompts ?? [];
+ }
+
+ const embeddedPrompt = embeddedPromptsCache?.find( ( prompt ) => 'textTransformation' === prompt.otter_name );
+
+ if ( ! embeddedPrompt ) {
+ setDisplayError( __( 'Something when wrong retrieving the prompts.', 'otter-blocks' ) );
+ return;
+ }
+
+ const action: undefined | string = embeddedPrompt?.[actionKey];
+
+ if ( ! action ) {
+ setDisplayError( __( 'The action is not longer available.', 'otter-blocks' ) );
+ return;
+ }
+
+ if ( ! hasAPIKey ) {
+ setDisplayError( __( 'No Open API key detected. Please add your key.', 'otter-blocks' ) );
+ return;
+ }
+
+ setIsProcessing( prevState => ({ ...prevState, [ actionKey ]: true }) );
+ sendPromptToOpenAI(
+ content,
+ injectActionIntoPrompt(
+ embeddedPrompt,
+ action
+ ),
+ {
+ 'otter_used_action': `textTransformation::${ actionKey }`,
+ 'otter_user_content': content
+ }
+ ).then( ( response ) => {
+ if ( response.error ) {
+ setDisplayError( response.error?.message ?? response.error );
+ return;
+ }
+
+ const blockContentRaw = response?.choices?.[0]?.message.content;
+
+ if ( ! blockContentRaw ) {
+ return;
+ }
+
+ const newBlocks = rawHandler({
+ HTML: blockContentRaw
+ });
+
+ const aiBlock = createBlock(
+ 'themeisle-blocks/content-generator',
+ {
+ promptID: 'textTransformation',
+ resultHistory: [{
+ result: response?.choices?.[0]?.message.content ?? '',
+ meta: {
+ usedToken: response?.usage.total_tokens,
+ prompt: ''
+ }
+ }]
+ },
+ newBlocks
+ );
+
+ insertBlockBelow( props.clientId, aiBlock );
+
+ setIsProcessing( prevState => ({ ...prevState, [ actionKey ]: false }) );
+ callback?.();
+ }).catch( ( error ) => {
+ setDisplayError( error.message );
+ setIsProcessing( prevState => ({ ...prevState, [ actionKey ]: false }) );
+ });
+ };
+
+ const ActionMenuItem = ( args: { actionKey: string, children: React.ReactNode, callback: Function }) => {
+ return (
+ x )}>
+ {
+ generateContent( extractContent( isMultipleSelection ? selectedBlocks : props ), args.actionKey, () => args.callback?.( args.actionKey ) );
+ }}
+ >
+ { args.children }
+ { isProcessing?.[args.actionKey] && }
+
+
+ );
+ };
+
+ return (
+
+
+ {(
+ ( isValidBlock( props.name ) && props.isSelected ) || ( areValidBlocks && isMultipleSelection ) ) &&
+ (
+
+
+
+ {
+ ({ onClose }) => (
+
+ {
+ ( ! hasAPIKey ) && (
+
+
+ { __( 'Please add your OpenAI API key in Integrations.', 'otter-blocks' ) }
+
+
+ {
+ __( 'Go to Dashboard', 'otter-blocks' )
+ }
+
+
+ )
+ }
+
+ {__( 'Writing', 'otter-blocks' )}
+
+ { __( 'Generate a heading', 'otter-blocks' ) }
+
+
+ { __( 'Continue writing', 'otter-blocks' ) }
+
+
+ { __( 'Summarize it', 'otter-blocks' ) }
+
+
+ { __( 'Make it shorter', 'otter-blocks' ) }
+
+
+ { __( 'Make it longer', 'otter-blocks' ) }
+
+
+ { __( 'Make it more descriptive', 'otter-blocks' ) }
+
+
+
+ {__( 'Tone', 'otter-blocks' )}
+
+ { __( 'Professional', 'otter-blocks' ) }
+
+
+ { __( 'Friendly', 'otter-blocks' ) }
+
+
+ { __( 'Humorous', 'otter-blocks' ) }
+
+
+ { __( 'Confident', 'otter-blocks' ) }
+
+
+ { __( 'Persuasive', 'otter-blocks' ) }
+
+
+ { __( 'Casual', 'otter-blocks' ) }
+
+
+
+
+ { __( 'Use as prompt', 'otter-blocks' ) }
+
+
+
+
+ {
+ __( 'Go to docs', 'otter-blocks' ) // TODO: Add link to docs & CSS styling
+ }
+
+
+
+ )
+ }
+
+
+
+ ) }
+
+ );
+ };
+}, 'withConditions' );
+
+addFilter( 'editor.BlockEdit', 'themeisle-gutenberg/otter-ai-content-toolbar', withConditions );
diff --git a/src/blocks/plugins/feedback/index.js b/src/blocks/plugins/feedback/index.js
index cd73fa319..935be9144 100644
--- a/src/blocks/plugins/feedback/index.js
+++ b/src/blocks/plugins/feedback/index.js
@@ -7,6 +7,7 @@ import classnames from 'classnames';
* WordPress dependencies
*/
import {
+ Fragment,
useState
} from '@wordpress/element';
import {
@@ -33,7 +34,7 @@ export const FeedbackModalComponent = ({
isOpen
}) => {
return (
- <>
+
{ isOpen && (
) }
- >
+
);
};
@@ -94,7 +95,7 @@ const FeedbackModal = (
};
return (
- <>
+
- >
+
);
};
diff --git a/src/blocks/plugins/options/index.js b/src/blocks/plugins/options/index.js
index a2513e88e..4cb02a9e1 100644
--- a/src/blocks/plugins/options/index.js
+++ b/src/blocks/plugins/options/index.js
@@ -136,6 +136,9 @@ const Options = () => {
setAPILoaded( true );
});
}
+ } else {
+ setCanUser( false );
+ setAPILoaded( true );
}
};
@@ -301,75 +304,81 @@ const Options = () => {
-
-
- {
- __( 'Make those features to be shown by default in Block Tools.', 'otter-blocks' )
- }
-
-
- {
- 'loading' === status && (
-
-
- { __( 'Checking optional module...', 'otter-blocks' ) }
-
- )
- }
-
- {
- enabledModules?.css && (
-
- updatedWithStatus( dispatch( 'core/preferences' )?.set( 'themeisle/otter-blocks', 'show-custom-css', value ) )}
- />
-
- )
- }
-
- {
- enabledModules?.animation && (
-
- updatedWithStatus( dispatch( 'core/preferences' )?.set( 'themeisle/otter-blocks', 'show-animations', value ) )}
- />
-
- )
- }
-
- {
- enabledModules?.condition && (
-
- updatedWithStatus( dispatch( 'core/preferences' )?.set( 'themeisle/otter-blocks', 'show-block-conditions', value ) )}
- />
-
- )
- }
-
-
-
- { __( 'Block Settings', 'otter-blocks' ) }
-
+ {
+ canUser && (
+
+
+
+ {
+ __( 'Make those features to be shown by default in Block Tools.', 'otter-blocks' )
+ }
+
+
+ {
+ 'loading' === status && (
+
+
+ { __( 'Checking optional module...', 'otter-blocks' ) }
+
+ )
+ }
+
+ {
+ enabledModules?.css && (
+
+ updatedWithStatus( dispatch( 'core/preferences' )?.set( 'themeisle/otter-blocks', 'show-custom-css', value ) )}
+ />
+
+ )
+ }
+
+ {
+ enabledModules?.animation && (
+
+ updatedWithStatus( dispatch( 'core/preferences' )?.set( 'themeisle/otter-blocks', 'show-animations', value ) )}
+ />
+
+ )
+ }
+
+ {
+ enabledModules?.condition && (
+
+ updatedWithStatus( dispatch( 'core/preferences' )?.set( 'themeisle/otter-blocks', 'show-block-conditions', value ) )}
+ />
+
+ )
+ }
+
+
+
+ { __( 'Block Settings', 'otter-blocks' ) }
+
+
+ )
+ }
{ applyFilters( 'otter.feedback', '', 'otter-menu-editor', __( 'Help us improve Otter Blocks', 'otter-blocks' ) ) }
diff --git a/src/blocks/plugins/registerPlugin.tsx b/src/blocks/plugins/registerPlugin.tsx
index 308f68748..476f6265f 100644
--- a/src/blocks/plugins/registerPlugin.tsx
+++ b/src/blocks/plugins/registerPlugin.tsx
@@ -31,6 +31,7 @@ import './feedback/index.js';
import './otter-tools-inspector/index';
import './live-search/index.js';
import './upsell-block/index.js';
+import './ai-content/index.tsx';
const icon = ;
diff --git a/src/blocks/plugins/welcome-guide/editor.scss b/src/blocks/plugins/welcome-guide/editor.scss
index 78518b8a6..7c019ba48 100644
--- a/src/blocks/plugins/welcome-guide/editor.scss
+++ b/src/blocks/plugins/welcome-guide/editor.scss
@@ -25,7 +25,6 @@
.o-welcome-guide__input {
font-size: 13px;
- margin: 0 0 10px;
padding: 0 32px;
}
diff --git a/src/blocks/plugins/welcome-guide/index.js b/src/blocks/plugins/welcome-guide/index.js
index bee3bbebd..867d1793c 100644
--- a/src/blocks/plugins/welcome-guide/index.js
+++ b/src/blocks/plugins/welcome-guide/index.js
@@ -119,6 +119,15 @@ const WelcomeGuide = () => {
)
},
+ {
+ image: ,
+ content: (
+
+ { __( 'Create amazing things in less time with AI', 'otter-blocks' ) }
+ { __( 'Supercharge your productivity with our new AI Block, which can generate forms for you in seconds. More features are coming soon.', 'otter-blocks' ) }
+
+ )
+ },
{
image: ,
content: (
diff --git a/src/blocks/test/e2e/blocks/form.spec.js b/src/blocks/test/e2e/blocks/form.spec.js
index e8805aede..03fc9e309 100644
--- a/src/blocks/test/e2e/blocks/form.spec.js
+++ b/src/blocks/test/e2e/blocks/form.spec.js
@@ -101,6 +101,8 @@ test.describe( 'Form Block', () => {
hasText: 'Form options have been saved.'
});
+ await page.waitForTimeout( 1500 );
+
expect( await msg.isVisible() ).toBeTruthy();
/*
@@ -173,6 +175,9 @@ test.describe( 'Form Block', () => {
helpText: 'This is a help text',
allowedFileTypes: [ 'text/plain', 'image/*' ]
}
+ },
+ {
+ name: 'themeisle-blocks/form-nonce'
}
] });
@@ -190,6 +195,8 @@ test.describe( 'Form Block', () => {
const postId = await editor.publishPost();
+ await page.waitForTimeout( 1700 );
+
await page.goto( `/?p=${postId}` );
const fileInput = await page.$( `#${attributes.id} input[type="file"]` );
@@ -221,6 +228,49 @@ test.describe( 'Form Block', () => {
});
+ test( 'insert a hidden field and check if it renders in frontend', async({ page, editor }) => {
+
+ await page.waitForTimeout( 1000 );
+ await editor.insertBlock({ name: 'themeisle-blocks/form', innerBlocks: [
+ {
+ name: 'themeisle-blocks/form-hidden-field',
+ attributes: {
+ label: 'Hidden Field Test',
+ paramName: 'test'
+ }
+ },
+ {
+ name: 'themeisle-blocks/form-nonce'
+ }
+ ] });
+
+ const blocks = await editor.getBlocks();
+
+ const formBlock = blocks.find( ( block ) => 'themeisle-blocks/form' === block.name );
+ expect( formBlock ).toBeTruthy();
+
+ const fileHiddenBlock = formBlock.innerBlocks.find( ( block ) => 'themeisle-blocks/form-hidden-field' === block.name );
+
+ expect( fileHiddenBlock ).toBeTruthy();
+
+ const { attributes } = fileHiddenBlock;
+
+ expect( attributes.id ).toBeTruthy();
+
+ const postId = await editor.publishPost();
+
+ await page.waitForTimeout( 1500 );
+
+ await page.goto( `/?p=${postId}&test=123` );
+
+ const hiddenInput = await page.locator( `#${attributes.id} input[type="hidden"]` );
+
+ expect( hiddenInput ).toBeTruthy();
+
+ await expect( hiddenInput ).toHaveAttribute( 'data-param-name', 'test' );
+
+ });
+
test( 'redirect to a page after form submission', async({ page, editor, browser }) => {
/*
@@ -253,24 +303,93 @@ test.describe( 'Form Block', () => {
const postId = await editor.publishPost();
- await page.waitForTimeout( 1000 );
+ await page.waitForTimeout( 2000 );
await page.goto( `/?p=${postId}` );
await page.getByLabel( 'Name*' ).fill( 'John Doe' );
await page.getByLabel( 'Email*' ).fill( 'test@otter.com' );
- await page.waitForTimeout( 5000 );
-
page.on( 'response', ( response ) =>
console.log( '<<', response.status(), response.url() )
);
+ await page.waitForTimeout( 5000 ); // Wait to prevent the anti-spam check from blocking the request.
+
await page.getByRole( 'button', { name: 'Submit' }).click();
+ await page.waitForTimeout( 500 );
+
+ await expect( await page.$( `[data-redirect="${REDIRECT_URL}"]` ) ).toBeTruthy();
await expect( await page.getByText( 'Success' ) ).toBeVisible();
// check for a element with the attribute data-redirect-url
- await expect( await page.$( `[data-redirect="${REDIRECT_URL}"]` ) ).toBeTruthy();
+ });
+
+ test( 'errors on invalid API Key for Market Integration', async({ page, editor, browser }) => {
+
+ await editor.insertBlock({ name: 'themeisle-blocks/form' });
+
+ let formBlock = ( await editor.getBlocks() ).find( ( block ) => 'themeisle-blocks/form' === block.name );
+
+ expect( formBlock ).toBeTruthy();
+
+ const { clientId } = formBlock;
+
+ await page.click( `#block-${clientId} > div > fieldset > ul > li:nth-child(1) > button` );
+
+ await page.getByRole( 'button', { name: 'Marketing Integration' }).click();
+
+ // Select the Mailchimp option on the select with label Provider
+ await page.getByLabel( 'Provider' ).selectOption( 'mailchimp' );
+
+ await page.getByLabel( 'API Key' ).fill( 'invalid-api-key' );
+
+ await expect( page.getByLabel( 'Dismiss this notice' ) ).toBeVisible();
+ });
+
+ test( 'enable post save button on options changed', async({ page, editor }) => {
+ const ccValue = 'otter@test-form.com';
+
+ /*
+ * Create a form block and insert the CC value using the Inspector Controls.
+ */
+
+ await editor.insertBlock({ name: 'themeisle-blocks/form' });
+
+ let formBlock = ( await editor.getBlocks() ).find( ( block ) => 'themeisle-blocks/form' === block.name );
+
+ expect( formBlock ).toBeTruthy();
+
+ const { clientId } = formBlock;
+
+ await page.click( `#block-${clientId} > div > fieldset > ul > li:nth-child(1) > button` );
+
+ // Open the options panel
+ await page.getByRole( 'button', { name: 'Form Options options' }).click();
+
+ // activate the option
+ await page.getByRole( 'menuitemcheckbox', { name: 'Show CC' }).click();
+
+ // Close the options panel
+ await page.getByRole( 'button', { name: 'Form Options options' }).click();
+
+ const cc = page.getByPlaceholder( 'Send copies to' );
+
+ await cc.fill( ccValue );
+
+ await editor.publishPost();
+
+ await page.getByLabel( 'Close panel' ).click();
+
+ await page.getByPlaceholder( 'Default is to admin site' ).fill( ccValue );
+
+ const saveBtn = page.getByRole( 'button', { name: 'Update', disabled: false });
+
+ await saveBtn.waitFor({
+ timeout: 4000
+ });
+
+ expect( await saveBtn.isEnabled() ).toBeTruthy();
});
});
diff --git a/src/blocks/test/e2e/blocks/product-review.spec.js b/src/blocks/test/e2e/blocks/product-review.spec.js
index bf98655df..83257b186 100644
--- a/src/blocks/test/e2e/blocks/product-review.spec.js
+++ b/src/blocks/test/e2e/blocks/product-review.spec.js
@@ -81,5 +81,26 @@ test.describe( 'Product Review Block', () => {
await expect( await page.getByRole( 'document', { name: 'Block: Product Review' }).getByText( FEATURE_DESCRIPTION, { exact: true }) ).toBeVisible();
});
+ test( 'open in new tab', async({ editor, page }) => {
+ await editor.insertBlock({ name: 'themeisle-blocks/review' });
+
+ await page.getByRole( 'button', { name: 'Buttons' }).click({ clickCount: 1 });
+
+ await page.getByRole( 'button', { name: 'Add Links' }).click();
+
+ await page.getByRole( 'button', { name: 'Buy Now' }).click();
+
+ await page.getByPlaceholder( 'Button label' ).fill( 'Buy Now in same tab' );
+ await page.getByLabel( 'Open in New Tab' ).click();
+ await page.getByRole( 'button', { name: 'Add Links' }).click();
+
+ const postId = await editor.publishPost();
+
+ await page.goto( `/?p=${postId}` );
+
+ await expect( page.getByRole( 'link', { name: 'Buy Now in same tab' }) ).toHaveAttribute( 'target', '_self' );
+ await expect( page.getByRole( 'link', { name: 'Buy Now', exact: true }) ).toHaveAttribute( 'target', '_blank' );
+
+ });
});
diff --git a/src/blocks/test/e2e/performance/performance.spec.js b/src/blocks/test/e2e/performance/performance.spec.js
index 886918321..f21cf7522 100644
--- a/src/blocks/test/e2e/performance/performance.spec.js
+++ b/src/blocks/test/e2e/performance/performance.spec.js
@@ -187,8 +187,8 @@ describe( 'Post Editor Performance', () => {
it( 'Typing', async() => {
- screenRecorder = new PuppeteerScreenRecorder( page, screenRecorderOptions );
- await screenRecorder.start( savePathVideo + 'typing-test.mp4' );
+ // screenRecorder = new PuppeteerScreenRecorder( page, screenRecorderOptions );
+ // await screenRecorder.start( savePathVideo + 'typing-test.mp4' );
await loadHtmlIntoTheBlockEditor(
readFile( path.join( __dirname, '../assets/large-post.html' ) )
@@ -234,7 +234,7 @@ describe( 'Post Editor Performance', () => {
await saveDraft();
- await screenRecorder.stop();
+ // await screenRecorder.stop();
});
it( 'Selecting blocks', async() => {
diff --git a/src/dashboard/components/pages/Integrations.js b/src/dashboard/components/pages/Integrations.js
index 85606f549..adab52fbe 100644
--- a/src/dashboard/components/pages/Integrations.js
+++ b/src/dashboard/components/pages/Integrations.js
@@ -43,10 +43,15 @@ const Integrations = () => {
setStripeAPI( getOption( 'themeisle_stripe_api_key' ) );
}, [ getOption( 'themeisle_stripe_api_key' ) ]);
+ useEffect( () => {
+ setOpenAISecretKey( getOption( 'themeisle_open_ai_api_key' ) );
+ }, [ getOption( 'themeisle_open_ai_api_key' ) ]);
+
const [ googleMapsAPI, setGoogleMapsAPI ] = useState( '' );
const [ googleCaptchaAPISiteKey, setGoogleCaptchaAPISiteKey ] = useState( '' );
const [ googleCaptchaAPISecretKey, setGoogleCaptchaAPISecretKey ] = useState( '' );
const [ stripeAPI, setStripeAPI ] = useState( '' );
+ const [ openAISecretKey, setOpenAISecretKey ] = useState( '' );
let ProModules = () => {
return (
@@ -209,6 +214,51 @@ const Integrations = () => {
+
+
+
+ setOpenAISecretKey( value ) }
+ />
+
+
+ updateOption( 'themeisle_open_ai_api_key', openAISecretKey ) }
+ >
+ { __( 'Save', 'otter-blocks' ) }
+
+
+
+ { __( 'Get API Key', 'otter-blocks' ) }
+
+
+
+ { __( 'More Info', 'otter-blocks' ) }
+
+
+
+
+
);
};
diff --git a/src/pro/blocks/file/edit.js b/src/pro/blocks/file/edit.js
index 037d1b1dc..18ea485a1 100644
--- a/src/pro/blocks/file/edit.js
+++ b/src/pro/blocks/file/edit.js
@@ -24,7 +24,8 @@ import Inspector from './inspector.js';
import { blockInit } from '../../../blocks/helpers/block-utility';
import { _cssBlock, pullSavedState, setSavedState } from '../../../blocks/helpers/helper-functions';
import useSettings from '../../../blocks/helpers/use-settings';
-import { select } from '@wordpress/data';
+import { dispatch, select, useSelect } from '@wordpress/data';
+import DeferedWpOptionsSave from '../../../blocks/helpers/defered-wp-options-save';
const { attributes: defaultAttributes } = metadata;
@@ -60,51 +61,61 @@ const Edit = ({
}
}, [ attributes.id ]);
- const blockProps = useBlockProps();
- const [ getOption, updateOption, status ] = useSettings();
-
- useEffect( () => {
- const fieldOptions = getOption?.( 'themeisle_blocks_form_fields_option' ) ?? [];
- const fieldIndex = fieldOptions?.findIndex( field => field.fieldOptionName === attributes.fieldOptionName );
-
- if ( Boolean( window.themeisleGutenberg?.hasPro ) && attributes.fieldOptionName && 'loaded' === status ) {
+ const { canSaveData } = useSelect( select => {
+ const isSavingPost = select( 'core/editor' )?.isSavingPost();
+ const isPublishingPost = select( 'core/editor' )?.isPublishingPost();
+ const isAutosaving = select( 'core/editor' )?.isAutosavingPost();
+ const widgetSaving = select( 'core/edit-widgets' )?.isSavingWidgetAreas();
- /** @type{import('../../../blocks/blocks/form/common').FieldOption[]} */
- const fieldOptions = getOption?.( 'themeisle_blocks_form_fields_option' ) ?? [];
+ return {
+ canSaveData: ( ! isAutosaving && ( isSavingPost || isPublishingPost ) ) || widgetSaving
+ };
+ });
- const fieldIndex = fieldOptions?.findIndex( field => field.fieldOptionName === attributes.fieldOptionName );
+ const { createNotice } = dispatch( 'core/notices' );
- if ( fieldIndex === undefined ) {
- return;
- }
+ /**
+ * Prevent saving data if the block is inside an AI block. This will prevent polluting the wp_options table.
+ */
+ const isInsideAiBlock = useSelect( select => {
+ const {
+ getBlockParentsByBlockName
+ } = select( 'core/block-editor' );
- const isChanged = pullSavedState( attributes.id, false ) || -1 === fieldIndex;
+ const parents = getBlockParentsByBlockName( clientId, 'themeisle-blocks/content-generator' );
+ return 0 < parents?.length;
+ }, [ clientId ]);
- if ( isChanged ) {
- if ( -1 !== fieldIndex ) {
- fieldOptions[fieldIndex].options.allowedFileTypes = attributes.allowedFileTypes ? attributes.allowedFileTypes : undefined;
- fieldOptions[fieldIndex].options.maxFileSize = attributes.maxFileSize ? attributes.maxFileSize : undefined;
- fieldOptions[fieldIndex].options.saveFiles = attributes.saveFiles ? attributes.saveFiles : undefined;
- fieldOptions[fieldIndex].options.maxFilesNumber = attributes.multipleFiles ? ( attributes.maxFilesNumber ?? 10 ) : undefined;
+ useEffect( () => {
+ if ( canSaveData && ! isInsideAiBlock ) {
+ ( new DeferedWpOptionsSave() ).save( 'field_options', {
+ fieldOptionName: attributes.fieldOptionName,
+ fieldOptionType: 'file',
+ options: {
+ allowedFileTypes: attributes.allowedFileTypes ? attributes.allowedFileTypes : undefined,
+ maxFileSize: Boolean( attributes.multipleFiles ) ? ( attributes.maxFileSize ?? 10 ) : undefined,
+ saveFiles: attributes.saveFiles ? attributes.saveFiles : undefined,
+ maxFilesNumber: attributes.multipleFiles ? ( attributes.maxFilesNumber ?? 10 ) : undefined
+ }
+ }, ( res, error ) => {
+ if ( error ) {
+ createNotice( 'error', __( 'Error saving File Field settings.', 'otter-blocks' ), {
+ isDismissible: true,
+ type: 'snackbar',
+ id: 'file-field-option-error'
+ });
} else {
- fieldOptions.push({
- fieldOptionName: attributes.fieldOptionName,
- fieldOptionType: 'file',
- options: {
- allowedFileTypes: attributes.allowedFileTypes ? attributes.allowedFileTypes : undefined,
- maxFileSize: Boolean( attributes.multipleFiles ) ? ( attributes.maxFileSize ?? 10 ) : undefined,
- saveFiles: attributes.saveFiles ? attributes.saveFiles : undefined,
- maxFilesNumber: attributes.multipleFiles ? ( attributes.maxFilesNumber ?? 10 ) : undefined
- }
+ createNotice( 'info', __( 'File Field settings saved.', 'otter-blocks' ), {
+ isDismissible: true,
+ type: 'snackbar',
+ id: 'file-field-option-success'
});
}
-
- updateOption( 'themeisle_blocks_form_fields_option', fieldOptions, __( 'Field settings saved.', 'otter-blocks' ), 'field-option' );
-
- setSavedState( attributes.id, false );
- }
+ });
}
- }, [ attributes.fieldOptionName, attributes.allowedFileTypes, attributes.maxFileSize, attributes.saveFiles, attributes.multipleFiles, status ]);
+ }, [ canSaveData ]);
+
+ const blockProps = useBlockProps();
return (
diff --git a/src/pro/blocks/file/index.js b/src/pro/blocks/file/index.js
index 7e6201334..d0afc610e 100644
--- a/src/pro/blocks/file/index.js
+++ b/src/pro/blocks/file/index.js
@@ -75,6 +75,16 @@ registerBlockType( name, {
...attrs
});
}
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-hidden-field' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-hidden-field', {
+ ...attrs
+ });
+ }
}
]
}
diff --git a/src/pro/blocks/file/inspector.js b/src/pro/blocks/file/inspector.js
index fed3a1e7c..960203248 100644
--- a/src/pro/blocks/file/inspector.js
+++ b/src/pro/blocks/file/inspector.js
@@ -20,10 +20,14 @@ import {
} from '@wordpress/components';
import { applyFilters } from '@wordpress/hooks';
import { Fragment } from '@wordpress/element';
-import { fieldTypesOptions, HideFieldLabelToggle, switchFormFieldTo } from '../../../blocks/blocks/form/common';
+import { dispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { fieldTypesOptions, HideFieldLabelToggle, mappedNameInfo, switchFormFieldTo } from '../../../blocks/blocks/form/common';
import { Notice } from '../../../blocks/components';
import { setUtm } from '../../../blocks/helpers/helper-functions';
-import { dispatch } from '@wordpress/data';
const ProPreview = ({ attributes }) => {
@@ -74,6 +78,14 @@ const ProPreview = ({ attributes }) => {
onChange={ () => {} }
/>
+ {} }
+ placeholder={ __( 'photos', 'otter-blocks' ) }
+ />
+
{
attributes.multipleFiles && (
{
+
+ useEffect( () => {
+ const unsubscribe = blockInit( clientId, defaultAttributes );
+ return () => unsubscribe( attributes.id );
+ }, [ attributes.id ]);
+
+
+ const blockProps = useBlockProps({
+ className: 'wp-block wp-block-themeisle-blocks-form-input'
+ });
+
+ const placeholder = attributes.paramName ? __( 'Get the value of the URL param: ', 'otter-blocks' ) + attributes.paramName : '';
+
+ return (
+
+
+
+
+
+
+
+ { __( 'Hidden Field', 'otter-blocks' ) }
+
+ setAttributes({ label }) }
+ tagName="span"
+ />
+
+
+
+ {
+ attributes.helpText && (
+
+ { attributes.helpText }
+
+ )
+ }
+
+
+ );
+};
+
+export default Edit;
diff --git a/src/pro/blocks/form-hidden-field/index.js b/src/pro/blocks/form-hidden-field/index.js
new file mode 100644
index 000000000..b2088b334
--- /dev/null
+++ b/src/pro/blocks/form-hidden-field/index.js
@@ -0,0 +1,96 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import { createBlock, registerBlockType } from '@wordpress/blocks';
+
+import { useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import { formFieldIcon as icon } from '../../../blocks/helpers/icons.js';
+import edit from './edit.js';
+import { omit } from 'lodash';
+import Inactive from '../../components/inactive';
+
+
+const { name } = metadata;
+
+if ( ! window.themeisleGutenberg.isAncestorTypeAvailable ) {
+ metadata.parent = [ 'themeisle-blocks/form' ];
+}
+
+if ( ! ( Boolean( window.otterPro.isActive ) && ! Boolean( window.otterPro.isExpired ) ) ) {
+ edit = () => ;
+}
+
+
+registerBlockType( name, {
+ ...metadata,
+ title: __( 'Hidden Field', 'otter-blocks' ),
+ description: __( 'A field used for adding extra metadata to the Form via URL params.', 'otter-blocks' ),
+ icon,
+ edit,
+ save: () => null,
+ transforms: {
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-input' ],
+ transform: ( attributes ) => {
+
+ return createBlock( 'themeisle-blocks/form-input', {
+ ...attributes
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-textarea' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-textarea', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-multiple-choice' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-multiple-choice', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-file' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-file', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-stripe-field' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-stripe-field', {
+ ...attrs
+ });
+ }
+ }
+ ]
+ }
+});
diff --git a/src/pro/blocks/form-hidden-field/inspector.js b/src/pro/blocks/form-hidden-field/inspector.js
new file mode 100644
index 000000000..13ca41c2e
--- /dev/null
+++ b/src/pro/blocks/form-hidden-field/inspector.js
@@ -0,0 +1,116 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import {
+ InspectorControls
+} from '@wordpress/block-editor';
+
+import {
+ Button,
+ PanelBody,
+ SelectControl,
+ TextControl
+} from '@wordpress/components';
+import { applyFilters } from '@wordpress/hooks';
+import { Fragment } from '@wordpress/element';
+import { dispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+
+import { Notice as OtterNotice } from '../../../blocks/components';
+import { fieldTypesOptions, mappedNameInfo, switchFormFieldTo } from '../../../blocks/blocks/form/common';
+
+
+/**
+ *
+ * @param {import('./types').FormHiddenFieldInspectorPros} props
+ * @returns {JSX.Element}
+ */
+const Inspector = ({
+ attributes,
+ setAttributes,
+ clientId
+}) => {
+
+ // FormContext is not available here. This is a workaround.
+ const selectForm = () => {
+ const formParentId = Array.from( document.querySelectorAll( `.wp-block-themeisle-blocks-form:has(#block-${clientId})` ) )?.pop()?.dataset?.block;
+ dispatch( 'core/block-editor' ).selectBlock( formParentId );
+ };
+
+ return (
+
+
+ selectForm?.() }
+ >
+ { __( 'Back to the Form', 'otter-blocks' ) }
+
+
+ {
+ if ( 'hidden' !== type ) {
+ switchFormFieldTo( type, clientId, attributes );
+ }
+ }}
+ />
+
+ setAttributes({ label }) }
+ help={ __( 'The label will be used as the field name.', 'otter-blocks' ) }
+ disabled={! Boolean( window?.otterPro?.isActive )}
+ />
+
+ setAttributes({ paramName }) }
+ help={ __( 'The query parameter name that is used in URL. If the param is present, its value will be extracted and send with the Form.', 'otter-blocks' ) }
+ placeholder={ __( 'e.g. utm_source', 'otter-blocks' ) }
+ disabled={! Boolean( window?.otterPro?.isActive )}
+ />
+
+ setAttributes({ mappedName }) }
+ placeholder={ __( 'car_type', 'otter-blocks' ) }
+ disabled={! Boolean( window?.otterPro?.isActive )}
+ />
+
+ { ! Boolean( window?.otterPro?.isActive ) && (
+
+
+
+ )
+
+ }
+
+
+ { applyFilters( 'otter.feedback', '', 'sticky' ) }
+ { applyFilters( 'otter.poweredBy', '' ) }
+
+
+
+
+ );
+};
+
+export default Inspector;
diff --git a/src/pro/blocks/form-hidden-field/types.d.ts b/src/pro/blocks/form-hidden-field/types.d.ts
new file mode 100644
index 000000000..830523158
--- /dev/null
+++ b/src/pro/blocks/form-hidden-field/types.d.ts
@@ -0,0 +1,14 @@
+import { BlockProps, InspectorProps } from '../../helpers/blocks';
+
+
+type Attributes = {
+ id: string
+ formId: string
+ label: string
+ paramName: string
+ mappedName: string
+ type: string
+}
+
+export type FormHiddenFieldProps = BlockProps
+export type FormHiddenFieldInspectorPros = InspectorProps
diff --git a/src/pro/blocks/form-stripe-field/block.json b/src/pro/blocks/form-stripe-field/block.json
new file mode 100644
index 000000000..29f4085bd
--- /dev/null
+++ b/src/pro/blocks/form-stripe-field/block.json
@@ -0,0 +1,52 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "name": "themeisle-blocks/form-stripe-field",
+ "title": "Stripe Field",
+ "category": "themeisle-blocks",
+ "description": "A field used for adding Stripe products to the form.",
+ "keywords": [ "product", "stripe", "field" ],
+ "textdomain": "otter-blocks",
+ "ancestor": [ "themeisle-blocks/form" ],
+ "attributes": {
+ "id": {
+ "type": "string"
+ },
+ "fieldOptionName": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ },
+ "label": {
+ "type": "string"
+ },
+ "labelColor": {
+ "type": "string"
+ },
+ "mappedName": {
+ "type": "string"
+ },
+ "inputWidth": {
+ "type": "number"
+ },
+ "borderRadius": {
+ "type": "object"
+ },
+ "borderWidth": {
+ "type": "object"
+ },
+ "borderColor": {
+ "type": "string"
+ },
+ "product": {
+ "type": "string"
+ },
+ "price": {
+ "type": "string"
+ }
+ },
+ "supports": {
+ "align": [ "wide", "full" ]
+ }
+}
diff --git a/src/pro/blocks/form-stripe-field/edit.js b/src/pro/blocks/form-stripe-field/edit.js
new file mode 100644
index 000000000..641eac919
--- /dev/null
+++ b/src/pro/blocks/form-stripe-field/edit.js
@@ -0,0 +1,423 @@
+import classnames from 'classnames';
+import hash from 'object-hash';
+
+/**
+ * WordPress dependencies
+ */
+
+import { Fragment, useContext, useEffect, useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { useBlockProps } from '@wordpress/block-editor';
+import { store } from '@wordpress/icons';
+import { dispatch, select, useSelect } from '@wordpress/data';
+import { Button, ExternalLink, Notice, Placeholder, SelectControl, Spinner, TextControl } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+
+import metadata from './block.json';
+import Inspector from './inspector';
+import useSettings from '../../../blocks/helpers/use-settings';
+import { blockInit } from '../../../blocks/helpers/block-utility';
+import DeferredWpOptionsSave from '../../../blocks/helpers/defered-wp-options-save';
+import { _cssBlock, boxValues } from '../../../blocks/helpers/helper-functions';
+
+const { attributes: defaultAttributes } = metadata;
+
+/**
+ * Stripe Field component
+ * @param {import('./types').FormStripeFieldProps} props
+ * @returns
+ */
+const Edit = ({
+ attributes,
+ setAttributes,
+ clientId
+}) => {
+
+ useEffect( () => {
+ const unsubscribe = blockInit( clientId, defaultAttributes );
+ return () => unsubscribe( attributes.id );
+ }, [ attributes.id ]);
+
+ const [ getOption, updateOption, status ] = useSettings();
+ const [ canRetrieveProducts, setCanRetrieveProducts ] = useState( false );
+ const { createNotice } = dispatch( 'core/notices' );
+
+ useEffect( () => {
+ if ( 'loaded' === status ) {
+ const apiKey = getOption( 'themeisle_stripe_api_key' );
+ setCanRetrieveProducts( 'loaded' === status && 0 < apiKey?.length );
+ }
+ }, [ status, getOption ]);
+
+ /**
+ * Create the form identification tag for Otter Options.
+ */
+ useEffect( () => {
+ if ( attributes.id && select( 'core/edit-widgets' ) ) {
+ setAttributes({ fieldOptionName: `widget_${ attributes.id.slice( -8 ) }` });
+ } else if ( attributes.id ) {
+ setAttributes({ fieldOptionName: `${ hash({ url: window.location.pathname }) }_${ attributes.id.slice( -8 ) }` });
+ }
+ }, [ attributes.id ]);
+
+ const { products, productsList, hasProductsRequestFailed, productsError, isLoadingProducts } = useSelect( select => {
+
+ const {
+ getStripeProducts,
+ getResolutionError,
+ isResolving
+ } = select( 'themeisle-gutenberg/data' );
+
+ const products = getStripeProducts();
+
+ return {
+ products,
+ productsList: products ? products?.map( ( product ) => {
+ return {
+ label: `${ product?.name } (id:${ product?.id })`,
+ value: product?.id
+ };
+ }) : [],
+ hasProductsRequestFailed: Boolean( getResolutionError( 'getStripeProducts' ) ),
+ productsError: getResolutionError( 'getStripeProducts' ),
+ isLoadingProducts: isResolving( 'getStripeProducts' )
+ };
+ }, [ canRetrieveProducts, status ]);
+
+ const { prices, pricesList, hasPricesRequestFailed, pricesError, isLoadingPrices } = useSelect( select => {
+
+ if ( ! canRetrieveProducts ) {
+ return {
+ prices: [],
+ pricesList: [],
+ hasPricesRequestFailed: true,
+ pricesError: null,
+ isLoadingPrices: false
+ };
+ }
+
+ const {
+ getStripeProductPrices,
+ getResolutionError,
+ isResolving
+ } = select( 'themeisle-gutenberg/data' );
+
+ const prices = attributes.product ? getStripeProductPrices( attributes.product ) : [];
+
+ return {
+ prices,
+ pricesList: prices ? prices?.map( ( prices ) => {
+ return {
+ label: `${ prices?.currency } ${ prices?.unit_amount } (id:${ prices?.id })`,
+ value: prices?.id
+ };
+ }) : [],
+ hasPricesRequestFailed: Boolean( getResolutionError( 'getStripeProductPrices', [ attributes.product ]) ),
+ pricesError: getResolutionError( 'getStripeProductPrices', [ attributes.product ]),
+ isLoadingPrices: isResolving( 'getStripeProductPrices', [ attributes.product ])
+ };
+ }, [ attributes.product, canRetrieveProducts ]);
+
+ const [ view, setView ] = useState( 'default' );
+ const [ meta, setMeta ] = useState({});
+
+ useEffect( () => {
+ const product = products?.find( ( i ) => attributes.product === i.id );
+ const price = prices?.find( ( i ) => attributes.price === i.id );
+
+ let unitAmount;
+
+ if ( price?.unit_amount ) {
+ unitAmount = price?.unit_amount / 100;
+ unitAmount = unitAmount.toLocaleString( 'en-US', { style: 'currency', currency: price?.currency });
+ }
+
+ setMeta({
+ name: product?.name,
+ price: unitAmount,
+ description: product?.description,
+ image: product?.images?.[0] || undefined
+ });
+ }, [ products, prices, attributes.price ]);
+
+ const showPlaceholder = ( isLoadingProducts || isLoadingPrices || hasProductsRequestFailed || hasPricesRequestFailed || undefined === attributes.product || undefined === attributes.price || 'loaded' !== status || ! canRetrieveProducts );
+
+ const blockProps = useBlockProps({
+ className: classnames({ 'is-placeholder': showPlaceholder }),
+ id: attributes.id
+ });
+
+ const [ apiKey, setAPIKey ] = useState( '' );
+
+ const reset = () => {
+ dispatch( 'themeisle-gutenberg/data' ).invalidateResolutionForStoreSelector( 'getStripeProducts' );
+ dispatch( 'themeisle-gutenberg/data' ).invalidateResolutionForStoreSelector( 'getStripeProductPrices' );
+ setCanRetrieveProducts( 0 < apiKey?.length );
+ setAPIKey( '' );
+ };
+
+ const saveApiKey = () => {
+ setCanRetrieveProducts( false );
+ updateOption( 'themeisle_stripe_api_key', apiKey?.replace?.( /\s/g, '' ), __( 'Stripe API Key saved!', 'otter-blocks' ), 'stripe-api-key', reset );
+ };
+
+
+ const saveProduct = ( fieldOptionName, product, price ) => {
+ if ( ! product || ! price || ! fieldOptionName || ! Boolean( window.themeisleGutenberg?.hasPro ) ) {
+ return;
+ }
+
+ ( new DeferredWpOptionsSave() )
+ .save( 'field_options', {
+ fieldOptionName: attributes.fieldOptionName,
+ fieldOptionType: 'stripe',
+ stripe: {
+ product: attributes.product ? attributes.product : undefined,
+ price: attributes.price ? attributes.price : undefined
+ }
+ }, ( res, error ) => {
+ if ( error ) {
+ createNotice(
+ 'info',
+ __( 'Error saving Stripe product on Form.', 'otter-blocks' ),
+ {
+ type: 'snackbar',
+ isDismissible: true,
+ id: 'stripe-product-error'
+ }
+ );
+ } else {
+ createNotice(
+ 'success',
+ __( 'Form Stripe product saved.', 'otter-blocks' ),
+ {
+ type: 'snackbar',
+ isDismissible: true,
+ id: 'stripe-product'
+ }
+ );
+ }
+ });
+ };
+
+ const { canSaveData } = useSelect( select => {
+ const isSavingPost = select( 'core/editor' )?.isSavingPost();
+ const isPublishingPost = select( 'core/editor' )?.isPublishingPost();
+ const isAutosaving = select( 'core/editor' )?.isAutosavingPost();
+ const widgetSaving = select( 'core/edit-widgets' )?.isSavingWidgetAreas();
+
+ return {
+ canSaveData: ( ! isAutosaving && ( isSavingPost || isPublishingPost ) ) || widgetSaving
+ };
+ });
+
+ /**
+ * Prevent saving data if the block is inside an AI block. This will prevent polluting the wp_options table.
+ */
+ const isInsideAiBlock = useSelect( select => {
+ const {
+ getBlockParentsByBlockName
+ } = select( 'core/block-editor' );
+
+ const parents = getBlockParentsByBlockName( clientId, 'themeisle-blocks/content-generator' );
+ return 0 < parents?.length;
+ }, [ clientId ]);
+
+ useEffect( () => {
+ if ( canSaveData && ! isInsideAiBlock ) {
+ saveProduct( attributes.fieldOptionName, attributes.product, attributes.price );
+ }
+ }, [ canSaveData ]);
+
+ if ( showPlaceholder ) {
+ return (
+
+
+
+
+ {
+ ( 'loading' === status || 'saving' === status ) && (
+
+
+ { __( 'Checking the API Key...', 'otter-blocks' ) }
+
+
+ )
+ }
+
+ {
+ (
+ ( hasProductsRequestFailed || hasPricesRequestFailed ) &&
+ ( 'loaded' === status ) &&
+ ( productsError?.message?.length || pricesError?.message?.length )
+ ) && (
+
+
+ {
+ ( hasProductsRequestFailed && productsError?.message ) || ( hasPricesRequestFailed && pricesError?.message )
+ }
+
+
+ )
+ }
+
+ {
+ ( ( 'loaded' === status ) && ( ( hasProductsRequestFailed && productsError?.message?.includes( 'Invalid API Key' ) ) || ! canRetrieveProducts ) ) && (
+
+
+
+
+
+
+ { __( 'Save', 'otter-blocks' ) }
+
+
+
+
+
+
{ __( 'You can also set it from Dashboard', 'otter-blocks' ) }
+
+ )
+ }
+
+ {
+ 'error' === status && (
+
+ {__( 'An error occurred during API Key checking.', 'otter-blocks' )}
+
+ )
+ }
+
+ {
+ ( 'loaded' === status && false === hasProductsRequestFailed && canRetrieveProducts ) && (
+
+ { ! isLoadingProducts && (
+ {
+ setAttributes({ product: 'none' !== product ? product : undefined });
+ } }
+ />
+ ) }
+
+ { ( ! isLoadingPrices && attributes.product ) && (
+ {
+ setAttributes({ price: 'none' !== price ? price : undefined });
+ } }
+ />
+ ) }
+
+ { ( isLoadingProducts || isLoadingPrices ) && }
+
+ )
+ }
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ { 'default' === view && (
+
+
+ { undefined !== meta?.image && (
+
+ )}
+
+
+
{ meta?.name }
+ { meta?.price }
+
+
+
+ )}
+
+ { 'success' === view && ( attributes.successMessage || __( 'Your payment was successful. If you have any questions, please email orders@example.com.', 'otter-blocks' ) ) }
+ { 'cancel' === view && ( attributes.cancelMessage || __( 'Your payment was unsuccessful. If you have any questions, please email orders@example.com.', 'otter-blocks' ) ) }
+
+
+ );
+};
+
+export default Edit;
diff --git a/src/pro/blocks/form-stripe-field/index.js b/src/pro/blocks/form-stripe-field/index.js
new file mode 100644
index 000000000..b5e32170f
--- /dev/null
+++ b/src/pro/blocks/form-stripe-field/index.js
@@ -0,0 +1,97 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import { createBlock, registerBlockType } from '@wordpress/blocks';
+
+import { useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import { formFieldIcon as icon } from '../../../blocks/helpers/icons.js';
+import edit from './edit.js';
+import { omit } from 'lodash';
+import Inactive from '../../components/inactive';
+
+
+const { name } = metadata;
+
+if ( ! window.themeisleGutenberg.isAncestorTypeAvailable ) {
+ metadata.parent = [ 'themeisle-blocks/form' ];
+}
+
+if ( ! ( Boolean( window.otterPro.isActive ) && ! Boolean( window.otterPro.isExpired ) ) ) {
+ edit = () => ;
+}
+
+
+registerBlockType( name, {
+ ...metadata,
+ title: __( 'Stripe Field', 'otter-blocks' ),
+ description: __( 'A field used for adding Stripe products to the form.', 'otter-blocks' ),
+ icon,
+ edit,
+ save: () => null,
+ transforms: {
+ to: [
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-input' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+
+ return createBlock( 'themeisle-blocks/form-input', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-textarea' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-textarea', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-multiple-choice' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-multiple-choice', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-file' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-file', {
+ ...attrs
+ });
+ }
+ },
+ {
+ type: 'block',
+ blocks: [ 'themeisle-blocks/form-hidden-field' ],
+ transform: ( attributes ) => {
+ const attrs = omit( attributes, [ 'type' ]);
+ return createBlock( 'themeisle-blocks/form-hidden-field', {
+ ...attrs
+ });
+ }
+ }
+ ]
+ }
+});
diff --git a/src/pro/blocks/form-stripe-field/inspector.js b/src/pro/blocks/form-stripe-field/inspector.js
new file mode 100644
index 000000000..9cbdef5f2
--- /dev/null
+++ b/src/pro/blocks/form-stripe-field/inspector.js
@@ -0,0 +1,218 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+import {
+ ContrastChecker,
+ InspectorControls, PanelColorSettings
+} from '@wordpress/block-editor';
+
+import {
+ __experimentalBoxControl as BoxControl,
+ Button,
+ PanelBody,
+ Placeholder,
+ SelectControl,
+ Spinner,
+ TextControl
+} from '@wordpress/components';
+import { applyFilters } from '@wordpress/hooks';
+import { Fragment, useState } from '@wordpress/element';
+import { dispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+
+import { InspectorHeader, Notice as OtterNotice, SyncColorPanel } from '../../../blocks/components';
+import { FieldInputWidth, fieldTypesOptions, switchFormFieldTo } from '../../../blocks/blocks/form/common';
+import { objectOrNumberAsBox } from '../../../blocks/helpers/helper-functions';
+
+
+/**
+ *
+ * @param {import('./types').FormStripeFieldInspectorPros} props
+ * @returns {JSX.Element}
+ */
+const Inspector = ({
+ attributes,
+ setAttributes,
+ clientId,
+ productsList,
+ pricesList,
+ isLoadingProducts,
+ isLoadingPrices,
+ status,
+ apiKey,
+ setAPIKey,
+ saveApiKey
+}) => {
+
+ // FormContext is not available here. This is a workaround.
+ const selectForm = () => {
+ const formParentId = Array.from( document.querySelectorAll( `.wp-block-themeisle-blocks-form:has(#block-${clientId})` ) )?.pop()?.dataset?.block;
+ dispatch( 'core/block-editor' ).selectBlock( formParentId );
+ };
+
+ return (
+
+
+
+ selectForm?.() }
+ >
+ { __( 'Back to the Form', 'otter-blocks' ) }
+
+
+ {
+ if ( 'stripe' !== type ) {
+ switchFormFieldTo( type, clientId, attributes );
+ }
+ }}
+ />
+
+
+
+ setAttributes({ mappedName }) }
+ placeholder={ __( 'product', 'otter-blocks' ) }
+ />
+
+ { ! Boolean( window?.otterPro?.isActive ) && (
+
+
+
+ )
+
+ }
+
+
+ {
+ applyFilters( 'otter.feedback', '', 'form' ) // BUG: This is not working when added in a Settings/Style tab like in the other blocks.
+ }
+ { applyFilters( 'otter.poweredBy', '' ) }
+
+
+
+
+ { ! isLoadingProducts && (
+ {
+ setAttributes({
+ product: 'none' !== product ? product : undefined,
+ price: undefined
+ });
+ } }
+ />
+ ) }
+
+ { ( ! isLoadingPrices && attributes.product ) && (
+ {
+ setAttributes({ price: 'none' !== price ? price : undefined });
+ } }
+ />
+ ) }
+
+ { ( isLoadingProducts || isLoadingPrices ) && }
+
+
+
+ setAttributes({ labelColor }),
+ label: __( 'Label Color', 'otter-blocks' )
+ },
+ {
+ value: attributes.borderColor,
+ onChange: borderColor => setAttributes({ borderColor }),
+ label: __( 'Border Color', 'otter-blocks' )
+ }
+ ] }
+ />
+
+ setAttributes({ borderWidth }) }
+ />
+
+ setAttributes({ borderRadius }) }
+ />
+
+
+
+
+
+
+ { __( 'Save API Key', 'otter-blocks' ) }
+
+
+
+
+ );
+};
+
+export default Inspector;
diff --git a/src/pro/blocks/form-stripe-field/types.d.ts b/src/pro/blocks/form-stripe-field/types.d.ts
new file mode 100644
index 000000000..8a257b3da
--- /dev/null
+++ b/src/pro/blocks/form-stripe-field/types.d.ts
@@ -0,0 +1,21 @@
+import { BlockProps, InspectorProps } from '../../helpers/blocks';
+import { BoxBorder } from '../../../blocks/common';
+
+
+type Attributes = {
+ id: string
+ formId: string
+ label: string
+ paramName: string
+ mappedName: string
+ type: string
+ product: string
+ price: string
+ fieldOptionName: string
+ borderColor: string
+ borderWidth: BoxBorder
+ borderRadius: BoxBorder
+}
+
+export type FormStripeFieldProps = BlockProps
+export type FormStripeFieldInspectorPros = InspectorProps
diff --git a/src/pro/blocks/index.js b/src/pro/blocks/index.js
index 853c2acdf..9c726a052 100644
--- a/src/pro/blocks/index.js
+++ b/src/pro/blocks/index.js
@@ -6,3 +6,5 @@ import './business-hours/index.js';
import './review-comparison/index.js';
import './woo-comparison/index.js';
import './file/index.js';
+import './form-hidden-field/index.js';
+import './form-stripe-field/index.js';
diff --git a/src/pro/components/inactive/index.js b/src/pro/components/inactive/index.js
index e3fb96865..b228f9dd5 100644
--- a/src/pro/components/inactive/index.js
+++ b/src/pro/components/inactive/index.js
@@ -25,6 +25,7 @@ const Inactive = ({
icon={ icon }
label={ label }
instructions={ instructions }
+ className="o-license-warning"
/>
);
diff --git a/src/pro/components/webhook-editor/index.tsx b/src/pro/components/webhook-editor/index.tsx
new file mode 100644
index 000000000..e9fff0794
--- /dev/null
+++ b/src/pro/components/webhook-editor/index.tsx
@@ -0,0 +1,389 @@
+/**
+ * External dependencies
+ */
+import { v4 as uuidv4 } from 'uuid';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ BaseControl,
+ Button, ExternalLink,
+ Icon,
+ Modal,
+ Notice,
+ SelectControl,
+ Spinner,
+ TextControl
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { useEffect, useState, Fragment } from '@wordpress/element';
+import { arrowRight, closeSmall } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import useSettings from '../../../blocks/helpers/use-settings';
+
+
+type WebhookEditorProps = {
+ webhookId: string,
+ onChange: ( webhookId: string ) => void,
+}
+
+type Webhook = {
+ id: string,
+ name: string,
+ url: string,
+ method: string,
+ headers: {key?: string, value?: string}[],
+}
+
+const WebhookEditor = ( props: WebhookEditorProps ) => {
+
+ const [ isOpen, setOpen ] = useState( false );
+ const [ error, setError ] = useState( '' );
+
+ const [ id, setId ] = useState( '' );
+
+ const [ url, setUrl ] = useState( '' );
+ const [ name, setName ] = useState( '' );
+ const [ method, setMethod ] = useState( 'GET' );
+ const [ headers, setHeaders ] = useState<{key?: string, value?: string}[]>([]);
+
+ const [ getOption, setOption, status ] = useSettings();
+
+ const fetchWebhook = () => {
+ const hooksOptions = getOption?.( 'themeisle_webhooks_options' );
+
+ if ( hooksOptions ) {
+ setWebhooks( hooksOptions );
+ }
+ };
+
+ const [ webhooks, setWebhooks ] = useState
([]);
+
+ const [ initWebhooks, setInitWebhooks ] = useState( true );
+ useEffect( () => {
+ if ( 'loaded' === status && initWebhooks ) {
+ fetchWebhook();
+ setInitWebhooks( false );
+ }
+ }, [ status, initWebhooks ]);
+
+ const checkWebhook = ( webhook: Webhook ) => {
+ if ( ! webhook.name ) {
+ return __( 'Please enter a webhook name.', 'otter-blocks' );
+ }
+
+ if ( ! webhook.url ) {
+ return __( 'Please enter a webhook URL.', 'otter-blocks' );
+ }
+
+ if ( 0 < webhook.headers.length ) {
+ for ( const header of webhook.headers ) {
+ if ( ! header.key || ! header.value ) {
+ return __( 'Please enter a key and value for all headers.', 'otter-blocks' );
+ }
+ }
+ }
+
+ return true;
+ };
+
+ const saveWebhooks = ( webhooksToSave: Webhook[]) => {
+ for ( const webhook of webhooksToSave ) {
+ const check = checkWebhook( webhook );
+ if ( true !== check ) {
+ const msg = __( 'There was an error saving the webhook: ', 'otter-blocks' ) + webhook?.name + '\n';
+ setError( msg + check );
+ return;
+ }
+ }
+
+ // Save to wp options
+ setOption?.( 'themeisle_webhooks_options', [ ...webhooksToSave ], __( 'Webhooks saved.', 'otter-blocks' ), 'webhook', ( response ) => {
+ setWebhooks( response?.['themeisle_webhooks_options'] ?? []);
+ });
+ };
+
+ useEffect( () => {
+ if ( isOpen && 0 < webhooks?.length && props.webhookId && id !== props.webhookId ) {
+ const webhook = webhooks.find( ( hook: Webhook ) => hook.id === props.webhookId );
+ if ( webhook ) {
+ setId( webhook.id );
+ setName( webhook.name );
+ setUrl( webhook.url );
+ setMethod( webhook.method );
+ setHeaders( webhook.headers );
+ }
+ }
+ }, [ isOpen, webhooks, props.webhookId ]);
+
+ return (
+
+ { isOpen && (
+ setOpen( false )}
+ shouldCloseOnClickOutside={ false }
+ >
+ {
+ id ? (
+
+
+
+
+
+
+
+ {
+ setId( '' );
+ }}
+ >
+ { __( 'Back', 'otter-blocks' ) }
+
+ {
+ saveWebhooks( webhooks.filter( ( webhook ) => webhook.id !== id ) );
+ setId( '' );
+ }}
+ >
+ { __( 'Delete', 'otter-blocks' ) }
+
+
+
+
{
+
+ const webhook = {
+ id,
+ name,
+ url,
+ method,
+ headers
+ };
+
+ const err = checkWebhook( webhook );
+
+ if ( true !== err ) {
+ setError( err );
+ return;
+ }
+
+ const newWebhooks = [ ...webhooks ];
+ const index = newWebhooks.findIndex( ( webhook ) => webhook.id === id );
+
+ if ( -1 === index ) {
+ newWebhooks.push( webhook );
+ } else {
+ newWebhooks[ index ] = webhook;
+ }
+
+ saveWebhooks( newWebhooks );
+ }}
+ >
+ { __( 'Save', 'otter-blocks' ) }
+
+
+
+ ) : (
+
+
+ {
+ webhooks?.map( ( webhook ) => {
+ return (
+
+
+
+ { webhook.name }
+
+
}
+
+ className="o-options-block-item-button"
+ onClick={ () => {
+ setId( webhook.id );
+ setName( webhook.name );
+ setUrl( webhook.url );
+ setMethod( webhook.method );
+ setHeaders( webhook.headers );
+ } }
+ />
+
+ );
+ })
+ }
+
+
+
+
+ {
+ setId( `w-${uuidv4()}` );
+ setName( '' );
+ setUrl( '' );
+ setMethod( 'POST' );
+ setHeaders([]);
+ }}>
+ { __( 'Add New Webhook', 'otter-blocks' ) }
+
+
+
+ )
+ }
+
+ {
+ error && (
+ {
+ setError( '' );
+ }}>
+ { error }
+
+ )
+ }
+
+ ) }
+
+ {
+ 'loading' === status && (
+
+
+
+
+ { __( 'Loading Webhooks', 'otter-blocks' ) }
+
+
+ )
+ }
+
+ {
+ return {
+ value: webhook.id,
+ label: webhook.name
+ };
+ }) ?? []
+ )
+ ]
+
+ }
+ onChange={ props.onChange }
+ />
+ < br />
+ setOpen( true )}
+ className="wp-block-themeisle-blocks-tabs-inspector-add-tab"
+ >
+ { __( 'Edit Webhooks', 'otter-blocks' ) }
+
+ < br />
+
+ { __( 'Learn more about webhooks.', 'otter-blocks' ) }
+
+
+ );
+};
+
+export default WebhookEditor;
diff --git a/src/pro/plugins/form/index.js b/src/pro/plugins/form/index.js
index 7d4adee91..010df03f9 100644
--- a/src/pro/plugins/form/index.js
+++ b/src/pro/plugins/form/index.js
@@ -16,9 +16,10 @@ import { Fragment } from '@wordpress/element';
* Internal dependencies
*/
import { Notice as OtterNotice } from '../../../blocks/components';
-import { FieldInputWidth, HideFieldLabelToggle } from '../../../blocks/blocks/form/common';
+import { FieldInputWidth, HideFieldLabelToggle, mappedNameInfo } from '../../../blocks/blocks/form/common';
import { setSavedState } from '../../../blocks/helpers/helper-functions';
import AutoresponderBodyModal from '../../components/autoresponder/index.js';
+import WebhookEditor from '../../components/webhook-editor';
// +-------------- Autoresponder --------------+
@@ -36,7 +37,15 @@ const helpMessages = {
'database-email': __( 'Save the submissions to the database and notify also via email.', 'otter-blocks' )
};
-
+/**
+ * Form Options
+ *
+ * @param {React.ReactNode} Options The children of the FormOptions component.
+ * @param {import('../../../blocks/blocks/form/type').FormOptions} formOptions The form options.
+ * @param { (options: import('../../../blocks/blocks/form/type').FormOptions) => void } setFormOption The function to set the form options.
+ * @param {any} config The form config.
+ * @returns {JSX.Element}
+ */
const FormOptions = ( Options, formOptions, setFormOption, config ) => {
return (
@@ -140,6 +149,37 @@ const FormOptions = ( Options, formOptions, setFormOption, config ) => {
)}
+
formOptions.webhookId }
+ label={__( 'Webhook', 'otter-blocks' )}
+ onDeselect={() => setFormOption({ webhookId: undefined })}
+ >
+ {Boolean( window.otterPro.isActive ) ? (
+ <>
+ {
+ setFormOption({
+ webhookId: webhookId
+ });
+ }}
+ />
+ >
+ ) : (
+
+
+
+ )}
+
>
);
};
@@ -263,6 +303,14 @@ const FormFileInspector = ( Template, {
onChange={ isRequired => setAttributes({ isRequired }) }
/>
+
setAttributes({ mappedName }) }
+ placeholder={ __( 'photos', 'otter-blocks' ) }
+ />
+