diff --git a/frontend-dev/src/components/AllIntegrations/EditInteg.jsx b/frontend-dev/src/components/AllIntegrations/EditInteg.jsx index 4b1de084..7415811f 100644 --- a/frontend-dev/src/components/AllIntegrations/EditInteg.jsx +++ b/frontend-dev/src/components/AllIntegrations/EditInteg.jsx @@ -92,6 +92,7 @@ const EditMailup = lazy(() => import('./Mailup/EditMailup')) const EditNotion = lazy(() => import('./Notion/EditNotion')) const EditMailjet = lazy(() => import('./Mailjet/EditMailjet')) const EditSendGrid = lazy(() => import('./SendGrid/EditSendGrid')) +const EditFabman = lazy(() => import('./Fabman/EditFabman')) const EditPCloud = lazy(() => import('./PCloud/EditPCloud')) const EditEmailOctopus = lazy(() => import('./EmailOctopus/EditEmailOctopus')) const EditCustomAction = lazy(() => import('./CustomAction/EditCustomAction')) @@ -411,6 +412,8 @@ const IntegType = memo(({ allIntegURL, flow }) => { return case 'SendGrid': return + case 'Fabman': + return case 'PCloud': return case 'EmailOctopus': diff --git a/frontend-dev/src/components/AllIntegrations/Fabman/EditFabman.jsx b/frontend-dev/src/components/AllIntegrations/Fabman/EditFabman.jsx new file mode 100644 index 00000000..1ee09629 --- /dev/null +++ b/frontend-dev/src/components/AllIntegrations/Fabman/EditFabman.jsx @@ -0,0 +1,157 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable no-param-reassign */ + +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useRecoilState, useRecoilValue } from 'recoil' +import { $actionConf, $formFields, $newFlow } from '../../../GlobalStates' +import { __ } from '../../../Utils/i18nwrap' +import SnackMsg from '../../Utilities/SnackMsg' +import EditFormInteg from '../EditFormInteg' +import SetEditIntegComponents from '../IntegrationHelpers/SetEditIntegComponents' +import EditWebhookInteg from '../EditWebhookInteg' +import { saveActionConf } from '../IntegrationHelpers/IntegrationHelpers' +import IntegrationStepThree from '../IntegrationHelpers/IntegrationStepThree' +import { checkMappedFields, handleInput, isConfigInvalid } from './FabmanCommonFunc' +import FabmanIntegLayout from './FabmanIntegLayout' +import { checkValidEmail } from '../../../Utils/Helpers' + +function EditFabman({ allIntegURL }) { + const navigate = useNavigate() + const [flow, setFlow] = useRecoilState($newFlow) + const [fabmanConf, setFabmanConf] = useRecoilState($actionConf) + const [loading, setLoading] = useState({ + list: false, + field: false, + auth: false + }) + const [snack, setSnackbar] = useState({ show: false }) + const formField = useRecoilValue($formFields) + + const [localName, setLocalName] = useState(fabmanConf.name || '') + + const getEmailMappingRow = () => { + const rows = Array.isArray(fabmanConf?.field_map) ? fabmanConf.field_map : [] + return rows.find(r => r?.fabmanFormField === 'emailAddress') + } + + const isEmailMappingInvalid = () => { + const emailRow = getEmailMappingRow() + if (!emailRow) return true + if (emailRow.formField === 'custom') { + const customValue = (emailRow.customValue || '').trim() + return !customValue || !checkValidEmail(customValue) + } + // Non-custom: a form field is selected. Consider valid if: + // - field type is email, OR + // - type is missing/unknown, OR + // - field name/label includes "email" + const selectedField = (formField || []).find(f => f.name === emailRow.formField) + + if (!selectedField) return false + + const hasEmailType = selectedField.type && String(selectedField.type).toLowerCase() === 'email' + const looksLikeEmailField = + /email/i.test(selectedField.name || '') || /email/i.test(selectedField.label || '') + return !(hasEmailType || !selectedField.type || looksLikeEmailField) + } + + const saveConfig = () => { + if (!fabmanConf.actionName) { + setSnackbar({ show: true, msg: __('Please select an action', 'bit-integrations') }) + return + } + + if (['update_member', 'delete_member'].includes(fabmanConf.actionName)) { + if (!checkMappedFields(fabmanConf)) { + setSnackbar({ show: true, msg: __('Please map mandatory fields', 'bit-integrations') }) + return + } + + if (isEmailMappingInvalid()) { + setSnackbar({ show: true, msg: __('Please map a valid email address', 'bit-integrations') }) + return + } + + saveActionConf({ + flow, + allIntegURL, + conf: fabmanConf, + navigate, + edit: 1, + setLoading, + setSnackbar + }) + return + } + + if (!checkMappedFields(fabmanConf)) { + setSnackbar({ show: true, msg: __('Please map mandatory fields', 'bit-integrations') }) + return + } + + const requiresWorkspaceSelection = + fabmanConf.actionName === 'update_spaces' || fabmanConf.actionName === 'delete_spaces' + + if (requiresWorkspaceSelection && !fabmanConf.selectedWorkspace) { + setSnackbar({ show: true, msg: __('Please select a workspace', 'bit-integrations') }) + return + } + + saveActionConf({ + flow, + allIntegURL, + conf: fabmanConf, + navigate, + edit: 1, + setLoading, + setSnackbar + }) + } + + const handleNameChange = e => { + setLocalName(e.target.value) + setFabmanConf(prev => ({ ...prev, name: e.target.value })) + } + + return ( +
+ +
+ {__('Integration Name:', 'bit-integrations')} + +
+
+ + handleInput(e, fabmanConf, setFabmanConf, setLoading, setSnackbar)} + fabmanConf={fabmanConf} + setFabmanConf={setFabmanConf} + loading={loading} + setLoading={setLoading} + setSnackbar={setSnackbar} + /> + +
+
+ ) +} + +export default EditFabman diff --git a/frontend-dev/src/components/AllIntegrations/Fabman/Fabman.jsx b/frontend-dev/src/components/AllIntegrations/Fabman/Fabman.jsx new file mode 100644 index 00000000..30daf572 --- /dev/null +++ b/frontend-dev/src/components/AllIntegrations/Fabman/Fabman.jsx @@ -0,0 +1,323 @@ +/* eslint-disable no-console */ +/* eslint-disable no-unused-expressions */ +import { useEffect, useState } from 'react' +import 'react-multiple-select-dropdown-lite/dist/index.css' +import { useNavigate } from 'react-router-dom' +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 FabmanAuthorization from './FabmanAuthorization' +import { + checkMappedFields, + hasEmailFieldMapped, + getEmailMappingRow, + isEmailMappingInvalid +} from './FabmanCommonFunc' +import FabmanIntegLayout from './FabmanIntegLayout' +import { checkValidEmail } from '../../../Utils/Helpers' + +export default function Fabman({ formFields, setFlow, flow, allIntegURL }) { + const navigate = useNavigate() + const [isLoading, setIsLoading] = useState(false) + const [loading, setLoading] = useState({ auth: false, workspaces: false, members: false }) + const [step, setStep] = useState(1) + const [snack, setSnack] = useState({ show: false }) + + const [fabmanConf, setFabmanConf] = useState({ + name: 'Fabman', + type: 'Fabman', + field_map: [{ formField: '', fabmanFormField: '' }], + customFields: [], + actions: {}, + condition: { + action_behavior: '', + actions: [{ field: '', action: 'value' }], + logics: [{ field: '', logic: '', val: '' }, 'or', { field: '', logic: '', val: '' }] + }, + trigger_type: '', + pro_integ_v: '2.5.4', + fields: [], + accountId: '', + selectedLockVersion: '', + memberStaticFields, + spacesStaticFields + }) + + const isConfigInvalid = () => { + if (!fabmanConf.actionName) return true + + if ( + !['delete_member', 'delete_spaces'].includes(fabmanConf.actionName) && + !checkMappedFields(fabmanConf) + ) + return true + + if ( + ['update_member', 'delete_member'].includes(fabmanConf.actionName) && + isEmailMappingInvalid(fabmanConf, formFields, checkValidEmail) + ) + return true + + if ( + ['create_member', 'update_member', 'update_spaces', 'delete_spaces'].includes( + fabmanConf.actionName + ) && + !fabmanConf.selectedWorkspace + ) + return true + + if (fabmanConf.actionName === 'delete_member') { + const hasEmailField = fabmanConf.field_map?.some( + field => field.fabmanFormField === 'emailAddress' && field.formField + ) + + if (!hasEmailField) { + return true + } + } + return false + } + + const saveConfig = () => { + if (isConfigInvalid()) { + if (!fabmanConf.actionName) { + setSnack({ show: true, msg: __('Please select an action', 'bit-integrations') }) + } else if ( + !['delete_member', 'delete_spaces'].includes(fabmanConf.actionName) && + !checkMappedFields(fabmanConf) + ) { + setSnack({ show: true, msg: __('Please map mandatory fields', 'bit-integrations') }) + } else if ( + ['update_member', 'delete_member'].includes(fabmanConf.actionName) && + isEmailMappingInvalid(fabmanConf, formFields, checkValidEmail) + ) { + setSnack({ show: true, msg: __('Please map a valid email address', 'bit-integrations') }) + } else if ( + ['create_member', 'update_member', 'update_spaces', 'delete_spaces'].includes( + fabmanConf.actionName + ) && + !fabmanConf.selectedWorkspace + ) { + setSnack({ show: true, msg: __('Please select a workspace', 'bit-integrations') }) + } else if (fabmanConf.actionName === 'delete_member') { + if (!hasEmailFieldMapped(fabmanConf)) { + setSnack({ + show: true, + msg: __('Please map email field for member lookup', 'bit-integrations') + }) + } + } + return + } + saveIntegConfig(flow, setFlow, allIntegURL, fabmanConf, navigate, '', '', setIsLoading) + } + + const nextPage = () => { + if (isConfigInvalid()) { + if (!fabmanConf.actionName) { + setSnack({ show: true, msg: __('Please select an action', 'bit-integrations') }) + } else if ( + !['delete_member', 'delete_spaces'].includes(fabmanConf.actionName) && + !checkMappedFields(fabmanConf) + ) { + setSnack({ show: true, msg: __('Please map mandatory fields', 'bit-integrations') }) + } else if ( + ['update_member', 'delete_member'].includes(fabmanConf.actionName) && + isEmailMappingInvalid(fabmanConf, formFields, checkValidEmail) + ) { + setSnack({ show: true, msg: __('Please map a valid email address', 'bit-integrations') }) + } else if ( + ['create_member', 'update_member', 'update_spaces', 'delete_spaces'].includes( + fabmanConf.actionName + ) && + !fabmanConf.selectedWorkspace + ) { + setSnack({ show: true, msg: __('Please select a workspace', 'bit-integrations') }) + } else if (fabmanConf.actionName === 'delete_member') { + if (!hasEmailFieldMapped(fabmanConf)) { + setSnack({ + show: true, + msg: __('Please map email field for member lookup', 'bit-integrations') + }) + } + } + return + } + setStep(3) + } + + return ( +
+ +
+ +
+ {/* STEP 1 */} + + {/* STEP 2 */} + {step === 2 && ( +
+ + +
+ )} + {/* STEP 3 */} + + saveIntegConfig(flow, setFlow, allIntegURL, fabmanConf, navigate, '', '', setIsLoading) + } + isLoading={isLoading} + dataConf={fabmanConf} + setDataConf={setFabmanConf} + formFields={formFields} + /> +
+ ) +} + +const memberStaticFields = [ + { key: 'emailAddress', label: __('Email Address', 'bit-integrations'), required: false }, + { key: 'firstName', label: __('First Name', 'bit-integrations'), required: true }, + { key: 'lastName', label: __('Last Name', 'bit-integrations'), required: false }, + { key: 'memberNumber', label: __('Member Number', 'bit-integrations'), required: false }, + { key: 'gender', label: __('Gender', 'bit-integrations'), required: false }, + { key: 'dateOfBirth', label: __('Date of Birth', 'bit-integrations'), required: false }, + { key: 'company', label: __('Company', 'bit-integrations'), required: false }, + { key: 'phone', label: __('Phone', 'bit-integrations'), required: false }, + { key: 'address', label: __('Address', 'bit-integrations'), required: false }, + { key: 'address2', label: __('Address 2', 'bit-integrations'), required: false }, + { key: 'city', label: __('City', 'bit-integrations'), required: false }, + { key: 'zip', label: __('ZIP Code', 'bit-integrations'), required: false }, + { key: 'countryCode', label: __('Country Code', 'bit-integrations'), required: false }, + { key: 'region', label: __('Region', 'bit-integrations'), required: false }, + { key: 'notes', label: __('Notes', 'bit-integrations'), required: false }, + { key: 'billingFirstName', label: __('Billing First Name', 'bit-integrations'), required: false }, + { key: 'billingLastName', label: __('Billing Last Name', 'bit-integrations'), required: false }, + { key: 'billingCompany', label: __('Billing Company', 'bit-integrations'), required: false }, + { key: 'billingAddress', label: __('Billing Address', 'bit-integrations'), required: false }, + { key: 'billingAddress2', label: __('Billing Address 2', 'bit-integrations'), required: false }, + { key: 'billingCity', label: __('Billing City', 'bit-integrations'), required: false }, + { key: 'billingZip', label: __('Billing ZIP Code', 'bit-integrations'), required: false }, + { + key: 'billingCountryCode', + label: __('Billing Country Code', 'bit-integrations'), + required: false + }, + { key: 'billingRegion', label: __('Billing Region', 'bit-integrations'), required: false }, + { + key: 'billingInvoiceText', + label: __('Billing Invoice Text', 'bit-integrations'), + required: false + }, + { + key: 'billingEmailAddress', + label: __('Billing Email Address', 'bit-integrations'), + required: false + }, + { key: 'language', label: __('Language', 'bit-integrations'), required: false }, + { key: 'state', label: __('State', 'bit-integrations'), required: false }, + { key: 'taxExempt', label: __('Tax Exempt', 'bit-integrations'), required: false }, + { + key: 'hasBillingAddress', + label: __('Has Billing Address', 'bit-integrations'), + required: false + }, + { + key: 'requireUpfrontPayment', + label: __('Require Upfront Payment', 'bit-integrations'), + required: false + }, + { + key: 'upfrontMinimumBalance', + label: __('Upfront Minimum Balance', 'bit-integrations'), + required: false + } +] + +const spacesStaticFields = [ + { key: 'name', label: __('Name', 'bit-integrations'), required: true }, + { key: 'shortName', label: __('Short Name', 'bit-integrations'), required: false }, + { key: 'timezone', label: __('Timezone', 'bit-integrations'), required: true }, + { key: 'emailAddress', label: __('Email Address', 'bit-integrations'), required: false }, + { key: 'website', label: __('Website', 'bit-integrations'), required: false }, + { key: 'phone', label: __('Phone', 'bit-integrations'), required: false }, + { key: 'infoText', label: __('Info Text', 'bit-integrations'), required: false }, + { + key: 'bookingTermsOfService', + label: __('Booking Terms Of Service', 'bit-integrations'), + required: false + }, + { + key: 'bookingSlotsPerHour', + label: __('Booking Slots Per Hour', 'bit-integrations'), + required: false + }, + { + key: 'bookingWindowMinHours', + label: __('Booking Window Min Hours', 'bit-integrations'), + required: false + }, + { + key: 'bookingWindowMaxDays', + label: __('Booking Window Max Days', 'bit-integrations'), + required: false + }, + { + key: 'bookingLockInHours', + label: __('Booking Lock-in Hours', 'bit-integrations'), + required: false + }, + { + key: 'bookingMaxMinutesPerMemberDay', + label: __('Booking Max Minutes Per Member/Day', 'bit-integrations'), + required: false + }, + { + key: 'bookingMaxMinutesPerMemberWeek', + label: __('Booking Max Minutes Per Member/Week', 'bit-integrations'), + required: false + }, + { + key: 'bookingExclusiveMinutes', + label: __('Booking Exclusive Minutes', 'bit-integrations'), + required: false + }, + { + key: 'bookingOpeningHours', + label: __('Booking Opening Hours', 'bit-integrations'), + required: false + }, + { key: 'bookingRefundable', label: __('Booking Refundable', 'bit-integrations'), required: false }, + { + key: 'bookingNamesPublic', + label: __('Booking Names Public', 'bit-integrations'), + required: false + } +] diff --git a/frontend-dev/src/components/AllIntegrations/Fabman/FabmanAuthorization.jsx b/frontend-dev/src/components/AllIntegrations/Fabman/FabmanAuthorization.jsx new file mode 100644 index 00000000..c22f82fe --- /dev/null +++ b/frontend-dev/src/components/AllIntegrations/Fabman/FabmanAuthorization.jsx @@ -0,0 +1,124 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +/* eslint-disable no-unused-expressions */ +import { useState, useCallback, useEffect } from 'react' +import { __, sprintf } from '../../../Utils/i18nwrap' +import LoaderSm from '../../Loaders/LoaderSm' +import { fabmanAuthentication } from './FabmanCommonFunc' +import Note from '../../Utilities/Note' +import tutorialLinks from '../../../Utils/StaticData/tutorialLinks' +import TutorialLink from '../../Utilities/TutorialLink' + +export default function FabmanAuthorization({ + fabmanConf, + setFabmanConf, + step, + setStep, + loading, + setLoading, + isInfo +}) { + const [isAuthorized, setIsAuthorized] = useState(false) + const [error, setError] = useState({ name: '', apiKey: '' }) + const { fabman } = tutorialLinks + + const nextPage = useCallback(() => { + setTimeout(() => { + document.getElementById('btcd-settings-wrp').scrollTop = 0 + }, 300) + setStep(2) + }, [setStep]) + + const handleInput = useCallback( + e => { + const { name, value } = e.target + setFabmanConf(prev => ({ ...prev, [name]: value })) + setError(prev => ({ ...prev, [name]: '' })) + }, + [setFabmanConf, setError] + ) + + const handleNameBlur = useCallback(() => {}, [setFabmanConf]) + + const styleStep1 = step === 1 ? { width: 900, height: 'auto' } : {} + + return ( +
+ {fabman?.youTubeLink && } + {fabman?.docLink && } +
+ {__('Integration Name:', 'bit-integrations')} +
+ +
+ {__('API Key:', 'bit-integrations')} +
+ +
+ {error.apiKey} +
+ + {!isInfo && ( +
+ + + +
+ )} + +
+ ) +} + +const fabmanApiKeyNote = `

${__('To get your Fabman API key:', 'bit-integrations')}

+
    +
  • ${sprintf( + __('Log in to your %s.', 'bit-integrations'), + 'Fabman account' + )}
  • +
  • ${__('Go to "Configure" → "Integrations (API & Webhooks)".', 'bit-integrations')}
  • +
  • ${__('Click "Create API key", add a title, and choose a member.', 'bit-integrations')}
  • +
  • ${__('Save, then click "Reveal" to copy your API key.', 'bit-integrations')}
  • +
` diff --git a/frontend-dev/src/components/AllIntegrations/Fabman/FabmanCommonFunc.js b/frontend-dev/src/components/AllIntegrations/Fabman/FabmanCommonFunc.js new file mode 100644 index 00000000..99385bfa --- /dev/null +++ b/frontend-dev/src/components/AllIntegrations/Fabman/FabmanCommonFunc.js @@ -0,0 +1,176 @@ +/* eslint-disable no-console */ +/* eslint-disable no-else-return */ +import toast from 'react-hot-toast' +import bitsFetch from '../../../Utils/bitsFetch' +import { __ } from '../../../Utils/i18nwrap' +import { create } from 'mutative' + +export const handleInput = (e, fabmanConf, setFabmanConf) => { + const newConf = { ...fabmanConf } + const { name } = e.target + + if (e.target.value !== '') { + newConf[name] = e.target.value + } else { + delete newConf[name] + } + + setFabmanConf({ ...newConf }) +} + +export const generateMappedField = fabmanConf => { + // The double exclamation mark (!!) is used to convert any value to a boolean. + // This ensures only fields with a truthy 'required' property are included. + const requiredFlds = fabmanConf?.staticFields.filter(fld => !!fld.required) + return requiredFlds.length > 0 + ? requiredFlds.map(field => ({ formField: '', fabmanFormField: field.key })) + : [{ formField: '', fabmanFormField: '' }] +} + +export const checkMappedFields = fabmanConf => { + const rows = Array.isArray(fabmanConf?.field_map) ? fabmanConf.field_map : [] + const mappedFieldPresent = rows.filter(r => { + const hasAnySide = + (r?.formField && r.formField !== '') || (r?.fabmanFormField && r.fabmanFormField !== '') + + if (!hasAnySide) return false + + if (!r.formField || !r.fabmanFormField) return true + + if (r.formField === 'custom' && !r.customValue) return true + return false + }) + return mappedFieldPresent.length === 0 +} + +export const fabmanAuthentication = ( + confTmp, + setConf, + setError, + setIsAuthorized, + loading, + setLoading, + type +) => { + if (!confTmp.apiKey) { + setError({ apiKey: !confTmp.apiKey ? __("API key can't be empty", 'bit-integrations') : '' }) + return + } + + setError({}) + + if (type === 'authentication') { + setLoading({ ...loading, auth: true }) + } + + const requestParams = { apiKey: confTmp.apiKey } + + bitsFetch(requestParams, 'fabman_authorization').then(result => { + if (result && result.success) { + // Use mutative's produce for state update + const newConf = create(confTmp, draft => { + if (type === 'authentication' && result.data && result.data.accountId) { + draft.accountId = result.data.accountId + } + }) + + setIsAuthorized(true) + + if (type === 'authentication') { + setConf(newConf) + setLoading({ ...loading, auth: false }) + toast.success(__('Authorized Successfully', 'bit-integrations')) + + fetchFabmanWorkspaces(newConf, setConf, loading, setLoading, 'fetch') + } + return + } + setLoading({ ...loading, auth: false }) + toast.error(__('Authorization Failed', 'bit-integrations')) + }) +} + +export const fetchFabmanWorkspaces = (confTmp, setConf, loading, setLoading, type = 'fetch') => { + if (!confTmp.apiKey) { + toast.error(__('API key is required to fetch workspaces', 'bit-integrations')) + return + } + + setLoading({ ...loading, workspaces: true }) + + const requestParams = { apiKey: confTmp.apiKey } + + bitsFetch(requestParams, 'fabman_fetch_workspaces').then(result => { + setLoading({ ...loading, workspaces: false }) + + if (result && result.success) { + // Use mutative's produce for state update + const newConf = create(confTmp, draft => { + if (result.data && result.data.workspaces && Array.isArray(result.data.workspaces)) { + draft.workspaces = result.data.workspaces + if (result.data.workspaces.length === 1) { + draft.selectedWorkspace = result.data.workspaces[0].id + } + } + }) + + setConf(newConf) + toast.success( + type === 'refresh' + ? __('Workspaces refreshed successfully', 'bit-integrations') + : __('Workspaces fetched successfully', 'bit-integrations') + ) + return + } + toast.error(__('Failed to fetch workspaces', 'bit-integrations')) + }) +} + +export const isConfigInvalid = (fabmanConf, formField) => { + if (!fabmanConf.actionName) return true + + if (['update_member', 'delete_member'].includes(fabmanConf.actionName)) { + // isEmailMappingInvalid needs to be passed or imported + if (isEmailMappingInvalid(fabmanConf, formField)) return true + if (!checkMappedFields(fabmanConf)) return true + return false + } + + if ( + ['update_spaces', 'delete_spaces'].includes(fabmanConf.actionName) && + !fabmanConf.selectedWorkspace + ) + return true + + if (!checkMappedFields(fabmanConf)) return true + return false +} + +export const hasEmailFieldMapped = fabmanConf => { + return fabmanConf.field_map?.some(field => field.fabmanFormField === 'emailAddress' && field.formField) +} + +export const getEmailMappingRow = fabmanConf => { + const rows = Array.isArray(fabmanConf?.field_map) ? fabmanConf.field_map : [] + return rows.find(r => r?.fabmanFormField === 'emailAddress') +} + +export const isEmailMappingInvalid = (fabmanConf, formFields, checkValidEmail) => { + const emailRow = getEmailMappingRow(fabmanConf) + + if (!emailRow) return true + + if (emailRow.formField === 'custom') { + const customValue = (emailRow.customValue || '').trim() + return !customValue || !checkValidEmail(customValue) + } + + const selectedField = (formFields || []).find(f => f.name === emailRow.formField) + + if (!selectedField) return false + + const hasEmailType = selectedField.type && String(selectedField.type).toLowerCase() === 'email' + const looksLikeEmailField = + /email/i.test(selectedField.name || '') || /email/i.test(selectedField.label || '') + return !(hasEmailType || !selectedField.type || looksLikeEmailField) +} diff --git a/frontend-dev/src/components/AllIntegrations/Fabman/FabmanFieldMap.jsx b/frontend-dev/src/components/AllIntegrations/Fabman/FabmanFieldMap.jsx new file mode 100644 index 00000000..0347d00b --- /dev/null +++ b/frontend-dev/src/components/AllIntegrations/Fabman/FabmanFieldMap.jsx @@ -0,0 +1,131 @@ +/* eslint-disable no-console */ +import { useRecoilValue } from 'recoil' +import { useMemo, useCallback } from 'react' +import { __, sprintf } from '../../../Utils/i18nwrap' +import { addFieldMap, delFieldMap, handleFieldMapping } from './IntegrationHelpers' +import { SmartTagField } from '../../../Utils/StaticData/SmartTagField' +import { $btcbi } from '../../../GlobalStates' +import { generateMappedField } from './FabmanCommonFunc' +import TagifyInput from '../../Utilities/TagifyInput' +import { handleCustomValue } from '../IntegrationHelpers/IntegrationHelpers' + +export default function FabmanFieldMap({ i, formFields, field, fabmanConf, setFabmanConf }) { + const requiredFields = useMemo( + () => (fabmanConf?.staticFields ? fabmanConf.staticFields.filter(fld => !!fld.required) : []), + [fabmanConf?.staticFields] + ) + const nonRequriedFields = useMemo( + () => (fabmanConf?.staticFields ? fabmanConf.staticFields.filter(fld => !fld.required) : []), + [fabmanConf?.staticFields] + ) + const customFields = useMemo( + () => (Array.isArray(fabmanConf.customFields) ? fabmanConf.customFields : []), + [fabmanConf.customFields] + ) + const allNonrequriedFields = useMemo( + () => [...nonRequriedFields, ...customFields], + [nonRequriedFields, customFields] + ) + + const btcbi = useRecoilValue($btcbi) + const { isPro } = btcbi + + const onFieldMapping = useCallback( + ev => handleFieldMapping(ev, i, fabmanConf, setFabmanConf), + [i, fabmanConf, setFabmanConf] + ) + const onCustomValue = useCallback( + e => handleCustomValue(e, i, fabmanConf, setFabmanConf), + [i, fabmanConf, setFabmanConf] + ) + const onAddFieldMap = useCallback( + () => addFieldMap(i, fabmanConf, setFabmanConf), + [i, fabmanConf, setFabmanConf] + ) + const onDelFieldMap = useCallback( + () => delFieldMap(i, fabmanConf, setFabmanConf), + [i, fabmanConf, setFabmanConf] + ) + + return ( +
+
+
+ + + {field.formField === 'custom' && ( + + )} + + +
+ {i >= requiredFields.length && ( + <> + + + + )} +
+
+ ) +} diff --git a/frontend-dev/src/components/AllIntegrations/Fabman/FabmanIntegLayout.jsx b/frontend-dev/src/components/AllIntegrations/Fabman/FabmanIntegLayout.jsx new file mode 100644 index 00000000..c2eabbcb --- /dev/null +++ b/frontend-dev/src/components/AllIntegrations/Fabman/FabmanIntegLayout.jsx @@ -0,0 +1,314 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable react-hooks/exhaustive-deps */ +import { __ } from '../../../Utils/i18nwrap' +import FabmanFieldMap from './FabmanFieldMap' +import { addFieldMap } from './IntegrationHelpers' +import { fetchFabmanWorkspaces, generateMappedField } from './FabmanCommonFunc' +import Loader from '../../Loaders/Loader' +import { useEffect, useMemo, useCallback, useRef } from 'react' +import Note from '../../Utilities/Note' + +const fabmanActionsList = [ + { label: __('Create Member', 'bit-integrations'), value: 'create_member' }, + { label: __('Update Member', 'bit-integrations'), value: 'update_member' }, + { label: __('Delete Member', 'bit-integrations'), value: 'delete_member' }, + { label: __('Create Spaces', 'bit-integrations'), value: 'create_spaces' }, + { label: __('Update Spaces', 'bit-integrations'), value: 'update_spaces' } +] + +export default function FabmanIntegLayout({ + formFields, + fabmanConf, + setFabmanConf, + loading, + setLoading, + setSnackbar +}) { + const getActiveStaticFields = useCallback(conf => { + if (conf.actionName === 'create_spaces' || conf.actionName === 'update_spaces') { + const fields = Array.isArray(conf.spacesStaticFields) + ? conf.spacesStaticFields.map(f => ({ ...f })) + : [] + const isCreate = conf.actionName === 'create_spaces' + const nameIdx = fields.findIndex(f => String(f.key) === 'name') + + if (nameIdx > -1) fields[nameIdx].required = true + + const tzIdx = fields.findIndex(f => String(f.key) === 'timezone') + + if (tzIdx > -1) fields[tzIdx].required = isCreate + return fields + } + + if (conf.actionName === 'update_member' || conf.actionName === 'delete_member') { + const fields = Array.isArray(conf.memberStaticFields) + ? conf.memberStaticFields.map(f => ({ ...f })) + : [] + const emailIdx = fields.findIndex(f => String(f.key) === 'emailAddress') + + if (emailIdx > -1) fields[emailIdx].required = true + + const firstNameIdx = fields.findIndex(f => String(f.key) === 'firstName') + + if (firstNameIdx > -1) fields[firstNameIdx].required = false + return fields + } + + return conf.memberStaticFields + }, []) + + useEffect(() => { + const staticFields = getActiveStaticFields(fabmanConf) || [] + const requiredFields = staticFields.filter(f => !!f.required) + + if (!Array.isArray(fabmanConf.field_map)) return + + let changed = false + const newFieldMap = [...fabmanConf.field_map] + + for (let i = 0; i < requiredFields.length; i += 1) { + if (!newFieldMap[i]) { + newFieldMap[i] = { formField: '', fabmanFormField: requiredFields[i].key } + changed = true + } else if (newFieldMap[i].fabmanFormField !== requiredFields[i].key) { + newFieldMap[i] = { ...newFieldMap[i], fabmanFormField: requiredFields[i].key } + changed = true + } + } + + if (changed) { + const newConf = { ...fabmanConf, field_map: newFieldMap } + setFabmanConf(newConf) + } + }, [fabmanConf.actionName, fabmanConf.selectedWorkspace, fabmanConf.field_map, getActiveStaticFields]) + + const handleActionChange = useCallback( + e => { + const newConf = { ...fabmanConf } + const value = e.target.value + newConf.actionName = value + + if (value === 'create_spaces' || value === 'update_spaces') { + delete newConf.selectedMember + delete newConf.selectedLockVersion + } + + newConf.field_map = [{ formField: '', fabmanFormField: '' }] + + setFabmanConf(newConf) + }, + [fabmanConf, setFabmanConf, loading, setLoading] + ) + + const handleWorkspaceChange = useCallback( + e => { + const newConf = { ...fabmanConf } + newConf.selectedWorkspace = e.target.value + + const ws = (fabmanConf?.workspaces || []).find(w => String(w.id) === String(e.target.value)) + + if (ws && typeof ws.lockVersion !== 'undefined') { + newConf.selectedLockVersion = ws.lockVersion + } + + setFabmanConf(newConf) + }, + [fabmanConf, setFabmanConf, loading, setLoading] + ) + + const handleMemberChange = useCallback( + e => { + const newConf = { ...fabmanConf } + const selectedValue = e.target.value + + if (selectedValue) { + const [memberId, lockVersion] = selectedValue.split('|') + newConf.selectedMember = memberId + newConf.selectedLockVersion = lockVersion + } else { + delete newConf.selectedMember + delete newConf.selectedLockVersion + } + + setFabmanConf(newConf) + }, + [fabmanConf, setFabmanConf] + ) + + const handleRefreshWorkspaces = useCallback(() => { + fetchFabmanWorkspaces(fabmanConf, setFabmanConf, loading, setLoading, 'refresh') + }, [fabmanConf, setFabmanConf, loading, setLoading]) + + const isSpaceAction = useMemo( + () => fabmanConf.actionName === 'create_spaces' || fabmanConf.actionName === 'update_spaces', + [fabmanConf.actionName] + ) + + const isDeleteMember = useMemo( + () => fabmanConf.actionName === 'delete_member', + [fabmanConf.actionName] + ) + + const activeStaticFields = useMemo( + () => getActiveStaticFields(fabmanConf), + [fabmanConf, getActiveStaticFields] + ) + + return ( +
+
+
+ {__('Action:', 'bit-integrations')} + +
+
+ {fabmanConf.actionName && + !isDeleteMember && + (!isSpaceAction || fabmanConf.actionName === 'update_spaces') && ( + <> +
+ {__('Select Workspace:', 'bit-integrations')} + + +
+ {loading.workspaces && ( + + )} +
+ + + )} + + {isDeleteMember && ( + <> +
+ {__('Field Map', 'bit-integrations')} +
+
+
+
+
+ {__('Form Fields', 'bit-integrations')} +
+
+ {__('Fabman Fields', 'bit-integrations')} +
+
+ + + )} + + {fabmanConf.actionName && + fabmanConf.actionName !== 'delete_member' && + (!isSpaceAction || + (isSpaceAction && + (fabmanConf.actionName !== 'update_spaces' || fabmanConf.selectedWorkspace))) && ( + <> + {fabmanConf.actionName === 'create_spaces' && } +
+ {__('Field Map', 'bit-integrations')} +
+
+
+
+
+ {__('Form Fields', 'bit-integrations')} +
+
+ {__('Fabman Fields', 'bit-integrations')} +
+
+ {fabmanConf?.field_map.map((itm, i) => ( + + ))} +
+
+ +
+
+
+
+ {__('Utilities', 'bit-integrations')} +
+
+
+ + )} +
+ ) +} + +const fabmanWorkspaceNote = `

${__( + 'Please select workspace for Create Member, Update Member, and Update Spaces.', + 'bit-integrations' +)}

` + +const fabmanTimezoneNote = `

${__( + 'For Create Spaces, Timezone must be like Asia/Dhaka (IANA timezone format).', + 'bit-integrations' +)}

` diff --git a/frontend-dev/src/components/AllIntegrations/Fabman/IntegrationHelpers.jsx b/frontend-dev/src/components/AllIntegrations/Fabman/IntegrationHelpers.jsx new file mode 100644 index 00000000..c4e635e6 --- /dev/null +++ b/frontend-dev/src/components/AllIntegrations/Fabman/IntegrationHelpers.jsx @@ -0,0 +1,40 @@ +import { create } from 'mutative' + +export const addFieldMap = (i, confTmp, setConf) => { + const newConf = create(confTmp, draft => { + const fieldMap = Array.isArray(draft.field_map) ? [...draft.field_map] : [] + fieldMap.splice(i, 0, {}) + draft.field_map = fieldMap + }) + + setConf(newConf) +} + +export const delFieldMap = (i, confTmp, setConf) => { + const newConf = create(confTmp, draft => { + const fieldMap = Array.isArray(draft.field_map) ? [...draft.field_map] : [] + + if (fieldMap.length > 1) { + fieldMap.splice(i, 1) + } + draft.field_map = fieldMap + }) + + setConf(newConf) +} + +export const handleFieldMapping = (event, index, conftTmp, setConf) => { + const newConf = create(conftTmp, draft => { + const fieldMap = Array.isArray(draft.field_map) ? [...draft.field_map] : [] + + if (!fieldMap[index]) fieldMap[index] = {} + fieldMap[index][event.target.name] = event.target.value + + if (event.target.value === 'custom') { + fieldMap[index].customValue = '' + } + draft.field_map = fieldMap + }) + + setConf(newConf) +} diff --git a/frontend-dev/src/components/AllIntegrations/IntegInfo.jsx b/frontend-dev/src/components/AllIntegrations/IntegInfo.jsx index 5d203d9a..b26da0b3 100644 --- a/frontend-dev/src/components/AllIntegrations/IntegInfo.jsx +++ b/frontend-dev/src/components/AllIntegrations/IntegInfo.jsx @@ -87,6 +87,7 @@ const MailupAuthentication = lazy(() => import('./Mailup/MailupAuthorization')) const NotionAuthorization = lazy(() => import('./Notion/NotionAuthorization')) const MailjetAuthorization = lazy(() => import('./Mailjet/MailjetAuthorization')) const SendGridAuthorization = lazy(() => import('./SendGrid/SendGridAuthorization')) +const FabmanAuthorization = lazy(() => import('./Fabman/FabmanAuthorization')) const PCloudAuthorization = lazy(() => import('./PCloud/PCloudAuthorization')) const EmailOctopusAuthorization = lazy(() => import('./EmailOctopus/EmailOctopusAuthorization')) const CustomAction = lazy(() => import('./CustomAction/CustomFuncEditor')) @@ -426,6 +427,8 @@ export default function IntegInfo() { return case 'SendGrid': return + case 'Fabman': + return case 'PCloud': return case 'EmailOctopus': diff --git a/frontend-dev/src/components/AllIntegrations/NewInteg.jsx b/frontend-dev/src/components/AllIntegrations/NewInteg.jsx index c06d03d3..f82d0d4d 100644 --- a/frontend-dev/src/components/AllIntegrations/NewInteg.jsx +++ b/frontend-dev/src/components/AllIntegrations/NewInteg.jsx @@ -93,6 +93,7 @@ const Mailup = lazy(() => import('./Mailup/Mailup')) const Notion = lazy(() => import('./Notion/Notion')) const Mailjet = lazy(() => import('./Mailjet/Mailjet')) const SendGrid = lazy(() => import('./SendGrid/SendGrid')) +const Fabman = lazy(() => import('./Fabman/Fabman')) const PCloud = lazy(() => import('./PCloud/PCloud')) const EmailOctopus = lazy(() => import('./EmailOctopus/EmailOctopus')) const Smaily = lazy(() => import('./Smaily/Smaily')) @@ -904,6 +905,15 @@ export default function NewInteg({ allIntegURL }) { setFlow={setFlow} /> ) + case 'Fabman': + return ( + + ) case 'PCloud': return ( apiKey)) { + wp_send_json_error(__('API Key is required', 'bit-integrations'), 400); + } + + $header = [ + 'Authorization' => 'Bearer ' . $requestParams->apiKey, + 'Content-Type' => 'application/json' + ]; + + $apiEndpoint = 'https://fabman.io/api/v1/accounts'; + $apiResponse = HttpHelper::get($apiEndpoint, null, $header); + + if (is_wp_error($apiResponse)) { + wp_send_json_error($apiResponse->get_error_message(), 400); + } + + if (empty($apiResponse) || isset($apiResponse->error) || !\is_array($apiResponse) || !isset($apiResponse[0]->id)) { + wp_send_json_error(isset($apiResponse->error) ? $apiResponse->error : __('Invalid API credentials', 'bit-integrations'), 400); + } + + $accountId = $apiResponse[0]->id; + wp_send_json_success([ + 'accountId' => $accountId + ], 200); + } + + public static function fetchWorkspaces($requestParams) + { + if (empty($requestParams->apiKey)) { + wp_send_json_error(__('API Key is required', 'bit-integrations'), 400); + } + + $header = [ + 'Authorization' => 'Bearer ' . $requestParams->apiKey, + 'Content-Type' => 'application/json' + ]; + + $apiEndpoint = 'https://fabman.io/api/v1/spaces'; + $apiResponse = HttpHelper::get($apiEndpoint, null, $header); + + if (is_wp_error($apiResponse)) { + wp_send_json_error($apiResponse->get_error_message(), 400); + } + + if (empty($apiResponse) || isset($apiResponse->error) || !\is_array($apiResponse)) { + wp_send_json_error(isset($apiResponse->error) ? $apiResponse->error : __('Failed to fetch workspaces', 'bit-integrations'), 400); + } + + wp_send_json_success([ + 'workspaces' => $apiResponse + ], 200); + } + + public static function execute($integrationData, $fieldValues) + { + $integrationDetails = $integrationData->flow_details; + $apiKey = $integrationDetails->apiKey; + $selectedWorkspace = $integrationDetails->selectedWorkspace ?? null; + $accountId = $integrationDetails->accountId ?? null; + $actionName = $integrationDetails->actionName; + $integId = $integrationData->id; + $memberId = $integrationDetails->selectedMember ?? null; + $lockVersion = $integrationDetails->selectedLockVersion ?? null; + + if (\in_array($actionName, ['update_member', 'delete_member'])) { + if ($actionName === 'delete_member' || empty($memberId)) { + $email = self::getMappedValue($integrationDetails->field_map, 'emailAddress', $fieldValues); + if ($email) { + $memberData = self::fetchMemberByEmailInternal($apiKey, $email); + if ($memberData) { + $memberId = $memberData['memberId']; + $lockVersion = $memberData['lockVersion']; + } + } + } + } + + $recordApiHelper = new RecordApiHelper( + $apiKey, + $selectedWorkspace, + $accountId, + $integId, + $memberId, + $lockVersion + ); + + return $recordApiHelper->execute($actionName, $fieldValues, $integrationDetails); + } + + private static function getMappedValue($fieldMap, $targetField, $fieldValues) + { + if (empty($fieldMap)) { + return; + } + + foreach ($fieldMap as $map) { + if (!empty($map->fabmanFormField) && $map->fabmanFormField === $targetField) { + if ($map->formField === 'custom') { + return $map->customValue; + } + + return $fieldValues[$map->formField] ?? null; + } + } + } + + private static function fetchMemberByEmailInternal($apiKey, $email) + { + $header = [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json' + ]; + + $apiEndpoint = 'https://fabman.io/api/v1/members?q=' . urlencode($email); + $apiResponse = HttpHelper::get($apiEndpoint, null, $header); + + if (is_wp_error($apiResponse) || empty($apiResponse) || isset($apiResponse->error) || !\is_array($apiResponse)) { + return; + } + + if (isset($apiResponse[0])) { + return [ + 'memberId' => $apiResponse[0]->id, + 'lockVersion' => $apiResponse[0]->lockVersion + ]; + } + } +} diff --git a/includes/Actions/Fabman/RecordApiHelper.php b/includes/Actions/Fabman/RecordApiHelper.php new file mode 100644 index 00000000..3f88b48e --- /dev/null +++ b/includes/Actions/Fabman/RecordApiHelper.php @@ -0,0 +1,177 @@ +integrationID = $integrationID; + $this->apiKey = $apiKey; + $this->workspaceId = $workspaceId; + $this->accountId = $accountId; + $this->memberId = $memberId; + $this->lockVersion = $lockVersion; + $this->apiEndpoint = 'https://fabman.io/api/v1'; + } + + public function execute($actionName, $fieldValues, $integrationDetails) + { + $finalData = []; + $finalData['account'] = $this->accountId; + + if ($this->workspaceId) { + $finalData['space'] = $this->workspaceId; + } + + if ($this->memberId) { + $finalData['memberId'] = $this->memberId; + } + + if (!empty($integrationDetails->field_map)) { + foreach ($integrationDetails->field_map as $fieldMap) { + if (!empty($fieldMap->formField) && !empty($fieldMap->fabmanFormField)) { + $finalData[$fieldMap->fabmanFormField] = $fieldMap->formField === 'custom' + ? $fieldMap->customValue + : ($fieldValues[$fieldMap->formField] ?? null); + } + } + } + + switch ($actionName) { + case 'create_member': + $apiResponse = $this->createMember($finalData); + + break; + case 'update_member': + $apiResponse = $this->updateMember($finalData); + + break; + case 'delete_member': + $apiResponse = $this->deleteMember($finalData); + + break; + case 'create_spaces': + $apiResponse = $this->createSpace($finalData); + + break; + case 'update_spaces': + $apiResponse = $this->updateSpace($finalData); + + break; + default: + $apiResponse = new WP_Error( + 'INVALID_ACTION', + __('Invalid action name', 'bit-integrations') + ); + } + + if (is_wp_error($apiResponse) || (\is_object($apiResponse) && isset($apiResponse->error))) { + $status = 'error'; + } else { + $status = \in_array(HttpHelper::$responseCode, [200, 201, 204]) ? 'success' : 'error'; + } + + LogHandler::save($this->integrationID, ['type' => 'record', 'type_name' => $actionName], $status, $apiResponse); + + return $apiResponse; + } + + private function createMember($data) + { + unset($data['memberId']); + $apiEndpoint = $this->apiEndpoint . '/members'; + $header = $this->setHeaders(); + $apiResponse = HttpHelper::post($apiEndpoint, json_encode($data), $header); + + if (\is_wp_error($apiResponse)) { + return $apiResponse; + } + + if (empty($apiResponse) || isset($apiResponse->error)) { + return new WP_Error('API_ERROR', isset($apiResponse->error) ? $apiResponse->error : \__('Failed to create member', 'bit-integrations')); + } + + return HttpHelper::$responseCode === 201 ? 'Member Created Successfully' : 'Failed'; + } + + private function updateMember($data) + { + $data['lockVersion'] = $this->lockVersion; + + if (empty($this->memberId)) { + return new WP_Error('MISSING_MEMBER_ID', __('The email provided did not match any existing Fabman member.', 'bit-integrations')); + } + + $response = \apply_filters('btcbi_fabman_update_member', false, json_encode($data), $this->setHeaders(), $this->apiEndpoint, $this->memberId); + + return $this->handleFilterResponse($response); + } + + private function deleteMember() + { + if (empty($this->memberId)) { + return new WP_Error('MISSING_MEMBER_ID', __('The email provided did not match any existing Fabman member.', 'bit-integrations')); + } + + $response = \apply_filters('btcbi_fabman_delete_member', false, $this->setHeaders(), $this->apiEndpoint, $this->memberId); + + return $this->handleFilterResponse($response); + } + + private function createSpace($data) + { + unset($data['space']); + $response = \apply_filters('btcbi_fabman_create_space', false, json_encode($data), $this->setHeaders(), $this->apiEndpoint); + + return $this->handleFilterResponse($response); + } + + private function updateSpace($data) + { + $data['lockVersion'] = $this->lockVersion; + + if (empty($this->workspaceId)) { + return new WP_Error('MISSING_SPACE_ID', __('Please select a space to update.', 'bit-integrations')); + } + + $response = \apply_filters('btcbi_fabman_update_space', false, json_encode($data), $this->setHeaders(), $this->apiEndpoint, $this->workspaceId); + + return $this->handleFilterResponse($response); + } + + private function handleFilterResponse($response) + { + if (empty($response)) { + return (object) ['error' => \wp_sprintf(\__('%s plugin is not installed or activated', 'bit-integrations'), 'Bit Integration Pro')]; + } + + return $response; + } + + private function setHeaders(): array + { + return [ + 'Authorization' => 'Bearer ' . $this->apiKey, + 'Content-Type' => 'application/json' + ]; + } +} diff --git a/includes/Actions/Fabman/Routes.php b/includes/Actions/Fabman/Routes.php new file mode 100644 index 00000000..cc3118b5 --- /dev/null +++ b/includes/Actions/Fabman/Routes.php @@ -0,0 +1,11 @@ +