diff --git a/backend/Actions/Sender/RecordApiHelper.php b/backend/Actions/Sender/RecordApiHelper.php new file mode 100644 index 00000000..af98767f --- /dev/null +++ b/backend/Actions/Sender/RecordApiHelper.php @@ -0,0 +1,119 @@ +_integrationDetails = $integrationDetails; + $this->_integrationID = $integId; + } + + public function execute($fieldValues, $fieldMap) + { + $fieldData = static::generateReqDataFromFieldMap($fieldMap, $fieldValues); + $mainAction = $this->_integrationDetails->mainAction ?? ''; + + $defaultResponse = [ + 'success' => false, + // translators: %s: Plugin name + 'message' => wp_sprintf(__('%s plugin is not installed or activates', 'bit-integrations'), 'Bit Integrations Pro'), + ]; + + switch ($mainAction) { + case 'create_or_update_subscriber': + $response = Hooks::apply(Config::withPrefix('sender_create_or_update_subscriber'), $defaultResponse, $fieldData, $this->_integrationDetails); + + break; + + case 'update_subscriber': + $response = Hooks::apply(Config::withPrefix('sender_update_subscriber'), $defaultResponse, $fieldData, $this->_integrationDetails); + + break; + + case 'delete_subscriber': + $response = Hooks::apply(Config::withPrefix('sender_delete_subscriber'), $defaultResponse, $fieldData, $this->_integrationDetails); + + break; + + case 'remove_phone_from_subscriber': + $response = Hooks::apply(Config::withPrefix('sender_remove_phone_from_subscriber'), $defaultResponse, $fieldData, $this->_integrationDetails); + + break; + + case 'add_subscriber_to_group': + $response = Hooks::apply(Config::withPrefix('sender_add_subscriber_to_group'), $defaultResponse, $fieldData, $this->_integrationDetails); + + break; + + case 'remove_subscriber_from_group': + $response = Hooks::apply(Config::withPrefix('sender_remove_subscriber_from_group'), $defaultResponse, $fieldData, $this->_integrationDetails); + + break; + + case 'create_group': + $response = Hooks::apply(Config::withPrefix('sender_create_group'), $defaultResponse, $fieldData, $this->_integrationDetails); + + break; + + case 'update_group': + $response = Hooks::apply(Config::withPrefix('sender_update_group'), $defaultResponse, $fieldData, $this->_integrationDetails); + + break; + + case 'delete_group': + $response = Hooks::apply(Config::withPrefix('sender_delete_group'), $defaultResponse, $fieldData, $this->_integrationDetails); + + break; + + default: + $response = [ + 'success' => false, + 'message' => __('Invalid action', 'bit-integrations'), + ]; + + break; + } + + $responseType = !is_wp_error($response) && isset($response['success']) && $response['success'] ? 'success' : 'error'; + LogHandler::save($this->_integrationID, ['type' => 'Sender', 'type_name' => $mainAction], $responseType, $response); + + return $response; + } + + private static function generateReqDataFromFieldMap($fieldMap, $fieldValues) + { + $dataFinal = []; + foreach ($fieldMap as $item) { + if (empty($item->senderField)) { + continue; + } + + $triggerValue = $item->formField; + $actionValue = $item->senderField; + + $dataFinal[$actionValue] = $triggerValue === 'custom' && isset($item->customValue) + ? Common::replaceFieldWithValue($item->customValue, $fieldValues) + : ($fieldValues[$triggerValue] ?? ''); + } + + return $dataFinal; + } +} diff --git a/backend/Actions/Sender/Routes.php b/backend/Actions/Sender/Routes.php new file mode 100644 index 00000000..96668d46 --- /dev/null +++ b/backend/Actions/Sender/Routes.php @@ -0,0 +1,12 @@ +api_token)) { + wp_send_json_error(__('API token is required', 'bit-integrations'), 400); + } + + $response = HttpHelper::get(self::$baseUrl . '/groups', null, self::authHeader($requestParams->api_token)); + + if (is_wp_error($response)) { + wp_send_json_error($response->get_error_message(), 400); + } + + if (HttpHelper::$responseCode >= 200 && HttpHelper::$responseCode < 300) { + wp_send_json_success(__('Authorized Successfully', 'bit-integrations'), 200); + } + + wp_send_json_error(__('Invalid API token', 'bit-integrations'), 400); + } + + /** + * Fetch the account groups for the group dropdowns. + * + * @param object $requestParams + */ + public static function refreshGroups($requestParams) + { + if (empty($requestParams->api_token)) { + wp_send_json_error(__('API token is required', 'bit-integrations'), 400); + } + + $response = HttpHelper::get(self::$baseUrl . '/groups', null, self::authHeader($requestParams->api_token)); + + if (is_wp_error($response)) { + wp_send_json_error($response->get_error_message(), 400); + } + + if (HttpHelper::$responseCode < 200 || HttpHelper::$responseCode >= 300) { + wp_send_json_error(__('Failed to fetch groups', 'bit-integrations'), 400); + } + + $groups = []; + foreach (self::dataRows($response) as $group) { + $groups[] = [ + 'id' => $group->id ?? '', + 'title' => $group->title ?? '', + ]; + } + + wp_send_json_success(['groups' => $groups], 200); + } + + /** + * Fetch the account custom fields so they can be mapped on subscriber actions. + * + * @param object $requestParams + */ + public static function refreshFields($requestParams) + { + if (empty($requestParams->api_token)) { + wp_send_json_error(__('API token is required', 'bit-integrations'), 400); + } + + $response = HttpHelper::get(self::$baseUrl . '/fields', null, self::authHeader($requestParams->api_token)); + + if (is_wp_error($response)) { + wp_send_json_error($response->get_error_message(), 400); + } + + if (HttpHelper::$responseCode < 200 || HttpHelper::$responseCode >= 300) { + wp_send_json_error(__('Failed to fetch fields', 'bit-integrations'), 400); + } + + $defaultFields = ['email', 'firstname', 'lastname', 'phone']; + $fields = []; + foreach (self::dataRows($response) as $field) { + // Sender returns the field token as `name` in {{slug}} form; the subscriber `fields` + // payload must be keyed by the {$slug} personalization token, not the numeric/string id. + $token = isset($field->name) ? str_replace(['{{', '}}'], ['{$', '}'], $field->name) : ''; + if ($token === '') { + continue; + } + + // Default subscriber fields are mapped top-level (email/firstname/lastname/phone), + // so keep them out of the custom-field options to avoid duplicate mapping. + $slug = strtolower(preg_replace('/[{}$\s]/', '', $token)); + if (\in_array($slug, $defaultFields, true)) { + continue; + } + + $fields[] = [ + 'key' => $token, + 'label' => $field->title ?? $token, + ]; + } + + wp_send_json_success(['fields' => $fields], 200); + } + + public function execute($integrationData, $fieldValues) + { + $integrationDetails = $integrationData->flow_details; + $integId = $integrationData->id; + $fieldMap = $integrationDetails->field_map ?? []; + + if (empty($integrationDetails->api_token)) { + return new WP_Error('api_token_empty', __('Sender API token is required', 'bit-integrations')); + } + + $recordApiHelper = new RecordApiHelper($integrationDetails, $integId); + $senderResponse = $recordApiHelper->execute($fieldValues, $fieldMap); + + if (is_wp_error($senderResponse)) { + return $senderResponse; + } + + return $senderResponse; + } + + private static function authHeader($token) + { + return [ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ]; + } + + private static function dataRows($response) + { + if (isset($response->data) && \is_array($response->data)) { + return $response->data; + } + + return \is_array($response) ? $response : []; + } +} diff --git a/frontend/src/Utils/StaticData/tutorialLinks.js b/frontend/src/Utils/StaticData/tutorialLinks.js index b95d5852..a8619269 100644 --- a/frontend/src/Utils/StaticData/tutorialLinks.js +++ b/frontend/src/Utils/StaticData/tutorialLinks.js @@ -677,6 +677,10 @@ const tutorialLinks = { bookingPress: { youTubeLink: '', docLink: '' + }, + sender: { + youTubeLink: '', + docLink: 'https://bit-integrations.com/wp-docs/actions/sender-integrations/' } } export default tutorialLinks diff --git a/frontend/src/components/AllIntegrations/EditInteg.jsx b/frontend/src/components/AllIntegrations/EditInteg.jsx index 995763b1..5fc70ebb 100644 --- a/frontend/src/components/AllIntegrations/EditInteg.jsx +++ b/frontend/src/components/AllIntegrations/EditInteg.jsx @@ -26,6 +26,7 @@ const EditPod = lazy(() => import('./Pods/EditPod')) const EditMailPoet = lazy(() => import('./MailPoet/EditMailPoet')) const EditMailerPress = lazy(() => import('./MailerPress/EditMailerPress')) const EditSendinBlue = lazy(() => import('./SendinBlue/EditSendinBlue')) +const EditSender = lazy(() => import('./Sender/EditSender')) const EditWooCommerce = lazy(() => import('./WooCommerce/EditWooCommerce')) const EditActiveCampaign = lazy(() => import('./ActiveCampaign/EditActiveCampaign')) const EditWebHooks = lazy(() => import('./WebHooks/EditWebHooks')) @@ -307,6 +308,8 @@ const IntegType = memo(({ allIntegURL, flow }) => { return case 'MailerPress': return + case 'Sender': + return case 'SendinBlue': case 'Brevo(Sendinblue)': return diff --git a/frontend/src/components/AllIntegrations/IntegInfo.jsx b/frontend/src/components/AllIntegrations/IntegInfo.jsx index 9624114c..d7985ba8 100644 --- a/frontend/src/components/AllIntegrations/IntegInfo.jsx +++ b/frontend/src/components/AllIntegrations/IntegInfo.jsx @@ -32,6 +32,7 @@ const MailChimpAuthorization = lazy(() => import('./MailChimp/MailChimpAuthoriza const MailPoetAuthorization = lazy(() => import('./MailPoet/MailPoetAuthorization')) const MailerPressAuthorization = lazy(() => import('./MailerPress/MailerPressAuthorization')) const SendinblueAuthorization = lazy(() => import('./SendinBlue/SendinBlueAuthorization')) +const SenderAuthorization = lazy(() => import('./Sender/SenderAuthorization')) const WooCommerceAuthorization = lazy(() => import('./WooCommerce/WooCommerceAuthorization')) const ActiveCampaignAuthorization = lazy(() => import('./ActiveCampaign/ActiveCampaignAuthorization')) const ZohoFlowAuthorization = lazy(() => import('./IntegrationHelpers/WebHook/WebHooksIntegration')) @@ -341,6 +342,8 @@ export default function IntegInfo() { return case 'MailerPress': return + case 'Sender': + return case 'SendinBlue': case 'Brevo(Sendinblue)': return diff --git a/frontend/src/components/AllIntegrations/NewInteg.jsx b/frontend/src/components/AllIntegrations/NewInteg.jsx index f11913e2..1c5a6c65 100644 --- a/frontend/src/components/AllIntegrations/NewInteg.jsx +++ b/frontend/src/components/AllIntegrations/NewInteg.jsx @@ -27,6 +27,7 @@ const MailChimp = lazy(() => import('./MailChimp/MailChimp')) const MailPoet = lazy(() => import('./MailPoet/MailPoet')) const MailerPress = lazy(() => import('./MailerPress/MailerPress')) const Sendinblue = lazy(() => import('./SendinBlue/SendinBlue')) +const Sender = lazy(() => import('./Sender/Sender')) const WooCommerce = lazy(() => import('./WooCommerce/WooCommerce')) const Pods = lazy(() => import('./Pods/Pods')) const ActiveCampaign = lazy(() => import('./ActiveCampaign/ActiveCampaign')) @@ -333,6 +334,15 @@ export default function NewInteg({ allIntegURL }) { setFlow={setFlow} /> ) + case 'Sender': + return ( + + ) case 'SendinBlue': return ( + + +
+ {__('Integration Name:', 'bit-integrations')} + handleInput(e, senderConf, setSenderConf)} + name="name" + value={senderConf.name} + type="text" + placeholder={__('Integration Name...', 'bit-integrations')} + /> +
+
+ + + + + + + saveActionConf({ + flow, + setFlow, + allIntegURL, + conf: senderConf, + navigate, + id, + edit: 1, + setIsLoading, + setSnackbar + }) + } + disabled={!checkMappedFields(senderConf)} + isLoading={isLoading} + dataConf={senderConf} + setDataConf={setSenderConf} + formFields={formFields} + /> +
+ + ) +} diff --git a/frontend/src/components/AllIntegrations/Sender/Sender.jsx b/frontend/src/components/AllIntegrations/Sender/Sender.jsx new file mode 100644 index 00000000..dd9625c9 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Sender/Sender.jsx @@ -0,0 +1,133 @@ +import { useState } from 'react' +import 'react-multiple-select-dropdown-lite/dist/index.css' +import { useNavigate } from 'react-router' +import toast from 'react-hot-toast' +import { __ } from '../../../Utils/i18nwrap' +import SnackMsg from '../../Utilities/SnackMsg' +import Steps from '../../Utilities/Steps' +import { saveIntegConfig } from '../IntegrationHelpers/IntegrationHelpers' +import IntegrationStepThree from '../IntegrationHelpers/IntegrationStepThree' +import SenderAuthorization from './SenderAuthorization' +import { checkMappedFields } from './SenderCommonFunc' +import SenderIntegLayout from './SenderIntegLayout' +import { singleGroupActions } from './staticData' + +function Sender({ formFields, setFlow, flow, allIntegURL }) { + const navigate = useNavigate() + const [isLoading, setIsLoading] = useState(false) + const [loading, setLoading] = useState({ field: false, auth: false, group: false }) + const [step, setstep] = useState(1) + const [snack, setSnackbar] = useState({ show: false }) + + const [senderConf, setSenderConf] = useState({ + name: 'Sender', + type: 'Sender', + api_token: '', + field_map: [{ formField: '', senderField: '' }], + mainAction: '', + senderFields: [], + allGroups: [], + allFields: [], + groups: [], + groupId: '', + actions: {} + }) + + const saveConfig = () => { + if (singleGroupActions.includes(senderConf?.mainAction) && !senderConf?.groupId) { + toast.error(__('Please select a group', 'bit-integrations')) + return + } + if (!checkMappedFields(senderConf)) { + toast.error(__('Please map mandatory fields', 'bit-integrations')) + return + } + + setIsLoading(true) + const resp = saveIntegConfig(flow, setFlow, allIntegURL, senderConf, navigate, '', '', setIsLoading) + resp.then(res => { + if (res.success) { + toast.success(res.data?.msg) + navigate(allIntegURL) + } else { + toast.error(res.data || res) + } + }) + } + + const nextPage = pageNo => { + setTimeout(() => { + document.getElementById('btcd-settings-wrp').scrollTop = 0 + }, 300) + + if (!senderConf.mainAction) { + toast.error(__('Please select an action', 'bit-integrations')) + return + } + if (singleGroupActions.includes(senderConf?.mainAction) && !senderConf?.groupId) { + toast.error(__('Please select a group', 'bit-integrations')) + return + } + if (!checkMappedFields(senderConf)) { + toast.error(__('Please map mandatory fields', 'bit-integrations')) + return + } + setstep(pageNo) + } + + return ( +
+ +
+ +
+ + {/* STEP 1 */} + + + {/* STEP 2 */} +
+ + + {senderConf?.mainAction && ( + + )} +
+ + {/* STEP 3 */} + saveConfig()} + isLoading={isLoading} + dataConf={senderConf} + setDataConf={setSenderConf} + formFields={formFields} + /> +
+ ) +} + +export default Sender diff --git a/frontend/src/components/AllIntegrations/Sender/SenderActions.jsx b/frontend/src/components/AllIntegrations/Sender/SenderActions.jsx new file mode 100644 index 00000000..70cb9773 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Sender/SenderActions.jsx @@ -0,0 +1,129 @@ +/* eslint-disable no-param-reassign */ + +import { create } from 'mutative' +import { useState } from 'react' +import MultiSelect from 'react-multiple-select-dropdown-lite' +import 'react-multiple-select-dropdown-lite/dist/index.css' +import { __ } from '../../../Utils/i18nwrap' +import Loader from '../../Loaders/Loader' +import ConfirmModal from '../../Utilities/ConfirmModal' +import TableCheckBox from '../../Utilities/TableCheckBox' +import { refreshSenderGroups } from './SenderCommonFunc' +import { subscriberActions } from './staticData' + +export default function SenderActions({ senderConf, setSenderConf, isLoading, setIsLoading }) { + const [actionMdl, setActionMdl] = useState({ show: false }) + + const clsActionMdl = () => setActionMdl({ show: false }) + + const actionHandler = (e, type) => { + if (type === 'groups') { + refreshSenderGroups(senderConf, setSenderConf, setIsLoading) + } + + setActionMdl({ show: type }) + + setSenderConf(prevConf => + create(prevConf, draftConf => { + draftConf.actions = { ...(draftConf.actions || {}) } + + if (e.target.checked) { + draftConf.actions[type] = true + } else { + delete draftConf.actions[type] + if (type === 'groups') { + draftConf.groups = [] + } + } + }) + ) + } + + const setGroups = val => { + setSenderConf(prevConf => + create(prevConf, draftConf => { + draftConf.actions = { ...(draftConf.actions || {}) } + draftConf.groups = val + + if (val.length) { + draftConf.actions.groups = true + } else { + delete draftConf.actions.groups + } + }) + ) + } + + const groupOptions = (senderConf?.allGroups ?? []).map(group => ({ + label: group.title, + value: String(group.id) + })) + + return ( + <> +
+ {subscriberActions.includes(senderConf?.mainAction) && ( + actionHandler(e, 'groups')} + className="wdt-200 mt-4 mr-2" + value="groups" + title={__('Groups', 'bit-integrations')} + subTitle={__('Add the subscriber to groups', 'bit-integrations')} + /> + )} + + actionHandler(e, 'trigger_automation')} + className="wdt-200 mt-4 mr-2" + value="trigger_automation" + title={__('Trigger Automation', 'bit-integrations')} + subTitle={__('Trigger automations for the selected groups', 'bit-integrations')} + /> +
+ + +
+ + {isLoading ? ( + + ) : ( +
+ setGroups(val)} + customValue + /> + +
+ )} + + + ) +} diff --git a/frontend/src/components/AllIntegrations/Sender/SenderAuthorization.jsx b/frontend/src/components/AllIntegrations/Sender/SenderAuthorization.jsx new file mode 100644 index 00000000..4cd7c345 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Sender/SenderAuthorization.jsx @@ -0,0 +1,112 @@ +/* eslint-disable no-unused-expressions */ +import { useState } from 'react' +import { __ } from '../../../Utils/i18nwrap' +import LoaderSm from '../../Loaders/LoaderSm' +import Note from '../../Utilities/Note' +import TutorialLink from '../../Utilities/TutorialLink' +import { authorization } from './SenderCommonFunc' + +const tokenUrl = 'https://app.sender.net/settings/tokens' +const note = ` +

${__('Steps to generate an API access token:', 'bit-integrations')}

+ + ` + +export default function SenderAuthorization({ + senderConf, + setSenderConf, + step, + setstep, + loading, + setLoading, + isInfo +}) { + const [isAuthorized, setIsAuthorized] = useState(false) + + const nextPage = () => { + setTimeout(() => { + document.getElementById('btcd-settings-wrp').scrollTop = 0 + }, 300) + setstep(2) + } + + const handleInput = e => { + const newConf = { ...senderConf } + newConf[e.target.name] = e.target.value + setSenderConf(newConf) + } + + return ( +
+ + +
+ {__('Integration Name:', 'bit-integrations')} +
+ + +
+ {__('API Token:', 'bit-integrations')} +
+ + + + {__('To get your API token, please visit', 'bit-integrations')} +   + + {__('Sender API Access Tokens', 'bit-integrations')} + + +
+
+ + {!isInfo && ( +
+ +
+ +
+ )} + +
+ ) +} diff --git a/frontend/src/components/AllIntegrations/Sender/SenderCommonFunc.js b/frontend/src/components/AllIntegrations/Sender/SenderCommonFunc.js new file mode 100644 index 00000000..5f603f26 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Sender/SenderCommonFunc.js @@ -0,0 +1,111 @@ +/* eslint-disable no-else-return */ +import { create } from 'mutative' +import toast from 'react-hot-toast' +import bitsFetch from '../../../Utils/bitsFetch' +import { __ } from '../../../Utils/i18nwrap' +import { singleGroupActions } from './staticData' + +export const handleInput = (e, senderConf, setSenderConf) => { + const { name, value } = e.target + + setSenderConf(prevConf => + create(prevConf, draftConf => { + draftConf[name] = value + }) + ) +} + +export const generateMappedField = fields => { + const requiredFlds = (fields || []).filter(fld => fld.required === true) + return requiredFlds.length > 0 + ? requiredFlds.map(field => ({ formField: '', senderField: field.key })) + : [{ formField: '', senderField: '' }] +} + +export const checkMappedFields = senderConf => { + // Group-target actions require a selected group id, which lives outside the field map. + if (singleGroupActions.includes(senderConf?.mainAction) && !senderConf?.groupId) { + return false + } + + const mappedFields = senderConf?.field_map + ? senderConf.field_map.filter( + mappedField => + !mappedField.formField || + !mappedField.senderField || + (mappedField.formField === 'custom' && !mappedField.customValue) + ) + : [] + return mappedFields.length < 1 +} + +export const authorization = (confTmp, setIsAuthorized, loading, setLoading) => { + if (!confTmp.api_token) { + toast.error(__("API token can't be empty", 'bit-integrations')) + return + } + + setLoading({ ...loading, auth: true }) + + bitsFetch({ api_token: confTmp.api_token }, 'sender_authorize').then(result => { + setLoading({ ...loading, auth: false }) + + if (result && result.success) { + setIsAuthorized(true) + toast.success(__('Authorized Successfully', 'bit-integrations')) + return + } + + toast.error(__('Authorization failed', 'bit-integrations')) + }) +} + +export const refreshSenderGroups = (confTmp, setSenderConf, setIsLoading) => { + if (!confTmp.api_token) { + toast.error(__("API token can't be empty", 'bit-integrations')) + return + } + + setIsLoading(true) + bitsFetch({ api_token: confTmp.api_token }, 'refresh_sender_groups') + .then(result => { + if (result && result.success && result.data?.groups) { + setSenderConf(prevConf => + create(prevConf, draftConf => { + draftConf.allGroups = result.data.groups + }) + ) + setIsLoading(false) + toast.success(__('Groups fetched successfully', 'bit-integrations')) + return + } + setIsLoading(false) + toast.error(__('Groups fetch failed', 'bit-integrations')) + }) + .catch(() => setIsLoading(false)) +} + +export const refreshSenderFields = (confTmp, setSenderConf, setIsLoading) => { + if (!confTmp.api_token) { + toast.error(__("API token can't be empty", 'bit-integrations')) + return + } + + setIsLoading(true) + bitsFetch({ api_token: confTmp.api_token }, 'refresh_sender_fields') + .then(result => { + if (result && result.success && result.data?.fields) { + setSenderConf(prevConf => + create(prevConf, draftConf => { + draftConf.allFields = result.data.fields + }) + ) + setIsLoading(false) + toast.success(__('Custom fields fetched successfully', 'bit-integrations')) + return + } + setIsLoading(false) + toast.error(__('Custom fields fetch failed', 'bit-integrations')) + }) + .catch(() => setIsLoading(false)) +} diff --git a/frontend/src/components/AllIntegrations/Sender/SenderFieldMap.jsx b/frontend/src/components/AllIntegrations/Sender/SenderFieldMap.jsx new file mode 100644 index 00000000..fd7f11cd --- /dev/null +++ b/frontend/src/components/AllIntegrations/Sender/SenderFieldMap.jsx @@ -0,0 +1,108 @@ +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 SenderFieldMap({ i, formFields, field, senderConf, setSenderConf }) { + const btcbi = useRecoilValue($appConfigState) + const { isPro } = btcbi + + const requiredFlds = senderConf?.senderFields?.filter(fld => fld.required === true) || [] + const staticNonRequired = senderConf?.senderFields?.filter(fld => fld.required === false) || [] + const fetchedFields = Array.isArray(senderConf?.allFields) + ? senderConf.allFields.map(f => ({ key: f.key, label: f.label })) + : [] + const nonRequiredFlds = [...staticNonRequired, ...fetchedFields] + + return ( +
+
+
+ + + {field.formField === 'custom' && ( + handleCustomValue(e, i, senderConf, setSenderConf)} + 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/Sender/SenderIntegLayout.jsx b/frontend/src/components/AllIntegrations/Sender/SenderIntegLayout.jsx new file mode 100644 index 00000000..860e029b --- /dev/null +++ b/frontend/src/components/AllIntegrations/Sender/SenderIntegLayout.jsx @@ -0,0 +1,184 @@ +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 { generateMappedField, refreshSenderFields, refreshSenderGroups } from './SenderCommonFunc' +import SenderActions from './SenderActions' +import SenderFieldMap from './SenderFieldMap' +import { + modules, + senderFieldsByAction, + singleGroupActions, + subscriberActions, + triggerAutomationActions +} from './staticData' + +export default function SenderIntegLayout({ + formFields, + senderConf, + setSenderConf, + isLoading, + setIsLoading +}) { + const btcbi = useRecoilValue($appConfigState) + const { isPro } = btcbi + + const setField = (key, val) => + setSenderConf(prevConf => + create(prevConf, draftConf => { + draftConf[key] = val + }) + ) + + const handleMainAction = value => { + const fields = senderFieldsByAction[value] || [] + + setSenderConf(prevConf => + create(prevConf, draftConf => { + draftConf.mainAction = value + draftConf.senderFields = fields + draftConf.field_map = fields.length ? generateMappedField(fields) : [] + // Clear per-action utilities/group selections so they never leak across an action switch. + draftConf.actions = {} + draftConf.groupId = '' + draftConf.groups = [] + }) + ) + + if (singleGroupActions.includes(value) || subscriberActions.includes(value)) { + refreshSenderGroups(senderConf, setSenderConf, setIsLoading) + } + if (subscriberActions.includes(value)) { + refreshSenderFields(senderConf, setSenderConf, setIsLoading) + } + } + + const groupOptions = (senderConf?.allGroups ?? []).map(group => ({ + label: group.title, + value: String(group.id) + })) + + return ( + <> +
+
+ {__('Action:', 'bit-integrations')} + handleMainAction(value)} + options={modules?.map(action => ({ + label: checkIsPro(isPro, action.is_pro) ? action.label : getProLabel(action.label), + value: action.name, + disabled: !checkIsPro(isPro, action.is_pro) + }))} + singleSelect + closeOnSelect + /> +
+ + {singleGroupActions.includes(senderConf?.mainAction) && ( + <> +
+
+ {__('Group:', 'bit-integrations')} + setField('groupId', val)} + singleSelect + closeOnSelect + /> + +
+ + )} + + {isLoading && ( + + )} + + {senderConf?.mainAction && senderConf?.senderFields?.length > 0 && ( +
+ {__('Map Fields', 'bit-integrations')} + {subscriberActions.includes(senderConf?.mainAction) && ( + + )} +
+
+
+ {__('Form Fields', 'bit-integrations')} +
+
+ {__('Sender Fields', 'bit-integrations')} +
+
+ + {senderConf?.field_map?.map((itm, i) => ( + + ))} + {subscriberActions.includes(senderConf?.mainAction) && ( +
+ +
+ )} +
+
+ )} + + {triggerAutomationActions.includes(senderConf?.mainAction) && ( +
+ {__('Utilities', 'bit-integrations')} +
+ +
+ )} + + ) +} diff --git a/frontend/src/components/AllIntegrations/Sender/staticData.js b/frontend/src/components/AllIntegrations/Sender/staticData.js new file mode 100644 index 00000000..c7156155 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Sender/staticData.js @@ -0,0 +1,95 @@ +import { __ } from '../../../Utils/i18nwrap' + +export const modules = [ + { + name: 'create_or_update_subscriber', + label: __('Create or Update Subscriber', 'bit-integrations'), + is_pro: true + }, + { name: 'update_subscriber', label: __('Update Subscriber', 'bit-integrations'), is_pro: true }, + { name: 'delete_subscriber', label: __('Delete Subscriber', 'bit-integrations'), is_pro: true }, + { + name: 'remove_phone_from_subscriber', + label: __('Remove Phone From Subscriber', 'bit-integrations'), + is_pro: true + }, + { + name: 'add_subscriber_to_group', + label: __('Add Subscriber To Group', 'bit-integrations'), + is_pro: true + }, + { + name: 'remove_subscriber_from_group', + label: __('Remove Subscriber From Group', 'bit-integrations'), + is_pro: true + }, + { name: 'create_group', label: __('Create Group', 'bit-integrations'), is_pro: true }, + { name: 'update_group', label: __('Update Group', 'bit-integrations'), is_pro: true }, + { name: 'delete_group', label: __('Delete Group', 'bit-integrations'), is_pro: true } +] + +// Field-map definitions per action. Only inputs that are NOT a fetchable dropdown live here. +// Group ids come from the group dropdown (conf.groups / conf.groupId), not the field map. +export const SubscriberFields = [ + { key: 'email', label: __('Email', 'bit-integrations'), required: true }, + { key: 'firstname', label: __('First Name', 'bit-integrations'), required: false }, + { key: 'lastname', label: __('Last Name', 'bit-integrations'), required: false }, + { key: 'phone', label: __('Phone', 'bit-integrations'), required: false } +] + +export const UpdateSubscriberFields = [ + { key: 'subscriber_id', label: __('Subscriber ID / Email', 'bit-integrations'), required: true }, + { key: 'firstname', label: __('First Name', 'bit-integrations'), required: false }, + { key: 'lastname', label: __('Last Name', 'bit-integrations'), required: false }, + { key: 'phone', label: __('Phone', 'bit-integrations'), required: false }, + { key: 'subscriber_status', label: __('Subscriber Status', 'bit-integrations'), required: false }, + { key: 'sms_status', label: __('SMS Status', 'bit-integrations'), required: false }, + { + key: 'transactional_email_status', + label: __('Transactional Email Status', 'bit-integrations'), + required: false + } +] + +export const EmailsField = [ + { key: 'emails', label: __('Emails (comma separated)', 'bit-integrations'), required: true } +] + +export const SubscriberIdField = [ + { key: 'subscriber_id', label: __('Subscriber ID / Email', 'bit-integrations'), required: true } +] + +export const GroupTitleField = [ + { key: 'title', label: __('Group Title', 'bit-integrations'), required: true } +] + +// Map an action to its static field-map definition. +export const senderFieldsByAction = { + create_or_update_subscriber: SubscriberFields, + update_subscriber: UpdateSubscriberFields, + delete_subscriber: EmailsField, + remove_phone_from_subscriber: SubscriberIdField, + add_subscriber_to_group: EmailsField, + remove_subscriber_from_group: EmailsField, + create_group: GroupTitleField, + update_group: GroupTitleField, + delete_group: [] +} + +// Actions that need the single-group dropdown. +export const singleGroupActions = [ + 'add_subscriber_to_group', + 'remove_subscriber_from_group', + 'update_group', + 'delete_group' +] + +// Actions that need the multi-group dropdown + custom field fetch. +export const subscriberActions = ['create_or_update_subscriber', 'update_subscriber'] + +// Actions that show the Trigger Automation switch. +export const triggerAutomationActions = [ + 'create_or_update_subscriber', + 'update_subscriber', + 'add_subscriber_to_group' +] diff --git a/frontend/src/components/Flow/New/SelectAction.jsx b/frontend/src/components/Flow/New/SelectAction.jsx index 2f70cded..a9816d09 100644 --- a/frontend/src/components/Flow/New/SelectAction.jsx +++ b/frontend/src/components/Flow/New/SelectAction.jsx @@ -206,6 +206,7 @@ export default function SelectAction() { { type: 'Asgaros Forum', logo: 'asgaros', is_pro: true }, { type: 'B2BKing', is_pro: true }, { type: 'User Registration & Membership', logo: 'userRegistrationMembership', is_pro: true }, + { type: 'Sender', is_pro: true }, { type: 'MainWP', is_pro: true }, ] diff --git a/frontend/src/resource/img/integ/sender.webp b/frontend/src/resource/img/integ/sender.webp new file mode 100644 index 00000000..1b2f1f07 Binary files /dev/null and b/frontend/src/resource/img/integ/sender.webp differ