diff --git a/backend/Actions/NextCrm/NextCrmController.php b/backend/Actions/NextCrm/NextCrmController.php new file mode 100644 index 000000000..ef6136eaa --- /dev/null +++ b/backend/Actions/NextCrm/NextCrmController.php @@ -0,0 +1,113 @@ +contact->get_tags()); + wp_send_json_success($response, 200); + } + + public function refreshLists() + { + self::isExists(); + + $response['lists'] = self::toOptions(nextcrm_manager()->contact->get_lists()); + wp_send_json_success($response, 200); + } + + public function refreshCampaigns() + { + self::isExists(); + + $response['campaigns'] = self::toOptions(nextcrm_get_campaigns()); + wp_send_json_success($response, 200); + } + + public function refreshContactFields() + { + self::isExists(); + + $response['contactFields'] = self::toOptions(nextcrm_get_contact_properties()); + wp_send_json_success($response, 200); + } + + public function refreshContactTypes() + { + self::isExists(); + + $response['contactTypes'] = self::toOptions(nextcrm_contact_types()); + wp_send_json_success($response, 200); + } + + public function refreshContactStatuses() + { + self::isExists(); + + $response['contactStatuses'] = self::toOptions(nextcrm_contact_status()); + wp_send_json_success($response, 200); + } + + public function execute($integrationData, $fieldValues) + { + $integrationDetails = $integrationData->flow_details; + $integId = $integrationData->id; + $fieldMap = $integrationDetails->field_map; + $utilities = isset($integrationDetails->utilities) ? $integrationDetails->utilities : []; + + if (empty($fieldMap)) { + return new WP_Error('field_map_empty', __('Field map is empty', 'bit-integrations')); + } + + $recordApiHelper = new RecordApiHelper($integrationDetails, $integId); + + return $recordApiHelper->execute($fieldValues, $fieldMap, $utilities); + } + + /** + * NextCRM returns its dropdown sources as `[value => label]` maps. + * + * @param array $map + * + * @return array + */ + private static function toOptions($map) + { + $options = []; + + foreach ((array) $map as $value => $label) { + $options[] = (object) [ + 'value' => $value, + 'label' => \is_scalar($label) ? (string) $label : (string) $value, + ]; + } + + return $options; + } +} diff --git a/backend/Actions/NextCrm/RecordApiHelper.php b/backend/Actions/NextCrm/RecordApiHelper.php new file mode 100644 index 000000000..fc70785aa --- /dev/null +++ b/backend/Actions/NextCrm/RecordApiHelper.php @@ -0,0 +1,189 @@ +_integrationDetails = $integrationDetails; + $this->_integrationID = $integId; + } + + /** + * Execute the integration + * + * @param array $fieldValues Field values from trigger + * @param array $fieldMap Field mapping + * @param array $utilities Optional actions to perform + * + * @return array + */ + public function execute($fieldValues, $fieldMap, $utilities) + { + if (!\defined('NEXTCRM_VERSION')) { + return [ + 'success' => false, + 'message' => __('NextCRM is not installed or activated', 'bit-integrations') + ]; + } + + $fieldData = static::generateReqDataFromFieldMap($fieldMap, $fieldValues); + + $mainAction = $this->_integrationDetails->mainAction ?? 'create_contact'; + + $defaultResponse = [ + 'success' => false, + // translators: %s: Plugin name + 'message' => wp_sprintf(__('%s plugin is not installed or activate', 'bit-integrations'), 'Bit Integrations Pro') + ]; + + switch ($mainAction) { + case 'create_contact': + $response = Hooks::apply(Config::withPrefix('next_crm_create_contact'), $defaultResponse, $fieldData, $utilities); + $type = 'contact'; + + break; + + case 'update_contact': + $response = Hooks::apply(Config::withPrefix('next_crm_update_contact'), $defaultResponse, $fieldData, $utilities); + $type = 'contact'; + + break; + + case 'create_or_update_contact': + $response = Hooks::apply(Config::withPrefix('next_crm_create_or_update_contact'), $defaultResponse, $fieldData, $utilities); + $type = 'contact'; + + break; + + case 'delete_contact': + $response = Hooks::apply(Config::withPrefix('next_crm_delete_contact'), $defaultResponse, $fieldData); + $type = 'contact'; + + break; + + case 'change_contact_status': + $response = Hooks::apply(Config::withPrefix('next_crm_change_contact_status'), $defaultResponse, $fieldData, $this->_integrationDetails); + $type = 'contact'; + + break; + + case 'update_contact_field': + $response = Hooks::apply(Config::withPrefix('next_crm_update_contact_field'), $defaultResponse, $fieldData, $this->_integrationDetails); + $type = 'contact'; + + break; + + case 'set_contact_meta': + $response = Hooks::apply(Config::withPrefix('next_crm_set_contact_meta'), $defaultResponse, $fieldData); + $type = 'contact'; + + break; + + case 'delete_contact_meta': + $response = Hooks::apply(Config::withPrefix('next_crm_delete_contact_meta'), $defaultResponse, $fieldData); + $type = 'contact'; + + break; + + case 'add_contact_activity': + $response = Hooks::apply(Config::withPrefix('next_crm_add_contact_activity'), $defaultResponse, $fieldData, $utilities); + $type = 'activity'; + + break; + + case 'create_tag': + $response = Hooks::apply(Config::withPrefix('next_crm_create_tag'), $defaultResponse, $fieldData); + $type = 'tag'; + + break; + + case 'add_tag_to_contact': + $response = Hooks::apply(Config::withPrefix('next_crm_add_tag_to_contact'), $defaultResponse, $fieldData, $this->_integrationDetails); + $type = 'tag'; + + break; + + case 'remove_tag_from_contact': + $response = Hooks::apply(Config::withPrefix('next_crm_remove_tag_from_contact'), $defaultResponse, $fieldData, $this->_integrationDetails); + $type = 'tag'; + + break; + + case 'create_list': + $response = Hooks::apply(Config::withPrefix('next_crm_create_list'), $defaultResponse, $fieldData); + $type = 'list'; + + break; + + case 'add_contact_to_list': + $response = Hooks::apply(Config::withPrefix('next_crm_add_contact_to_list'), $defaultResponse, $fieldData, $this->_integrationDetails); + $type = 'list'; + + break; + + case 'remove_contact_from_list': + $response = Hooks::apply(Config::withPrefix('next_crm_remove_contact_from_list'), $defaultResponse, $fieldData, $this->_integrationDetails); + $type = 'list'; + + break; + + case 'send_campaign_email': + $response = Hooks::apply(Config::withPrefix('next_crm_send_campaign_email'), $defaultResponse, $fieldData, $utilities, $this->_integrationDetails); + $type = 'campaign'; + + break; + + default: + $response = [ + 'success' => false, + 'message' => __('Invalid action', 'bit-integrations') + ]; + $type = 'NextCrm'; + + break; + } + + $responseType = isset($response['success']) && $response['success'] ? 'success' : 'error'; + LogHandler::save($this->_integrationID, ['type' => $type, 'type_name' => $mainAction], $responseType, $response); + + return $response; + } + + private static function generateReqDataFromFieldMap($fieldMap, $fieldValues) + { + $dataFinal = []; + + foreach ($fieldMap as $item) { + $triggerValue = $item->formField; + $actionValue = $item->nextCrmField; + + if (empty($actionValue)) { + continue; + } + + $dataFinal[$actionValue] = $triggerValue === 'custom' && isset($item->customValue) + ? Common::replaceFieldWithValue($item->customValue, $fieldValues) + : $fieldValues[$triggerValue] ?? ''; + } + + return $dataFinal; + } +} diff --git a/backend/Actions/NextCrm/Routes.php b/backend/Actions/NextCrm/Routes.php new file mode 100644 index 000000000..6cce3cf48 --- /dev/null +++ b/backend/Actions/NextCrm/Routes.php @@ -0,0 +1,15 @@ + ['name' => 'MoreConvert Wishlist', 'isPro' => true, 'is_active' => false], 'Newsletter' => ['name' => 'Newsletter', 'isPro' => true, 'is_active' => false], 'NewUserApprove' => ['name' => 'New User Approve', 'isPro' => true, 'is_active' => false], + 'NextCrm' => ['name' => 'NextCRM', 'isPro' => true, 'is_active' => false], 'NexForms' => ['name' => 'NEX-Forms', 'isPro' => true, 'is_active' => false], 'NF' => ['name' => 'Ninja Forms', 'isPro' => true, 'is_active' => false], 'NinjaTables' => ['name' => 'Ninja Tables', 'isPro' => true, 'is_active' => false], diff --git a/frontend/src/Utils/StaticData/webhookIntegrations.js b/frontend/src/Utils/StaticData/webhookIntegrations.js index 1d4c63a90..96768e991 100644 --- a/frontend/src/Utils/StaticData/webhookIntegrations.js +++ b/frontend/src/Utils/StaticData/webhookIntegrations.js @@ -114,7 +114,8 @@ export const customFormIntegrations = [ 'GiveWp', 'SenseiLMS', 'FluentPlayer', - 'BitCrm' + 'BitCrm', + 'NextCrm' ] export const actionHookIntegrations = ['ActionHook'] diff --git a/frontend/src/components/AllIntegrations/EditInteg.jsx b/frontend/src/components/AllIntegrations/EditInteg.jsx index d4daa3176..6c8c05d75 100644 --- a/frontend/src/components/AllIntegrations/EditInteg.jsx +++ b/frontend/src/components/AllIntegrations/EditInteg.jsx @@ -180,6 +180,7 @@ const EditCreatorLms = lazy(() => import('./CreatorLms/EditCreatorLms')) const EditUltimateAffiliatePro = lazy(() => import('./UltimateAffiliatePro/EditUltimateAffiliatePro')) const EditBookly = lazy(() => import('./Bookly/EditBookly')) const EditFluentCart = lazy(() => import('./FluentCart/EditFluentCart')) +const EditNextCrm = lazy(() => import('./NextCrm/EditNextCrm')) const EditFluentPlayer = lazy(() => import('./FluentPlayer/EditFluentPlayer')) const EditBitCrm = lazy(() => import('./BitCrm/EditBitCrm')) const EditWsms = lazy(() => import('./Wsms/EditWsms')) @@ -629,6 +630,8 @@ const IntegType = memo(({ allIntegURL, flow }) => { return case 'Bookly': return + case 'NextCrm': + return case 'FluentCart': return case 'FluentPlayer': diff --git a/frontend/src/components/AllIntegrations/IntegInfo.jsx b/frontend/src/components/AllIntegrations/IntegInfo.jsx index 8f1f95c41..841b1f7e0 100644 --- a/frontend/src/components/AllIntegrations/IntegInfo.jsx +++ b/frontend/src/components/AllIntegrations/IntegInfo.jsx @@ -185,6 +185,7 @@ const UltimateAffiliateProAuthorization = lazy( ) const BooklyAuthorization = lazy(() => import('./Bookly/BooklyAuthorization')) const FluentCartAuthorization = lazy(() => import('./FluentCart/FluentCartAuthorization')) +const NextCrmAuthorization = lazy(() => import('./NextCrm/NextCrmAuthorization')) const FluentPlayerAuthorization = lazy(() => import('./FluentPlayer/FluentPlayerAuthorization')) const BitCrmAuthorization = lazy(() => import('./BitCrm/BitCrmAuthorization')) const WsmsAuthorization = lazy(() => import('./Wsms/WsmsAuthorization')) @@ -670,6 +671,8 @@ const IntegrationInfo = memo(({ integrationConf, location, editUrl }) => { return case 'FluentCart': return + case 'NextCrm': + return case 'FluentPlayer': return case 'BitCrm': diff --git a/frontend/src/components/AllIntegrations/NewInteg.jsx b/frontend/src/components/AllIntegrations/NewInteg.jsx index ab927ddcc..329f304d6 100644 --- a/frontend/src/components/AllIntegrations/NewInteg.jsx +++ b/frontend/src/components/AllIntegrations/NewInteg.jsx @@ -179,6 +179,7 @@ const CreatorLms = lazy(() => import('./CreatorLms/CreatorLms')) const UltimateAffiliatePro = lazy(() => import('./UltimateAffiliatePro/UltimateAffiliatePro')) const Bookly = lazy(() => import('./Bookly/Bookly')) const FluentCart = lazy(() => import('./FluentCart/FluentCart')) +const NextCrm = lazy(() => import('./NextCrm/NextCrm')) const FluentPlayer = lazy(() => import('./FluentPlayer/FluentPlayer')) const BitCrm = lazy(() => import('./BitCrm/BitCrm')) const Wsms = lazy(() => import('./Wsms/Wsms')) @@ -1741,6 +1742,15 @@ const NewIntegs = memo(({ integUrlName, allIntegURL, flow, setFlow }) => { setFlow={setFlow} /> ) + case 'NextCrm': + return ( + + ) case 'FluentCart': return ( + + +
+ {__('Integration Name:', 'bit-integrations')} + handleInput(e, nextCrmConf, setNextCrmConf)} + name="name" + value={nextCrmConf.name} + type="text" + placeholder={__('Integration Name...', 'bit-integrations')} + /> +
+
+ + + + + + + saveActionConf({ + flow, + setFlow, + allIntegURL, + conf: nextCrmConf, + navigate, + id, + edit: 1, + setIsLoading, + setSnackbar + }) + } + disabled={!checkMappedFields(nextCrmConf)} + isLoading={isLoading} + dataConf={nextCrmConf} + setDataConf={setNextCrmConf} + formFields={formFields} + /> +
+ + ) +} diff --git a/frontend/src/components/AllIntegrations/NextCrm/NextCrm.jsx b/frontend/src/components/AllIntegrations/NextCrm/NextCrm.jsx new file mode 100644 index 000000000..3f3b16e53 --- /dev/null +++ b/frontend/src/components/AllIntegrations/NextCrm/NextCrm.jsx @@ -0,0 +1,130 @@ +import { useState } from 'react' +import 'react-multiple-select-dropdown-lite/dist/index.css' +import { useNavigate, useParams } from 'react-router' +import BackIcn from '../../../Icons/BackIcn' +import { __ } from '../../../Utils/i18nwrap' +import SnackMsg from '../../Utilities/SnackMsg' +import { saveIntegConfig } from '../IntegrationHelpers/IntegrationHelpers' +import IntegrationStepThree from '../IntegrationHelpers/IntegrationStepThree' +import NextCrmAuthorization from './NextCrmAuthorization' +import { checkMappedFields } from './NextCrmCommonFunc' +import NextCrmIntegLayout from './NextCrmIntegLayout' +import { needsCampaign, needsContactField, needsList, needsStatus, needsTag } from './staticData' + +const requiredSelect = { + selectedStatus: needsStatus, + selectedField: needsContactField, + selectedTag: needsTag, + selectedList: needsList, + selectedCampaign: needsCampaign +} + +export default function NextCrm({ formFields, setFlow, flow, allIntegURL }) { + const navigate = useNavigate() + const { formID } = useParams() + const [isLoading, setIsLoading] = useState(false) + const [step, setStep] = useState(1) + const [snack, setSnackbar] = useState({ show: false }) + const [nextCrmConf, setNextCrmConf] = useState({ + name: 'NextCRM', + type: 'NextCrm', + field_map: [{ formField: '', nextCrmField: '' }], + actions: {}, + mainAction: '' + }) + + const nextPage = val => { + setTimeout(() => { + document.getElementById('btcd-settings-wrp').scrollTop = 0 + }, 300) + + if (val === 3) { + const missingSelect = Object.keys(requiredSelect).find( + key => requiredSelect[key].includes(nextCrmConf.mainAction) && !nextCrmConf?.[key] + ) + + if (missingSelect) { + setSnackbar({ + show: true, + msg: __('Please complete all required selections to continue.', 'bit-integrations') + }) + return + } + + if (!checkMappedFields(nextCrmConf)) { + setSnackbar({ + show: true, + msg: __('Please map all required fields to continue.', 'bit-integrations') + }) + return + } + + if (nextCrmConf.name !== '' && nextCrmConf.field_map.length > 0) { + setStep(val) + } + } else { + setStep(val) + } + } + + return ( +
+ +
+ + {/* STEP 1 */} + + + {/* STEP 2 */} +
+ +
+
+
+ +
+ + {/* STEP 3 */} + + saveIntegConfig(flow, setFlow, allIntegURL, nextCrmConf, navigate, '', '', setIsLoading) + } + isLoading={isLoading} + dataConf={nextCrmConf} + setDataConf={setNextCrmConf} + formFields={formFields} + /> +
+ ) +} diff --git a/frontend/src/components/AllIntegrations/NextCrm/NextCrmActions.jsx b/frontend/src/components/AllIntegrations/NextCrm/NextCrmActions.jsx new file mode 100644 index 000000000..61058a518 --- /dev/null +++ b/frontend/src/components/AllIntegrations/NextCrm/NextCrmActions.jsx @@ -0,0 +1,204 @@ +/* eslint-disable no-param-reassign */ + +import { useState } from 'react' +import { create } from 'mutative' +import MultiSelect from 'react-multiple-select-dropdown-lite' +import { __ } from '../../../Utils/i18nwrap' +import Loader from '../../Loaders/Loader' +import ConfirmModal from '../../Utilities/ConfirmModal' +import TableCheckBox from '../../Utilities/TableCheckBox' +import 'react-multiple-select-dropdown-lite/dist/index.css' +import { + refreshNextCrmContactStatuses, + refreshNextCrmContactTypes, + refreshNextCrmLists, + refreshNextCrmTags +} from './NextCrmCommonFunc' +import { activityStatusOptions, yesNoOptions } from './staticData' + +export default function NextCrmActions({ nextCrmConf, setNextCrmConf }) { + const [isLoading, setIsLoading] = useState(false) + const [actionMdl, setActionMdl] = useState({ show: false }) + const action = nextCrmConf?.mainAction + const isContactSave = ['create_contact', 'update_contact', 'create_or_update_contact'].includes(action) + + const actionHandler = type => { + setActionMdl({ show: type }) + + if (type === 'contact_type') refreshNextCrmContactTypes(setNextCrmConf, setIsLoading) + if (type === 'status') refreshNextCrmContactStatuses(setNextCrmConf, setIsLoading) + if (type === 'lists') refreshNextCrmLists(setNextCrmConf, setIsLoading) + if (type === 'tags') refreshNextCrmTags(setNextCrmConf, setIsLoading) + } + + const clsActionMdl = () => setActionMdl({ show: false }) + + const setAction = (val, name) => + setNextCrmConf(prevConf => + create(prevConf, draftConf => { + if (!draftConf.utilities) { + draftConf.utilities = {} + } + draftConf.utilities[name] = val + }) + ) + + const toOptions = list => (list ?? []).map(item => ({ label: item.label, value: String(item.value) })) + + const renderModal = ({ type, title, options, valueName, isMulti = false, refresher }) => ( + +
+
{title}
+ {isLoading ? ( + + ) : ( +
+ setAction(isMulti ? (val ? val.split(',') : []) : val, valueName)} + singleSelect={!isMulti} + closeOnSelect={!isMulti} + /> + {refresher && ( + + )} +
+ )} + + ) + + return ( +
+ {isContactSave && ( + <> + actionHandler('contact_type')} + className="wdt-200 mt-4 mr-2" + value="contact_type" + title={__('Contact Type', 'bit-integrations')} + subTitle={__('Set the contact type', 'bit-integrations')} + /> + actionHandler('status')} + className="wdt-200 mt-4 mr-2" + value="status" + title={__('Contact Status', 'bit-integrations')} + subTitle={__('Set the contact status', 'bit-integrations')} + /> + 0} + onChange={() => actionHandler('lists')} + className="wdt-200 mt-4 mr-2" + value="lists" + title={__('Lists', 'bit-integrations')} + subTitle={__('Assign the contact to lists', 'bit-integrations')} + /> + 0} + onChange={() => actionHandler('tags')} + className="wdt-200 mt-4 mr-2" + value="tags" + title={__('Tags', 'bit-integrations')} + subTitle={__('Assign tags to the contact', 'bit-integrations')} + /> + + )} + + {action === 'add_contact_activity' && ( + actionHandler('activity_status')} + className="wdt-200 mt-4 mr-2" + value="activity_status" + title={__('Activity Status', 'bit-integrations')} + subTitle={__('Set the activity status', 'bit-integrations')} + /> + )} + + {action === 'send_campaign_email' && ( + actionHandler('skip_already_sent')} + className="wdt-200 mt-4 mr-2" + value="skip_already_sent" + title={__('Skip If Already Sent', 'bit-integrations')} + subTitle={__('Do not queue a contact twice', 'bit-integrations')} + /> + )} + + {renderModal({ + type: 'contact_type', + title: __('Contact Type', 'bit-integrations'), + options: toOptions(nextCrmConf?.allContactTypes), + valueName: 'selected_contact_type', + refresher: refreshNextCrmContactTypes + })} + + {renderModal({ + type: 'status', + title: __('Contact Status', 'bit-integrations'), + options: toOptions(nextCrmConf?.allContactStatuses), + valueName: 'selected_status', + refresher: refreshNextCrmContactStatuses + })} + + {renderModal({ + type: 'lists', + title: __('Lists', 'bit-integrations'), + options: toOptions(nextCrmConf?.allLists), + valueName: 'selected_lists', + isMulti: true, + refresher: refreshNextCrmLists + })} + + {renderModal({ + type: 'tags', + title: __('Tags', 'bit-integrations'), + options: toOptions(nextCrmConf?.allTags), + valueName: 'selected_tags', + isMulti: true, + refresher: refreshNextCrmTags + })} + + {renderModal({ + type: 'activity_status', + title: __('Activity Status', 'bit-integrations'), + options: activityStatusOptions, + valueName: 'selected_activity_status' + })} + + {renderModal({ + type: 'skip_already_sent', + title: __('Skip If Already Sent', 'bit-integrations'), + options: yesNoOptions, + valueName: 'selected_skip_already_sent' + })} +
+ ) +} diff --git a/frontend/src/components/AllIntegrations/NextCrm/NextCrmAuthorization.jsx b/frontend/src/components/AllIntegrations/NextCrm/NextCrmAuthorization.jsx new file mode 100644 index 000000000..1b9bec68c --- /dev/null +++ b/frontend/src/components/AllIntegrations/NextCrm/NextCrmAuthorization.jsx @@ -0,0 +1,30 @@ +import { useCallback } from 'react' +import { AUTH_TYPES } from '../../../Utils/connectionAuth' +import { __ } from '../../../Utils/i18nwrap' +import tutorialLinks from '../../../Utils/StaticData/tutorialLinks' +import Authorization from '../../Connections/Authorization' + +export default function NextCrmAuthorization({ nextCrmConf, setNextCrmConf, step, nextPage, isInfo }) { + const setStep = useCallback(value => nextPage(value), [nextPage]) + return ( + + ) +} diff --git a/frontend/src/components/AllIntegrations/NextCrm/NextCrmCommonFunc.js b/frontend/src/components/AllIntegrations/NextCrm/NextCrmCommonFunc.js new file mode 100644 index 000000000..671818cd5 --- /dev/null +++ b/frontend/src/components/AllIntegrations/NextCrm/NextCrmCommonFunc.js @@ -0,0 +1,109 @@ +import { create } from 'mutative' +import toast from 'react-hot-toast' +import bitsFetch from '../../../Utils/bitsFetch' +import { __ } from '../../../Utils/i18nwrap' + +export const handleInput = (e, nextCrmConf, setNextCrmConf) => { + const { name, value } = e.target + + setNextCrmConf(prevConf => + create(prevConf, draftConf => { + draftConf[name] = value + }) + ) +} + +const refreshOptions = + (route, dataKey, confKey, successMsg, errorMsg) => (setNextCrmConf, setIsLoading) => { + setIsLoading(true) + bitsFetch(null, route) + .then(result => { + if (result && result?.success && result?.data?.[dataKey]) { + setNextCrmConf(prevConf => + create(prevConf, draftConf => { + draftConf[confKey] = result.data[dataKey] + }) + ) + + setIsLoading(false) + toast.success(successMsg) + return + } + setIsLoading(false) + toast.error(errorMsg) + }) + .catch(() => setIsLoading(false)) + } + +export const refreshNextCrmTags = refreshOptions( + 'refresh_next_crm_tags', + 'tags', + 'allTags', + __('All tags fetched successfully', 'bit-integrations'), + __('NextCRM tags fetch failed. Please try again', 'bit-integrations') +) + +export const refreshNextCrmLists = refreshOptions( + 'refresh_next_crm_lists', + 'lists', + 'allLists', + __('All lists fetched successfully', 'bit-integrations'), + __('NextCRM lists fetch failed. Please try again', 'bit-integrations') +) + +export const refreshNextCrmCampaigns = refreshOptions( + 'refresh_next_crm_campaigns', + 'campaigns', + 'allCampaigns', + __('All campaigns fetched successfully', 'bit-integrations'), + __('NextCRM campaigns fetch failed. Please try again', 'bit-integrations') +) + +export const refreshNextCrmContactFields = refreshOptions( + 'refresh_next_crm_contact_fields', + 'contactFields', + 'allContactFields', + __('All contact fields fetched successfully', 'bit-integrations'), + __('NextCRM contact fields fetch failed. Please try again', 'bit-integrations') +) + +export const refreshNextCrmContactTypes = refreshOptions( + 'refresh_next_crm_contact_types', + 'contactTypes', + 'allContactTypes', + __('All contact types fetched successfully', 'bit-integrations'), + __('NextCRM contact types fetch failed. Please try again', 'bit-integrations') +) + +export const refreshNextCrmContactStatuses = refreshOptions( + 'refresh_next_crm_contact_statuses', + 'contactStatuses', + 'allContactStatuses', + __('All contact statuses fetched successfully', 'bit-integrations'), + __('NextCRM contact statuses fetch failed. Please try again', 'bit-integrations') +) + +export const checkMappedFields = nextCrmConf => { + const mappedFields = nextCrmConf?.field_map + ? nextCrmConf.field_map.filter( + mappedField => + !mappedField.formField || + !mappedField.nextCrmField || + (mappedField.formField === 'custom' && !mappedField.customValue) + ) + : [] + if (mappedFields.length > 0) { + return false + } + return true +} + +export const generateMappedField = fields => { + const requiredFlds = fields.filter(fld => fld.required === true) + return requiredFlds.length > 0 + ? requiredFlds.map(field => ({ + formField: '', + nextCrmField: field.key + })) + : [{ formField: '', nextCrmField: '' }] +} diff --git a/frontend/src/components/AllIntegrations/NextCrm/NextCrmFieldMap.jsx b/frontend/src/components/AllIntegrations/NextCrm/NextCrmFieldMap.jsx new file mode 100644 index 000000000..3760b5477 --- /dev/null +++ b/frontend/src/components/AllIntegrations/NextCrm/NextCrmFieldMap.jsx @@ -0,0 +1,104 @@ +import { useRecoilValue } from 'recoil' +import { $appConfigState } from '../../../GlobalStates' +import { __, sprintf } from '../../../Utils/i18nwrap' +import { SmartTagField } from '../../../Utils/StaticData/SmartTagField' +import TagifyInput from '../../Utilities/TagifyInput' +import { + addFieldMap, + delFieldMap, + handleCustomValue, + handleFieldMapping +} from '../GlobalIntegrationHelper' + +export default function NextCrmFieldMap({ i, formFields, field, nextCrmConf, setNextCrmConf }) { + const btcbi = useRecoilValue($appConfigState) + const { isPro } = btcbi + + const requiredFlds = nextCrmConf?.nextCrmFields?.filter(fld => fld.required === true) || [] + const nonRequiredFlds = nextCrmConf?.nextCrmFields?.filter(fld => fld.required === false) || [] + + return ( +
+
+
+ + + {field.formField === 'custom' && ( + handleCustomValue(e, i, nextCrmConf, setNextCrmConf)} + label={__('Custom Value', 'bit-integrations')} + className="mr-2" + type="text" + value={field.customValue} + placeholder={__('Custom Value', 'bit-integrations')} + formFields={formFields} + /> + )} + + +
+ {i >= requiredFlds.length && ( + <> + + + + )} +
+
+ ) +} diff --git a/frontend/src/components/AllIntegrations/NextCrm/NextCrmIntegLayout.jsx b/frontend/src/components/AllIntegrations/NextCrm/NextCrmIntegLayout.jsx new file mode 100644 index 000000000..94414f635 --- /dev/null +++ b/frontend/src/components/AllIntegrations/NextCrm/NextCrmIntegLayout.jsx @@ -0,0 +1,215 @@ +import { create } from 'mutative' +import MultiSelect from 'react-multiple-select-dropdown-lite' +import { useRecoilValue } from 'recoil' +import { $appConfigState } from '../../../GlobalStates' +import { __ } from '../../../Utils/i18nwrap' +import Loader from '../../Loaders/Loader' +import { checkIsPro, getProLabel } from '../../Utilities/ProUtilHelpers' +import { addFieldMap } from '../IntegrationHelpers/IntegrationHelpers' +import NextCrmActions from './NextCrmActions' +import { + generateMappedField, + refreshNextCrmCampaigns, + refreshNextCrmContactFields, + refreshNextCrmContactStatuses, + refreshNextCrmLists, + refreshNextCrmTags +} from './NextCrmCommonFunc' +import NextCrmFieldMap from './NextCrmFieldMap' +import { + hasUtilities, + modules, + needsCampaign, + needsContactField, + needsList, + needsStatus, + needsTag, + nextCrmStaticData +} from './staticData' + +export default function NextCrmIntegLayout({ + formID, + formFields, + nextCrmConf, + setNextCrmConf, + isLoading, + setIsLoading, + setSnackbar +}) { + const btcbi = useRecoilValue($appConfigState) + const { isPro } = btcbi + const action = nextCrmConf?.mainAction + + const setField = (key, value) => + setNextCrmConf(prevConf => + create(prevConf, draftConf => { + draftConf[key] = value + }) + ) + + const handleMainAction = value => { + setNextCrmConf(prevConf => + create(prevConf, draftConf => { + draftConf.mainAction = value + draftConf.nextCrmFields = nextCrmStaticData[value] ?? [] + draftConf.field_map = generateMappedField(draftConf.nextCrmFields) + }) + ) + + if (needsStatus.includes(value)) refreshNextCrmContactStatuses(setNextCrmConf, setIsLoading) + if (needsContactField.includes(value)) refreshNextCrmContactFields(setNextCrmConf, setIsLoading) + if (needsTag.includes(value)) refreshNextCrmTags(setNextCrmConf, setIsLoading) + if (needsList.includes(value)) refreshNextCrmLists(setNextCrmConf, setIsLoading) + if (needsCampaign.includes(value)) refreshNextCrmCampaigns(setNextCrmConf, setIsLoading) + } + + const toOptions = list => (list ?? []).map(item => ({ label: item.label, value: String(item.value) })) + + const renderSelect = (title, label, confKey, optionsKey, refresher) => ( + <> +
+
+ {label} + setField(confKey, value)} + singleSelect + closeOnSelect + /> + +
+ + ) + + return ( + <> +
+
+ {__('Action:', 'bit-integrations')} + handleMainAction(value)} + options={modules?.map(module => ({ + label: checkIsPro(isPro, module.is_pro) ? module.label : getProLabel(module.label), + value: module.name, + disabled: !checkIsPro(isPro, module.is_pro) + }))} + singleSelect + closeOnSelect + /> +
+ + {needsStatus.includes(action) && + renderSelect( + 'selectedStatus', + __('Status:', 'bit-integrations'), + 'selectedStatus', + 'allContactStatuses', + refreshNextCrmContactStatuses + )} + + {needsContactField.includes(action) && + renderSelect( + 'selectedField', + __('Contact Field:', 'bit-integrations'), + 'selectedField', + 'allContactFields', + refreshNextCrmContactFields + )} + + {needsTag.includes(action) && + renderSelect( + 'selectedTag', + __('Tag:', 'bit-integrations'), + 'selectedTag', + 'allTags', + refreshNextCrmTags + )} + + {needsList.includes(action) && + renderSelect( + 'selectedList', + __('List:', 'bit-integrations'), + 'selectedList', + 'allLists', + refreshNextCrmLists + )} + + {needsCampaign.includes(action) && + renderSelect( + 'selectedCampaign', + __('Campaign:', 'bit-integrations'), + 'selectedCampaign', + 'allCampaigns', + refreshNextCrmCampaigns + )} + + {isLoading && ( + + )} + + {action && nextCrmConf?.nextCrmFields && ( +
+ {__('Map Fields', 'bit-integrations')} +
+
+
+ {__('Form Fields', 'bit-integrations')} +
+
+ {__('NextCRM Fields', 'bit-integrations')} +
+
+ + {nextCrmConf?.field_map?.map((itm, i) => ( + + ))} + +
+ +
+
+
+ )} + + {action && nextCrmConf?.nextCrmFields && hasUtilities.includes(action) && ( +
+ {__('Utilities', 'bit-integrations')} +
+ +
+ )} + + ) +} diff --git a/frontend/src/components/AllIntegrations/NextCrm/staticData.js b/frontend/src/components/AllIntegrations/NextCrm/staticData.js new file mode 100644 index 000000000..7d2da8ba5 --- /dev/null +++ b/frontend/src/components/AllIntegrations/NextCrm/staticData.js @@ -0,0 +1,176 @@ +import { __ } from '../../../Utils/i18nwrap' + +export const modules = [ + { name: 'create_contact', label: __('Create Contact', 'bit-integrations'), is_pro: true }, + { name: 'update_contact', label: __('Update Contact', 'bit-integrations'), is_pro: true }, + { + name: 'create_or_update_contact', + label: __('Create or Update Contact', 'bit-integrations'), + is_pro: true + }, + { name: 'delete_contact', label: __('Delete Contact', 'bit-integrations'), is_pro: true }, + { + name: 'change_contact_status', + label: __('Change Contact Status', 'bit-integrations'), + is_pro: true + }, + { + name: 'update_contact_field', + label: __('Update Contact Field', 'bit-integrations'), + is_pro: true + }, + { name: 'set_contact_meta', label: __('Set Contact Meta', 'bit-integrations'), is_pro: true }, + { + name: 'delete_contact_meta', + label: __('Delete Contact Meta', 'bit-integrations'), + is_pro: true + }, + { + name: 'add_contact_activity', + label: __('Add Contact Activity', 'bit-integrations'), + is_pro: true + }, + { name: 'create_tag', label: __('Create Tag', 'bit-integrations'), is_pro: true }, + { name: 'add_tag_to_contact', label: __('Add Tag to Contact', 'bit-integrations'), is_pro: true }, + { + name: 'remove_tag_from_contact', + label: __('Remove Tag from Contact', 'bit-integrations'), + is_pro: true + }, + { name: 'create_list', label: __('Create List', 'bit-integrations'), is_pro: true }, + { + name: 'add_contact_to_list', + label: __('Add Contact to List', 'bit-integrations'), + is_pro: true + }, + { + name: 'remove_contact_from_list', + label: __('Remove Contact from List', 'bit-integrations'), + is_pro: true + }, + { + name: 'send_campaign_email', + label: __('Send Campaign Email to Contact', 'bit-integrations'), + is_pro: true + } +] + +const ContactEmailField = { + key: 'contact_email', + label: __('Contact Email', 'bit-integrations'), + required: true +} + +const AdditionalFields = [ + { key: 'mobile', label: __('Mobile', 'bit-integrations'), required: false }, + { key: 'source', label: __('Source', 'bit-integrations'), required: false }, + { key: 'date_of_birth', label: __('Date of Birth', 'bit-integrations'), required: false }, + { key: 'gender', label: __('Gender', 'bit-integrations'), required: false }, + { key: 'address', label: __('Address', 'bit-integrations'), required: false }, + { key: 'city', label: __('City', 'bit-integrations'), required: false }, + { key: 'state', label: __('State', 'bit-integrations'), required: false }, + { key: 'zip', label: __('Zip', 'bit-integrations'), required: false }, + { key: 'country', label: __('Country', 'bit-integrations'), required: false } +] + +const ContactProfileFields = [ + { key: 'first_name', label: __('First Name', 'bit-integrations'), required: false }, + { key: 'last_name', label: __('Last Name', 'bit-integrations'), required: false }, + { key: 'photo', label: __('Photo URL', 'bit-integrations'), required: false }, + { key: 'rating', label: __('Rating', 'bit-integrations'), required: false }, + ...AdditionalFields +] + +export const CreateContactFields = [ + { key: 'email_address', label: __('Email Address', 'bit-integrations'), required: true }, + ...ContactProfileFields +] + +export const UpdateContactFields = [ + ContactEmailField, + { key: 'new_email_address', label: __('New Email Address', 'bit-integrations'), required: false }, + ...ContactProfileFields +] + +export const ContactEmailOnlyFields = [ContactEmailField] + +export const UpdateContactFieldFields = [ + ContactEmailField, + { key: 'value', label: __('Value', 'bit-integrations'), required: true } +] + +export const SetContactMetaFields = [ + ContactEmailField, + { key: 'meta_key', label: __('Meta Key', 'bit-integrations'), required: true }, + { key: 'meta_value', label: __('Meta Value', 'bit-integrations'), required: true } +] + +export const DeleteContactMetaFields = [ + ContactEmailField, + { key: 'meta_key', label: __('Meta Key', 'bit-integrations'), required: true } +] + +export const ContactActivityFields = [ + ContactEmailField, + { key: 'title', label: __('Title', 'bit-integrations'), required: true }, + { key: 'description', label: __('Description', 'bit-integrations'), required: false } +] + +export const TaxonomyFields = [ + { key: 'title', label: __('Title', 'bit-integrations'), required: true }, + { key: 'name', label: __('Slug', 'bit-integrations'), required: false }, + { key: 'description', label: __('Description', 'bit-integrations'), required: false } +] + +export const CampaignEmailFields = [ + ContactEmailField, + { key: 'body', label: __('Email Body', 'bit-integrations'), required: true }, + { key: 'subject', label: __('Subject', 'bit-integrations'), required: false } +] + +export const nextCrmStaticData = { + create_contact: CreateContactFields, + create_or_update_contact: CreateContactFields, + update_contact: UpdateContactFields, + delete_contact: ContactEmailOnlyFields, + change_contact_status: ContactEmailOnlyFields, + update_contact_field: UpdateContactFieldFields, + set_contact_meta: SetContactMetaFields, + delete_contact_meta: DeleteContactMetaFields, + add_contact_activity: ContactActivityFields, + create_tag: TaxonomyFields, + create_list: TaxonomyFields, + add_tag_to_contact: ContactEmailOnlyFields, + remove_tag_from_contact: ContactEmailOnlyFields, + add_contact_to_list: ContactEmailOnlyFields, + remove_contact_from_list: ContactEmailOnlyFields, + send_campaign_email: CampaignEmailFields +} + +// Fixed option sets — rendered as selects, never mapped. +export const activityStatusOptions = [ + { label: __('Active', 'bit-integrations'), value: 'active' }, + { label: __('Deactive', 'bit-integrations'), value: 'deactive' }, + { label: __('Delete', 'bit-integrations'), value: 'delete' } +] + +export const yesNoOptions = [ + { label: __('No', 'bit-integrations'), value: 'no' }, + { label: __('Yes', 'bit-integrations'), value: 'yes' } +] + +// Required config selects → IntegLayout. +export const needsStatus = ['change_contact_status'] +export const needsContactField = ['update_contact_field'] +export const needsTag = ['add_tag_to_contact', 'remove_tag_from_contact'] +export const needsList = ['add_contact_to_list', 'remove_contact_from_list'] +export const needsCampaign = ['send_campaign_email'] + +// Optional config selects → Utilities. +export const hasUtilities = [ + 'create_contact', + 'update_contact', + 'create_or_update_contact', + 'add_contact_activity', + 'send_campaign_email' +] diff --git a/frontend/src/components/Flow/New/SelectAction.jsx b/frontend/src/components/Flow/New/SelectAction.jsx index f2efc8ff1..07424a2c1 100644 --- a/frontend/src/components/Flow/New/SelectAction.jsx +++ b/frontend/src/components/Flow/New/SelectAction.jsx @@ -164,6 +164,7 @@ export default function SelectAction() { { type: 'ZagoMail', is_pro: false }, { type: 'Drip', is_pro: false }, { type: 'Newsletter', is_pro: false }, + { type: 'NextCrm', name: 'NextCRM', is_pro: true }, { type: 'SureDash', is_pro: true }, { type: 'SureMembers', is_pro: false }, { type: 'Mailster', is_pro: false }, diff --git a/frontend/src/resource/img/integ/nextCrm.webp b/frontend/src/resource/img/integ/nextCrm.webp new file mode 100644 index 000000000..54c00fabe Binary files /dev/null and b/frontend/src/resource/img/integ/nextCrm.webp differ